diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dff172e5..4ace1ca86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ on Columbus Labs QuotaKit releases and product-facing changes. - Overview: raise the merged provider limit from three to six. ### Changed +- Synced upstream CodexBar changes through `8ef86077e`, including preferred-currency conversion, xAI billing history, Claude weekly pacing, optional Crof quota alerts, and StepFun support while preserving QuotaKit release ownership, public URLs, config paths, CloudKit setup, and all build numbers. - CLI: redact stored credentials from `quotakit config dump` by default; use `--show-secrets` to reveal raw values. - Menu bar: remove status-item hover tooltips while retaining VoiceOver titles. - Cost displays: use consistent labels while preserving reported-versus-estimated provenance in settings and per-value hints. diff --git a/CodexBarMobile/CodexBarMobile/Design/ProviderBrandMark.swift b/CodexBarMobile/CodexBarMobile/Design/ProviderBrandMark.swift index a7d4ea3b4..ac3c3a074 100644 --- a/CodexBarMobile/CodexBarMobile/Design/ProviderBrandMark.swift +++ b/CodexBarMobile/CodexBarMobile/Design/ProviderBrandMark.swift @@ -59,6 +59,7 @@ enum ProviderBrandAsset { "vertexai", "warp", "windsurf", + "xai", "zai", "zed", "zenmux", diff --git a/CodexBarMobile/CodexBarMobile/Models/ProviderColorPalette.swift b/CodexBarMobile/CodexBarMobile/Models/ProviderColorPalette.swift index 8a3fa7e42..31e932e9f 100644 --- a/CodexBarMobile/CodexBarMobile/Models/ProviderColorPalette.swift +++ b/CodexBarMobile/CodexBarMobile/Models/ProviderColorPalette.swift @@ -139,6 +139,7 @@ enum ProviderColorPalette { (["deepgram"], RawColor(red: 0.49, green: 0.23, blue: 0.93)), (["aiand", "ai&"], RawColor(red: 226 / 255, green: 92 / 255, blue: 43 / 255)), (["zoommate"], RawColor(red: 64 / 255, green: 176 / 255, blue: 255 / 255)), + (["xai"], RawColor(red: 142 / 255, green: 142 / 255, blue: 160 / 255)), (["devin"], RawColor(red: 70 / 255, green: 180 / 255, blue: 130 / 255)), ] diff --git a/CodexBarMobile/CodexBarMobile/ProviderIcons.xcassets/ProviderIcon-xai.imageset/Contents.json b/CodexBarMobile/CodexBarMobile/ProviderIcons.xcassets/ProviderIcon-xai.imageset/Contents.json new file mode 100644 index 000000000..88a18caee --- /dev/null +++ b/CodexBarMobile/CodexBarMobile/ProviderIcons.xcassets/ProviderIcon-xai.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ProviderIcon-xai.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/CodexBarMobile/CodexBarMobile/ProviderIcons.xcassets/ProviderIcon-xai.imageset/ProviderIcon-xai.svg b/CodexBarMobile/CodexBarMobile/ProviderIcons.xcassets/ProviderIcon-xai.imageset/ProviderIcon-xai.svg new file mode 100644 index 000000000..f0a69128c --- /dev/null +++ b/CodexBarMobile/CodexBarMobile/ProviderIcons.xcassets/ProviderIcon-xai.imageset/ProviderIcon-xai.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/CodexBarMobile/CodexBarMobileTests/ProviderBrandAssetTests.swift b/CodexBarMobile/CodexBarMobileTests/ProviderBrandAssetTests.swift index 23fbb8784..420e5fb37 100644 --- a/CodexBarMobile/CodexBarMobileTests/ProviderBrandAssetTests.swift +++ b/CodexBarMobile/CodexBarMobileTests/ProviderBrandAssetTests.swift @@ -17,6 +17,7 @@ struct ProviderBrandAssetTests { #expect(ProviderBrandAsset.assetName(for: "neuralwatt") == "ProviderIcon-neuralwatt") #expect(ProviderBrandAsset.assetName(for: "qwencloud") == "ProviderIcon-qwencloud") #expect(ProviderBrandAsset.assetName(for: "zoommate") == "ProviderIcon-zoommate") + #expect(ProviderBrandAsset.assetName(for: "xai") == "ProviderIcon-xai") } @Test diff --git a/CodexBarMobile/CodexBarMobileTests/ProviderColorPaletteTests.swift b/CodexBarMobile/CodexBarMobileTests/ProviderColorPaletteTests.swift index 235706f18..f86ac9ef1 100644 --- a/CodexBarMobile/CodexBarMobileTests/ProviderColorPaletteTests.swift +++ b/CodexBarMobile/CodexBarMobileTests/ProviderColorPaletteTests.swift @@ -71,6 +71,7 @@ struct ProviderColorPaletteTests { ("longcat", 1, 209 / 255, 0), ("neuralwatt", 0.12, 0.72, 0.38), ("zoommate", 64 / 255, 176 / 255, 255 / 255), + ("xai", 142 / 255, 142 / 255, 160 / 255), ] for (provider, red, green, blue) in expected { @@ -93,6 +94,7 @@ struct ProviderColorPaletteTests { ("Qoder", "qoder"), ("Qwen Cloud", "qwencloud"), ("ZoomMate", "zoommate"), + ("xAI", "xai"), ] for (displayName, providerID) in pairs { @@ -148,7 +150,7 @@ private let knownDistinctProviders = [ "doubao", "sakana", "abacus", "mistral", "deepseek", "codebuff", "crof", "venice", "commandcode", "qoder", "stepfun", "bedrock", "grok", "groq", "llmproxy", "litellm", "deepgram", "crossmodel", "clinepass", "longcat", "deepinfra", "aiand", - "zenmux", "zoommate", + "zenmux", "zoommate", "xai", ] private func expectDistinctColors( diff --git a/CodexBarMobile/CodexBarMobileTests/QuotaProviderListTests.swift b/CodexBarMobile/CodexBarMobileTests/QuotaProviderListTests.swift index 482902c98..e4605bd7b 100644 --- a/CodexBarMobile/CodexBarMobileTests/QuotaProviderListTests.swift +++ b/CodexBarMobile/CodexBarMobileTests/QuotaProviderListTests.swift @@ -18,7 +18,7 @@ import Testing @Suite("Quota provider list") struct QuotaProviderListTests { @Test - func `Total count is 58 including Qwen Cloud and ZoomMate`() { + func `Total count is 59 including xAI`() { // Outcome: 25 → 27 in iOS 1.5.0 (Abacus + Mistral) → // 38 in iOS 1.6.0 (11 new from Mac v0.24+v0.25 catch-up) → // 40 in iOS 1.7.0 (2 new from Mac v0.26.0: moonshot + bedrock) → @@ -29,17 +29,17 @@ struct QuotaProviderListTests { // 49 in iOS 1.10.0 (Sakana AI from upstream v0.36.x) → // 50 after Qoder, 51 after Sub2API, 52 after ZenMux, 54 after // ClinePass and LongCat, 55 after Neuralwatt, 56 after DeepInfra, - // then 58 after Qwen Cloud and ZoomMate. + // then 58 after Qwen Cloud and ZoomMate, and 59 after xAI. // ai& is spend-only and has no quota transitions, so it intentionally // does not consume three CloudKit quota-zone subscriptions. // If this number shifts without matching upstream updates, // the push-subscription set drifts out of sync with Mac's // actual emitting providers. - #expect(QuotaProviderList.providers.count == 58) + #expect(QuotaProviderList.providers.count == 59) } @Test - func `Subscription zone count is 174 (58 providers × 3 states)`() { + func `Subscription zone count is 177 (59 providers × 3 states)`() { // iOS 1.5.0: 27 × 2 = 54 zones. // iOS 1.6.0 / Mac 0.25.2: 38 × 3 (depleted/restored/warning) = 114. // iOS 1.7.0 / Mac 0.26.2: 40 × 3 = 120 zones (+moonshot, +bedrock). @@ -53,10 +53,11 @@ struct QuotaProviderListTests { // Neuralwatt catch-up: 55 × 3 = 165 zones. // DeepInfra catch-up: 56 × 3 = 168 zones. // Qwen Cloud + ZoomMate catch-up: 58 × 3 = 174 zones. + // xAI platform billing: 59 × 3 = 177 zones. // `QuotaTransitionSubscriptions.makeConfigs()` builds one // `SubConfig` per (provider, state) — pinning here so a // future state addition/removal can't drift silently. - #expect(QuotaProviderList.providers.count * 3 == 174) + #expect(QuotaProviderList.providers.count * 3 == 177) } @Test @@ -132,7 +133,7 @@ struct QuotaProviderListTests { /// re-create them all. Verify Abacus + Mistral + the 11 v0.24/v0.25 /// additions are appended at the END (additive), not interleaved. @Test - func `Cause: new providers through ZoomMate are appended at the tail`() { + func `Cause: new providers through xAI are appended at the tail`() { let providers = QuotaProviderList.providers // Providers are append-only so per-(provider,state) CK subscription // IDs stay stable across upgrades. Pin the recent tail so a careless @@ -146,12 +147,13 @@ struct QuotaProviderListTests { // - ZenMux, ClinePass, and LongCat occupy positions [51...53]. // - DeepInfra occupies position [55]. // - Qwen Cloud and ZoomMate occupy positions [56...57]. - let tail = providers.suffix(18).map(\.id) + // - xAI occupies position [58]. + let tail = providers.suffix(19).map(\.id) #expect(tail == [ "grok", "groq", "elevenlabs", "deepgram", "llmproxy", "azureopenai", "alibabatokenplan", "t3chat", "sakana", "qoder", "sub2api", "zenmux", - "clinepass", "longcat", "neuralwatt", "deepinfra", "qwencloud", "zoommate", - ], "provider catch-up additions through ZoomMate must stay at the tail in this order") + "clinepass", "longcat", "neuralwatt", "deepinfra", "qwencloud", "zoommate", "xai", + ], "provider catch-up additions through xAI must stay at the tail in this order") } // MARK: - iOS 1.6.0 · v0.24+v0.25 catch-up presence @@ -250,9 +252,9 @@ struct QuotaProviderListTests { /// (Zone count is providers × 3 states since iOS 1.6.0 added the /// `warning` state alongside `depleted`/`restored`.) @Test - func `Cause: catalog 58/174 numbers match the actual list`() { - #expect(QuotaProviderList.providers.count == 58) - #expect(QuotaProviderList.providers.count * 3 == 174) + func `Cause: catalog 59/177 numbers match the actual list`() { + #expect(QuotaProviderList.providers.count == 59) + #expect(QuotaProviderList.providers.count * 3 == 177) } @Test diff --git a/README.md b/README.md index 875fba670..b11718b04 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ Provider setup notes and Mac provider internals live in [docs/providers.md](docs - [Moonshot / Kimi API](docs/moonshot.md) — API key for Moonshot/Kimi API account balance tracking. - [Venice](docs/venice.md) — API key for DIEM or USD balance tracking. - [Codebuff](docs/codebuff.md) — API token (or `~/.config/manicode/credentials.json`) for credit balance + weekly rate limit. -- [Crof](docs/crof.md) — API key for dollar credit balance and request quota tracking. +- [Crof](docs/crof.md) — API key for dollar credit balance and optional request quota tracking. - [Command Code](docs/command-code.md) — Browser or manual cookies for monthly USD credits from Command Code billing. - [Qoder](docs/qoder.md) — Browser or manual cookies for Qoder big model credit usage. - [StepFun](docs/stepfun.md) — Username + password login for Step Plan rate limits (5‑hour + weekly windows) and subscription plan name. @@ -127,6 +127,7 @@ Provider setup notes and Mac provider internals live in [docs/providers.md](docs - [LongCat](docs/providers.md) — Browser or manual-cookie usage for LongCat plan quotas. - [Neuralwatt](docs/neuralwatt.md) — API key for subscription kWh usage and prepaid credit balance. - [ZenMux](docs/zenmux.md) — Management API key for rolling five-hour and seven-day quota windows plus PAYG balance. +- [xAI](docs/xai.md) — Management API key + team ID for prepaid credit balance and daily platform spend. - Open to new providers: [provider authoring guide](docs/provider.md). ## Upstream And Credits diff --git a/Shared/Notifications/QuotaProviderList.swift b/Shared/Notifications/QuotaProviderList.swift index 274942e85..a2aba90ac 100644 --- a/Shared/Notifications/QuotaProviderList.swift +++ b/Shared/Notifications/QuotaProviderList.swift @@ -128,6 +128,9 @@ public enum QuotaProviderList { // remain stable. Provider(id: "qwencloud", displayName: "Qwen Cloud"), Provider(id: "zoommate", displayName: "ZoomMate"), + // xAI platform billing. Appended so all existing CloudKit subscription + // identifiers remain stable. + Provider(id: "xai", displayName: "xAI"), ] /// Returns the CloudKit zone name for a given `(providerID, state)`. The diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index d1d168c25..810fdeca7 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -429,6 +429,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { guard let settings = self?.settings else { return } AdaptiveActivityConsentPresenter.presentIfNeeded(settings: settings) AppNotifications.shared.requestAuthorizationOnStartup() + // A persisted non-USD choice opts into the daily exchange-rate refresh. The service + // returns before networking for the default USD setting and Auto. + guard CurrencyExchange.requiresLiveRates( + preferredCurrencyCode: settings.preferredCurrencyCode) + else { return } + let ratesChanged = await CurrencyExchange.shared.fetchLatestRatesIfNeeded( + preferredCurrencyCode: settings.preferredCurrencyCode) + if ratesChanged { + NotificationCenter.default.post(name: .codexbarCurrencyExchangeRatesDidChange, object: nil) + } } KeyboardShortcuts.onKeyUp(for: .openMenu) { [weak self] in // KeyboardShortcuts dispatches both normal and menu-tracking hotkeys on the main event loop. diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index bdc5c21cc..bf7d7cc4e 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -216,17 +216,22 @@ extension UsageMenuCardView.Model { return self.costHistoryInlineDashboard( provider: input.provider, snapshot: tokenSnapshot, - comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled) + comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled, + preferredCurrencyCode: input.preferredCurrencyCode) } if input.provider == .claude, let usage = input.snapshot?.claudeAdminAPIUsage { - return Self.claudeAdminAPIInlineDashboard(usage) + return Self.claudeAdminAPIInlineDashboard( + usage, + preferredCurrencyCode: input.preferredCurrencyCode) } if input.provider == .openrouter, let usage = input.snapshot?.openRouterUsage { - return Self.openRouterInlineDashboard(usage) + return Self.openRouterInlineDashboard( + usage, + preferredCurrencyCode: input.preferredCurrencyCode) } if input.provider == .zai, let modelUsage = input.snapshot?.zaiUsage?.modelUsage @@ -269,7 +274,8 @@ extension UsageMenuCardView.Model { return Self.costHistoryInlineDashboard( provider: input.provider, snapshot: tokenSnapshot, - comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled) + comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled, + preferredCurrencyCode: input.preferredCurrencyCode) } return nil } @@ -326,7 +332,7 @@ extension UsageMenuCardView.Model { } static func usesProviderCostHistoryAsPrimaryDashboard(_ provider: UsageProvider) -> Bool { - provider == .openai || provider == .mistral || provider == .groq + provider == .openai || provider == .mistral || provider == .groq || provider == .xai } static func primaryCostHistorySnapshot(input: Input) -> CostUsageTokenSnapshot? { @@ -346,6 +352,11 @@ extension UsageMenuCardView.Model { return projected } return input.snapshot == nil ? input.tokenSnapshot : nil + case .xai: + if let projected = input.snapshot?.xaiUsage?.costHistorySnapshot() { + return projected + } + return input.snapshot == nil ? input.tokenSnapshot : nil default: return input.tokenSnapshot } @@ -414,8 +425,26 @@ extension UsageMenuCardView.Model { private static func costHistoryInlineDashboard( provider: UsageProvider, snapshot: CostUsageTokenSnapshot, - comparisonPeriodsEnabled: Bool) -> InlineUsageDashboardModel + comparisonPeriodsEnabled: Bool, + preferredCurrencyCode: String) -> InlineUsageDashboardModel { + let displayCurrencyCode = UsageFormatter.convertedCost( + 0, + preferredCurrency: preferredCurrencyCode, + providerCurrency: snapshot.currencyCode).currencyCode + func convertedValue(_ value: Double) -> Double { + UsageFormatter.convertedCost( + value, + preferredCurrency: preferredCurrencyCode, + providerCurrency: snapshot.currencyCode).value + } + func convertedString(_ value: Double) -> String { + UsageFormatter.convertedCostString( + value, + preferredCurrency: preferredCurrencyCode, + providerCurrency: snapshot.currencyCode) + } + let historyDays = max(1, min(365, snapshot.historyDays)) let defaultHistoryTitle = snapshot.historyLabel ?? (historyDays == 1 @@ -454,8 +483,8 @@ extension UsageMenuCardView.Model { return InlineUsageDashboardModel.Point( id: entry.date, label: Self.shortDayLabel(entry.date), - value: cost, - accessibilityValue: "\(entry.date): \(Self.costString(cost, currencyCode: snapshot.currencyCode))") + value: convertedValue(cost), + accessibilityValue: "\(entry.date): \(convertedString(cost))") } let latest = CostUsageTokenSnapshot.latestEntry(in: snapshot.daily) let usesLatestPrimary = provider == .bedrock || provider == .mistral @@ -463,7 +492,14 @@ extension UsageMenuCardView.Model { var details: [String] = [] if comparisonPeriodsEnabled { details.append(contentsOf: snapshot.comparisonSummaries().map { - Self.costWindowLine(summary: $0, currencyCode: snapshot.currencyCode) + let label = Self.costHistoryWindowLabel(days: $0.days) + let cost = $0.totalCostUSD.map(convertedString) ?? "—" + guard let totalTokens = $0.totalTokens else { return "\(label): \(cost)" } + return String( + format: L("%@: %@ · %@ tokens"), + label, + cost, + UsageFormatter.tokenCountString(totalTokens)) }) } if let topModel = Self.topCostModel(from: snapshot.daily) { @@ -494,12 +530,12 @@ extension UsageMenuCardView.Model { var kpis = [ InlineUsageDashboardModel.KPI( title: usesLatestPrimary ? L("Latest") : L("Today"), - value: primaryCostUSD.map { Self.costString($0, currencyCode: snapshot.currencyCode) } ?? "—", + value: primaryCostUSD.map(convertedString) ?? "—", emphasis: true), .init( title: historyTitle, value: snapshot.last30DaysCostUSD - .map { Self.costString($0, currencyCode: snapshot.currencyCode) } ?? "—", + .map(convertedString) ?? "—", emphasis: false), ] let tokenHistoryKPI = InlineUsageDashboardModel.KPI( @@ -518,17 +554,17 @@ extension UsageMenuCardView.Model { kpis.insert( .init( title: "Cursor-metered", - value: Self.costString(meteredCostUSD, currencyCode: snapshot.currencyCode), + value: convertedString(meteredCostUSD), emphasis: true), at: 0) } var model = InlineUsageDashboardModel( accessibilityLabel: accessibilityLabel, - valueStyle: Self.costValueStyle(currencyCode: snapshot.currencyCode), + valueStyle: Self.costValueStyle(currencyCode: displayCurrencyCode), kpis: kpis, points: points, detailLines: details) - model.currencyCode = snapshot.currencyCode + model.currencyCode = displayCurrencyCode return model } @@ -553,9 +589,27 @@ extension UsageMenuCardView.Model { ] } - fileprivate static func claudeAdminAPIInlineDashboard(_ usage: ClaudeAdminAPIUsageSnapshot) + fileprivate static func claudeAdminAPIInlineDashboard( + _ usage: ClaudeAdminAPIUsageSnapshot, + preferredCurrencyCode: String = "auto") -> InlineUsageDashboardModel { + let displayCurrencyCode = UsageFormatter.convertedCost( + 0, + preferredCurrency: preferredCurrencyCode, + providerCurrency: "USD").currencyCode + func convertedValue(_ value: Double) -> Double { + UsageFormatter.convertedCost( + value, + preferredCurrency: preferredCurrencyCode, + providerCurrency: "USD").value + } + func convertedString(_ value: Double) -> String { + UsageFormatter.convertedCostString( + value, + preferredCurrency: preferredCurrencyCode, + providerCurrency: "USD") + } let today = usage.currentDay let last7 = usage.last7Days let last30 = usage.last30Days @@ -563,8 +617,8 @@ extension UsageMenuCardView.Model { InlineUsageDashboardModel.Point( id: $0.day, label: Self.shortDayLabel($0.day), - value: $0.costUSD, - accessibilityValue: "\($0.day): \(UsageFormatter.usdString($0.costUSD))") + value: convertedValue($0.costUSD), + accessibilityValue: "\($0.day): \(convertedString($0.costUSD))") } var details = [ "30d: \(UsageFormatter.tokenCountString(last30.totalTokens)) \(L("tokens"))", @@ -575,13 +629,13 @@ extension UsageMenuCardView.Model { } var model = InlineUsageDashboardModel( accessibilityLabel: L("Claude Admin API 30 day spend trend"), - valueStyle: .currencyUSD, + valueStyle: Self.costValueStyle(currencyCode: displayCurrencyCode), kpis: [ - .init(title: L("Today"), value: UsageFormatter.usdString(today.costUSD), emphasis: true), - .init(title: L("7d spend"), value: UsageFormatter.usdString(last7.costUSD), emphasis: false), + .init(title: L("Today"), value: convertedString(today.costUSD), emphasis: true), + .init(title: L("7d spend"), value: convertedString(last7.costUSD), emphasis: false), .init( title: L("30d spend"), - value: UsageFormatter.usdString(last30.costUSD), + value: convertedString(last30.costUSD), emphasis: false), .init( title: L("Today tokens"), @@ -590,11 +644,30 @@ extension UsageMenuCardView.Model { ], points: points, detailLines: details) - model.currencyCode = "USD" + model.currencyCode = displayCurrencyCode return model } - private static func openRouterInlineDashboard(_ usage: OpenRouterUsageSnapshot) -> InlineUsageDashboardModel? { + private static func openRouterInlineDashboard( + _ usage: OpenRouterUsageSnapshot, + preferredCurrencyCode: String) -> InlineUsageDashboardModel? + { + let displayCurrencyCode = UsageFormatter.convertedCost( + 0, + preferredCurrency: preferredCurrencyCode, + providerCurrency: "USD").currencyCode + func convertedValue(_ value: Double) -> Double { + UsageFormatter.convertedCost( + value, + preferredCurrency: preferredCurrencyCode, + providerCurrency: "USD").value + } + func convertedString(_ value: Double) -> String { + UsageFormatter.convertedCostString( + value, + preferredCurrency: preferredCurrencyCode, + providerCurrency: "USD") + } let periodValues: [(String, String, Double?)] = [ ("day", L("Today"), usage.keyUsageDaily), ("week", L("Week"), usage.keyUsageWeekly), @@ -602,11 +675,11 @@ extension UsageMenuCardView.Model { ] let points = periodValues.compactMap { id, label, value -> InlineUsageDashboardModel.Point? in guard let value else { return nil } - let formattedValue = Self.openRouterCurrencyString(value) + let formattedValue = convertedString(value) return InlineUsageDashboardModel.Point( id: id, label: label, - value: value, + value: convertedValue(value), accessibilityValue: String(format: L("%@: %@"), label, formattedValue)) } guard !points.isEmpty else { return nil } @@ -620,7 +693,7 @@ extension UsageMenuCardView.Model { details.append(String( format: L("%@: %@"), L("Key remaining"), - Self.openRouterCurrencyString(remaining))) + convertedString(remaining))) } case .noLimitConfigured: details.append(L("No limit set for the API key")) @@ -629,25 +702,25 @@ extension UsageMenuCardView.Model { } var model = InlineUsageDashboardModel( accessibilityLabel: L("OpenRouter API key spend trend"), - valueStyle: .currencyUSD, + valueStyle: Self.costValueStyle(currencyCode: displayCurrencyCode), kpis: [ - .init(title: L("Balance"), value: Self.openRouterCurrencyString(usage.balance), emphasis: true), + .init(title: L("Balance"), value: convertedString(usage.balance), emphasis: true), .init( title: L("Today"), - value: usage.keyUsageDaily.map(Self.openRouterCurrencyString) ?? "—", + value: usage.keyUsageDaily.map(convertedString) ?? "—", emphasis: false), .init( title: L("Week"), - value: usage.keyUsageWeekly.map(Self.openRouterCurrencyString) ?? "—", + value: usage.keyUsageWeekly.map(convertedString) ?? "—", emphasis: false), .init( title: L("Month"), - value: usage.keyUsageMonthly.map(Self.openRouterCurrencyString) ?? "—", + value: usage.keyUsageMonthly.map(convertedString) ?? "—", emphasis: false), ], points: points, detailLines: details) - model.currencyCode = "USD" + model.currencyCode = displayCurrencyCode return model } @@ -810,10 +883,6 @@ extension UsageMenuCardView.Model { }?.key } - private static func openRouterCurrencyString(_ value: Double) -> String { - String(format: "$%.2f", value) - } - private static func minimaxCashString(_ value: Double) -> String { String(format: "%.2f", max(0, value)) } diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 12312714f..3b47ab5d8 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -90,13 +90,20 @@ extension UsageMenuCardView.Model.ProviderCostSection { } extension UsageMenuCardView.Model { - static func sakanaPayAsYouGoSection(_ usage: SakanaPayAsYouGoSnapshot?) -> ProviderCostSection? { + static func sakanaPayAsYouGoSection( + _ usage: SakanaPayAsYouGoSnapshot?, + preferredCurrencyCode: String = "auto") -> ProviderCostSection? + { guard let usage else { return nil } return ProviderCostSection( title: L("Extra usage"), percentUsed: nil, spendLine: "\(L("Balance")): \(usage.balanceDetail)", - percentLine: usage.periodUsageTotal.map { "\(L("Usage")): \(UsageFormatter.usdString($0))" }) + percentLine: usage.periodUsageTotal.map { value in + let cost = UsageFormatter.convertedCostString( + value, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + return "\(L("Usage")): \(cost)" + }) } static func isRequiredOpenCodeZenBalance(_ snapshot: UsageSnapshot?) -> Bool { @@ -116,13 +123,14 @@ extension UsageMenuCardView.Model { metadata: ProviderMetadata, snapshot: UsageSnapshot?, credits: CreditsSnapshot?, - error: String?) -> String? + error: String?, + preferredCurrencyCode: String = "auto") -> String? { guard metadata.supportsCredits else { return nil } if metadata.id == .codex, credits == nil, error == nil { return nil } if metadata.id == .amp, let ampUsage = snapshot?.ampUsage, - let ampCredits = self.ampCreditsLine(ampUsage) + let ampCredits = self.ampCreditsLine(ampUsage, preferredCurrencyCode: preferredCurrencyCode) { return ampCredits } @@ -158,15 +166,20 @@ extension UsageMenuCardView.Model { return parts.joined(separator: " · ") } - private static func ampCreditsLine(_ usage: AmpUsageDetails) -> String? { + private static func ampCreditsLine( + _ usage: AmpUsageDetails, + preferredCurrencyCode: String = "auto") -> String? + { var lines: [String] = [] if let individualCredits = usage.individualCredits { - lines.append( - "\(L("Individual credits")): \(UsageFormatter.currencyString(individualCredits, currencyCode: "USD"))") + let cost = UsageFormatter.convertedCostString( + individualCredits, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + lines.append("\(L("Individual credits")): \(cost)") } lines.append(contentsOf: usage.workspaceBalances.map { workspace in "\(L("Workspace")) \(workspace.name): " + - UsageFormatter.currencyString(workspace.remaining, currencyCode: "USD") + UsageFormatter.convertedCostString( + workspace.remaining, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") }) return lines.isEmpty ? nil : lines.joined(separator: "\n") } @@ -176,7 +189,8 @@ extension UsageMenuCardView.Model { enabled: Bool, comparisonPeriodsEnabled: Bool, snapshot: CostUsageTokenSnapshot?, - error: String?) -> TokenUsageSection? + error: String?, + preferredCurrencyCode: String = "auto") -> TokenUsageSection? { guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else { return nil @@ -185,7 +199,10 @@ extension UsageMenuCardView.Model { guard let snapshot else { return nil } let sessionCost = snapshot.sessionCostUSD.map { - UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) + UsageFormatter.convertedCostString( + $0, + preferredCurrency: preferredCurrencyCode, + providerCurrency: snapshot.currencyCode) } ?? "—" let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) } let sessionLabel = if provider == .bedrock || provider == .mistral { @@ -201,7 +218,10 @@ extension UsageMenuCardView.Model { }() let monthCost = snapshot.last30DaysCostUSD.map { - UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) + UsageFormatter.convertedCostString( + $0, + preferredCurrency: preferredCurrencyCode, + providerCurrency: snapshot.currencyCode) } ?? "—" let fallbackTokens = snapshot.daily.compactMap(\.totalTokens).reduce(0, +) let monthTokensValue = snapshot.last30DaysTokens ?? (fallbackTokens > 0 ? fallbackTokens : nil) @@ -225,7 +245,10 @@ extension UsageMenuCardView.Model { // Plan-metered spend over the same window (what the provider actually deducts); // only providers that report it (currently Cursor) populate `meteredCostUSD`. let meteredLine: String? = snapshot.meteredCostUSD.map { - let amount = UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) + let amount = UsageFormatter.convertedCostString( + $0, + preferredCurrency: preferredCurrencyCode, + providerCurrency: snapshot.currencyCode) return String(format: L("Cursor-metered: %@ (%@)"), amount, windowLabel.lowercased()) } let err = (error?.isEmpty ?? true) ? nil : error @@ -235,7 +258,12 @@ extension UsageMenuCardView.Model { meteredLine: meteredLine, comparisonLines: comparisonPeriodsEnabled ? snapshot.comparisonSummaries().map { - Self.costWindowLine(summary: $0, currencyCode: snapshot.currencyCode) + Self.costWindowLine( + summary: $0, + currencyCode: UsageFormatter.effectiveCurrencyCode( + preferred: preferredCurrencyCode, + providerCurrency: snapshot.currencyCode), + sourceCurrencyCode: snapshot.currencyCode) } : [], hintLine: Self.tokenUsageHint(provider: provider), @@ -243,10 +271,17 @@ extension UsageMenuCardView.Model { errorCopyText: (error?.isEmpty ?? true) ? nil : error) } - static func costWindowLine(summary: CostUsageWindowSummary, currencyCode: String) -> String { + static func costWindowLine( + summary: CostUsageWindowSummary, + currencyCode: String, + sourceCurrencyCode: String? = nil) -> String + { let label = Self.costHistoryWindowLabel(days: summary.days) let cost = summary.totalCostUSD.map { - UsageFormatter.currencyString($0, currencyCode: currencyCode) + UsageFormatter.convertedCostString( + $0, + preferredCurrency: currencyCode, + providerCurrency: sourceCurrencyCode ?? currencyCode) } ?? "—" guard let totalTokens = summary.totalTokens else { return "\(label): \(cost)" } return String( @@ -374,7 +409,8 @@ extension UsageMenuCardView.Model { static func providerCostSection( provider: UsageProvider, cost: ProviderCostSnapshot?, - isClaudeAdminAPI: Bool = false) -> ProviderCostSection? + isClaudeAdminAPI: Bool = false, + preferredCurrencyCode: String = "auto") -> ProviderCostSection? { if provider == .manus { return nil @@ -382,8 +418,16 @@ extension UsageMenuCardView.Model { guard let cost else { return nil } guard provider != .synthetic else { return nil } + /// Formats a cost value using the user's currency preference. + func formatCost(_ value: Double, providerCurrency: String? = nil) -> String { + UsageFormatter.convertedCostString( + value, + preferredCurrency: preferredCurrencyCode, + providerCurrency: providerCurrency ?? cost.currencyCode) + } + if provider == .factory || provider == .devin, cost.period == "Extra usage balance" { - let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + let balance = formatCost(cost.used) return ProviderCostSection( title: L("Extra usage"), percentUsed: nil, @@ -392,7 +436,7 @@ extension UsageMenuCardView.Model { } if provider == .opencodego, cost.period == "Zen balance" { - let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + let balance = formatCost(cost.used) return ProviderCostSection( title: L("Zen balance"), percentUsed: nil, @@ -409,8 +453,17 @@ extension UsageMenuCardView.Model { percentLine: nil) } + if provider == .xai, cost.period == "Prepaid credits" { + let balance = formatCost(cost.used) + return ProviderCostSection( + title: L("Credits"), + percentUsed: nil, + spendLine: "\(L("Posted balance")): \(balance)", + percentLine: L("Ledger may lag current-cycle spend")) + } + if provider == .zenmux || provider == .neuralwatt { - let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + let balance = formatCost(cost.used) return ProviderCostSection( title: L("metric_mistral_payg"), percentUsed: nil, @@ -420,7 +473,7 @@ extension UsageMenuCardView.Model { if provider == .claude { if isClaudeAdminAPI { - let spend = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + let spend = formatCost(cost.used) let periodLabel = Self.localizedPeriodLabel(cost.period ?? "Last 30 days") return ProviderCostSection( title: L("API spend"), @@ -431,7 +484,7 @@ extension UsageMenuCardView.Model { if cost.limit <= 0 { guard let balance = cost.balance else { return nil } - let value = UsageFormatter.currencyString(balance, currencyCode: cost.currencyCode) + let value = formatCost(balance) return ProviderCostSection( title: L("Credits"), percentUsed: nil, @@ -441,12 +494,12 @@ extension UsageMenuCardView.Model { showsInProviderDetails: false) } - let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) - let limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) + let used = formatCost(cost.used) + let limit = formatCost(cost.limit) let percentUsed = Self.clamped((cost.used / cost.limit) * 100) let periodLabel = Self.localizedPeriodLabel(cost.period ?? "This month") let balanceLine = cost.balance.map { - "\(L("Balance")): \(UsageFormatter.currencyString($0, currencyCode: cost.currencyCode))" + "\(L("Balance")): \(formatCost($0))" } return ProviderCostSection( title: L("Extra usage"), @@ -460,7 +513,7 @@ extension UsageMenuCardView.Model { if provider == .openai || provider == .litellm || provider == .aiand, cost.limit <= 0 { - let spend = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + let spend = formatCost(cost.used) let periodLabel = Self.localizedPeriodLabel(cost.period ?? "Last 30 days") return ProviderCostSection( title: L("API spend"), @@ -474,7 +527,7 @@ extension UsageMenuCardView.Model { } if provider == .clawrouter, cost.limit <= 0 { - let spend = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + let spend = formatCost(cost.used) return ProviderCostSection( title: "ClawRouter spend", percentUsed: nil, @@ -490,16 +543,16 @@ extension UsageMenuCardView.Model { if provider == .clawrouter { title = "Monthly budget" - used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) - limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) + used = formatCost(cost.used) + limit = formatCost(cost.limit) } else if cost.currencyCode == "Quota" { title = L("Quota usage") used = String(format: "%.0f", cost.used) limit = String(format: "%.0f", cost.limit) } else { title = L("Extra usage") - used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) - limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) + used = formatCost(cost.used) + limit = formatCost(cost.limit) } let percentUsed = Self.clamped((cost.used / cost.limit) * 100) @@ -509,7 +562,7 @@ extension UsageMenuCardView.Model { // account's own contribution underneath it. let personalSpendLine: String? = cost.personalUsed.flatMap { personal in personal > 0 - ? "\(L("Your spend")): \(UsageFormatter.currencyString(personal, currencyCode: cost.currencyCode))" + ? "\(L("Your spend")): \(formatCost(personal))" : nil } diff --git a/Sources/CodexBar/MenuCardView+Kiro.swift b/Sources/CodexBar/MenuCardView+Kiro.swift index f9f61a9c0..c9262caa3 100644 --- a/Sources/CodexBar/MenuCardView+Kiro.swift +++ b/Sources/CodexBar/MenuCardView+Kiro.swift @@ -29,7 +29,11 @@ extension UsageMenuCardView.Model { if overagesEnabled, let estimatedOverageCostUSD = input.snapshot?.kiroUsage?.estimatedOverageCostUSD { - notes.append("\(L("Overage cost")): \(UsageFormatter.usdString(estimatedOverageCostUSD))") + let costStr = UsageFormatter.convertedCostString( + estimatedOverageCostUSD, + preferredCurrency: input.preferredCurrencyCode, + providerCurrency: "USD") + notes.append("\(L("Overage cost")): \(costStr)") } return notes } diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 802ef1760..52fdba81e 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -9,6 +9,180 @@ extension UsageMenuCardView.Model { let paceOnTop: Bool } + struct PrimaryMetricPresentation { + var statusText: String? + var resetText: String? + var detailText: String? + var detailLeft: String? + var detailRight: String? + var pacePercent: Double? + var paceOnTop = true + } + + static func applyPrimaryQuotaPresentation( + _ presentation: inout PrimaryMetricPresentation, + input: Input, + primary: RateWindow, + openRouterQuotaDetail: String?) + { + if input.provider == .openrouter, let openRouterQuotaDetail { + presentation.resetText = openRouterQuotaDetail + } + if [.copilot, .zenmux].contains(input.provider), + let detail = Self.trimmedResetDescription(primary) + { + presentation.detailLeft = detail + } + guard input.provider == .crof, + let detail = Self.trimmedResetDescription(primary) + else { return } + if input.snapshot?.secondary != nil { + presentation.detailRight = detail + } else { + presentation.detailText = detail + } + } + + static func applyPrimaryBalancePresentation( + _ presentation: inout PrimaryMetricPresentation, + input: Input, + primary: RateWindow) + { + if [.warp, .kilo, .mimo, .deepseek, .deepinfra, .qoder, .mistral, .neuralwatt, .litellm, .chutes] + .contains(input.provider), + let detail = nonEmptyResetDescription(primary) + { + presentation.detailText = detail + } + if let balance = Self.poeBalanceDetailText(input: input) { + presentation.detailText = balance + } + if input.provider == .kiro, + let kiroUsage = input.snapshot?.kiroUsage, + kiroUsage.creditsTotal > 0 + { + let remaining = UsageFormatter.kiroCreditNumber(kiroUsage.creditsRemaining) + let total = UsageFormatter.kiroCreditNumber(kiroUsage.creditsTotal) + presentation.detailLeft = String(format: L("%@ of %@ credits left"), remaining, total) + } + if input.provider == .alibaba || input.provider == .alibabatokenplan || input.provider == .manus, + let detail = Self.nonEmptyResetDescription(primary) + { + presentation.detailText = detail + if input.provider == .manus { + presentation.resetText = nil + } + } + } + + static func applyPrimaryResetPresentation( + _ presentation: inout PrimaryMetricPresentation, + input: Input, + primary: RateWindow) + { + if input.provider == .sub2api { + presentation.resetText = primary.resetDescription + } + if [.warp, .kilo, .mimo, .deepseek, .deepinfra, .qoder, .mistral, .neuralwatt, .litellm, .zenmux, .chutes] + .contains(input.provider), + primary.resetsAt == nil + { + presentation.resetText = nil + } + if input.provider == .crof, input.snapshot?.secondary == nil { + presentation.resetText = nil + } + } + + static func applyPrimaryPacePresentation( + _ presentation: inout PrimaryMetricPresentation, + input: Input, + primary: RateWindow) + { + if let paceDetail = sessionPaceDetail( + provider: input.provider, + window: primary, + now: input.now, + showUsed: input.usageBarsShowUsed) + { + self.apply(paceDetail, to: &presentation) + } + if input.provider == .abacus { + if let detail = Self.nonEmptyResetDescription(primary) { + presentation.detailText = detail + } + if primary.resetsAt == nil { + presentation.resetText = nil + } + if let pace = input.weeklyPace, + let paceDetail = Self.weeklyPaceDetail( + provider: input.provider, + window: primary, + now: input.now, + pace: pace, + showUsed: input.usageBarsShowUsed) + { + Self.apply(paceDetail, to: &presentation) + } + } else if let paceDetail = Self.resetWindowPaceDetail( + window: primary, + input: input, + pace: input.provider == .kimi ? input.weeklyPace : nil) + { + Self.apply(paceDetail, to: &presentation) + } + } + + static func applyPrimaryFinalOverrides( + _ presentation: inout PrimaryMetricPresentation, + input: Input, + primary: RateWindow) + { + // Legacy request-based Cursor plans surface the raw used/limit quota on its own line. + if input.provider == .cursor, let requests = input.snapshot?.cursorRequests { + presentation.detailText = String( + format: L("Request quota: %@ / %@"), + "\(requests.used)", + "\(requests.limit)") + } + if input.provider == .synthetic, + let regen = Self.syntheticRollingRegenDetail( + window: primary, + now: input.now, + showUsed: input.usageBarsShowUsed) + { + presentation.resetText = regen.resetText + Self.apply(regen.pace, to: &presentation) + } + let usesBalanceStatusText = input.provider == .deepseek || input.provider == .deepinfra || + (input.provider == .crof && input.snapshot?.secondary == nil) + if usesBalanceStatusText { + presentation.statusText = presentation.detailText + presentation.detailText = nil + } + } + + private static func apply(_ paceDetail: PaceDetail, to presentation: inout PrimaryMetricPresentation) { + presentation.detailLeft = paceDetail.leftLabel + presentation.detailRight = paceDetail.rightLabel + presentation.pacePercent = paceDetail.pacePercent + presentation.paceOnTop = paceDetail.paceOnTop + } + + private static func nonEmptyResetDescription(_ window: RateWindow) -> String? { + guard let detail = window.resetDescription, + !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return nil } + return detail + } + + private static func trimmedResetDescription(_ window: RateWindow) -> String? { + guard let detail = window.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), + !detail.isEmpty + else { return nil } + return detail + } + static func redactedMetricDetail(_ detail: String?, provider: UsageProvider, metricID: String) -> String? { guard let detail else { return nil } guard provider == .litellm, @@ -290,6 +464,8 @@ extension UsageMenuCardView.Model { // Legacy request-based Cursor plans track a request quota, not the token-based "Total" pool. let primaryLabel = if input.provider == .cursor, snapshot.cursorRequests != nil { "Requests" + } else if input.provider == .crof { + CrofProviderDescriptor.primaryLabel(snapshot: snapshot) } else if input.provider == .grok { GrokProviderDescriptor.primaryLabel(window: snapshot.primary, now: input.now) ?? input.metadata.sessionLabel } else if input.provider == .doubao { @@ -761,7 +937,8 @@ extension UsageMenuCardView.Model { window: RateWindow, input: Input) -> PaceDetail? { - guard provider == .codex || provider == .antigravity else { return nil } + if provider == .claude, window.windowMinutes != 10080 { return nil } + guard provider == .codex || provider == .claude || provider == .antigravity else { return nil } switch window.windowMinutes { case 300: return self.sessionPaceDetail( @@ -868,7 +1045,11 @@ extension UsageMenuCardView.Model { return nil } - static func openRouterQuotaDetail(provider: UsageProvider, snapshot: UsageSnapshot) -> String? { + static func openRouterQuotaDetail( + provider: UsageProvider, + snapshot: UsageSnapshot, + preferredCurrencyCode: String = "auto") -> String? + { guard provider == .openrouter, let usage = snapshot.openRouterUsage, usage.hasValidKeyQuota, @@ -878,8 +1059,10 @@ extension UsageMenuCardView.Model { return nil } - let remaining = UsageFormatter.usdString(keyRemaining) - let limit = UsageFormatter.usdString(keyLimit) + let remaining = UsageFormatter.convertedCostString( + keyRemaining, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + let limit = UsageFormatter.convertedCostString( + keyLimit, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") return String(format: L("%@/%@ left"), remaining, limit) } diff --git a/Sources/CodexBar/MenuCardView+ModelInput.swift b/Sources/CodexBar/MenuCardView+ModelInput.swift index 12d5ab9fb..27b80a908 100644 --- a/Sources/CodexBar/MenuCardView+ModelInput.swift +++ b/Sources/CodexBar/MenuCardView+ModelInput.swift @@ -38,6 +38,7 @@ extension UsageMenuCardView.Model { let quotaWarningThresholds: [QuotaWarningWindow: [Int]] let workDaysPerWeek: Int? let usesLiveSubtitle: Bool + let preferredCurrencyCode: String let now: Date init( @@ -76,6 +77,7 @@ extension UsageMenuCardView.Model { quotaWarningThresholds: [QuotaWarningWindow: [Int]] = [:], workDaysPerWeek: Int? = nil, usesLiveSubtitle: Bool = false, + preferredCurrencyCode: String = "auto", now: Date) { self.provider = provider @@ -113,6 +115,7 @@ extension UsageMenuCardView.Model { self.quotaWarningThresholds = quotaWarningThresholds self.workDaysPerWeek = workDaysPerWeek self.usesLiveSubtitle = usesLiveSubtitle + self.preferredCurrencyCode = preferredCurrencyCode self.now = now } } diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 62e05813f..e18e4fa52 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -895,7 +895,8 @@ extension UsageMenuCardView.Model { metadata: input.metadata, snapshot: input.snapshot, credits: input.credits, - error: input.creditsError) + error: input.creditsError, + preferredCurrencyCode: input.preferredCurrencyCode) } let creditsText = PersonalInfoRedactor.redactEmails(in: rawCreditsText, isEnabled: input.hidePersonalInfo) let creditsProgressPercent = Self.creditsProgressPercent(credits: input.credits) @@ -911,7 +912,9 @@ extension UsageMenuCardView.Model { !input.showOptionalCreditsAndExtraUsage let providerCost: ProviderCostSection? = if input.provider == .sakana { input.showOptionalCreditsAndExtraUsage - ? Self.sakanaPayAsYouGoSection(input.snapshot?.sakanaPayAsYouGo) + ? Self.sakanaPayAsYouGoSection( + input.snapshot?.sakanaPayAsYouGo, + preferredCurrencyCode: input.preferredCurrencyCode) : nil } else if hidesOptionalProviderCost || (input.provider == .openai && openAIAPIUsage != nil) @@ -921,7 +924,8 @@ extension UsageMenuCardView.Model { Self.providerCostSection( provider: input.provider, cost: input.snapshot?.providerCost, - isClaudeAdminAPI: isClaudeAdminAPI) + isClaudeAdminAPI: isClaudeAdminAPI, + preferredCurrencyCode: input.preferredCurrencyCode) } let tokenUsageSnapshot = Self.tokenUsageSnapshot(input: input) let tokenUsage = Self.tokenUsageSection( @@ -929,7 +933,8 @@ extension UsageMenuCardView.Model { enabled: input.tokenCostMenuSectionEnabled, comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled, snapshot: tokenUsageSnapshot, - error: input.tokenError) + error: input.tokenError, + preferredCurrencyCode: input.preferredCurrencyCode) let subtitle = Self.subtitle( snapshot: input.snapshot, isRefreshing: input.isRefreshing, @@ -1172,7 +1177,10 @@ extension UsageMenuCardView.Model { let zaiTokenDetail = Self.zaiLimitDetailText(limit: zaiUsage?.tokenLimit) let zaiTimeDetail = Self.zaiLimitDetailText(limit: zaiUsage?.timeLimit) let zaiSessionDetail = Self.zaiLimitDetailText(limit: zaiUsage?.sessionTokenLimit) - let openRouterQuotaDetail = Self.openRouterQuotaDetail(provider: input.provider, snapshot: snapshot) + let openRouterQuotaDetail = Self.openRouterQuotaDetail( + provider: input.provider, + snapshot: snapshot, + preferredCurrencyCode: input.preferredCurrencyCode) let labels = Self.rateWindowLabels(input: input, snapshot: snapshot) if input.provider == .mistral, let credits = snapshot.mistralUsage?.credits { metrics.append(Metric( @@ -1303,148 +1311,31 @@ extension UsageMenuCardView.Model { zaiTokenDetail: String?, openRouterQuotaDetail: String?) -> Metric { - var primaryDetailText: String? = input.provider == .zai ? zaiTokenDetail : nil - var primaryResetText = Self.resetText(for: primary, style: input.resetTimeDisplayStyle, now: input.now) - var primaryDetailLeft: String? - var primaryDetailRight: String? - if input.provider == .crof, - let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), - !detail.isEmpty - { - primaryDetailRight = detail - } - if input.provider == .openrouter, - let openRouterQuotaDetail - { - primaryResetText = openRouterQuotaDetail - } - if [.copilot, .zenmux].contains(input.provider), - let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), - !detail.isEmpty - { - primaryDetailLeft = detail - } - if [.warp, .kilo, .mimo, .deepseek, .deepinfra, .qoder, .mistral, .neuralwatt, .litellm, .chutes] - .contains(input.provider), - let detail = primary.resetDescription, - !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - primaryDetailText = detail - } - if input.provider == .sub2api { - primaryResetText = primary.resetDescription - } - if let balance = Self.poeBalanceDetailText(input: input) { - primaryDetailText = balance - } - if input.provider == .kiro, - let kiroUsage = input.snapshot?.kiroUsage, - kiroUsage.creditsTotal > 0 - { - let remaining = UsageFormatter.kiroCreditNumber(kiroUsage.creditsRemaining) - let total = UsageFormatter.kiroCreditNumber(kiroUsage.creditsTotal) - primaryDetailLeft = String(format: L("%@ of %@ credits left"), remaining, total) - } - if input.provider == .alibaba || input.provider == .alibabatokenplan || input.provider == .manus, - let detail = primary.resetDescription, - !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - primaryDetailText = detail - if input.provider == .manus { - primaryResetText = nil - } - } - if [.warp, .kilo, .mimo, .deepseek, .deepinfra, .qoder, .mistral, .neuralwatt, .litellm, .zenmux, .chutes] - .contains(input.provider), - primary.resetsAt == nil - { - primaryResetText = nil - } - // Abacus: show credits as detail, compute pace on the primary monthly window - var primaryPacePercent: Double? - var primaryPaceOnTop = true - if let paceDetail = Self.sessionPaceDetail( - provider: input.provider, - window: primary, - now: input.now, - showUsed: input.usageBarsShowUsed) - { - primaryDetailLeft = paceDetail.leftLabel - primaryDetailRight = paceDetail.rightLabel - primaryPacePercent = paceDetail.pacePercent - primaryPaceOnTop = paceDetail.paceOnTop - } - if input.provider == .abacus { - if let detail = primary.resetDescription, - !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - primaryDetailText = detail - } - if primary.resetsAt == nil { - primaryResetText = nil - } - if let pace = input.weeklyPace { - let paceDetail = Self.weeklyPaceDetail( - provider: input.provider, - window: primary, - now: input.now, - pace: pace, - showUsed: input.usageBarsShowUsed) - if let paceDetail { - primaryDetailLeft = paceDetail.leftLabel - primaryDetailRight = paceDetail.rightLabel - primaryPacePercent = paceDetail.pacePercent - primaryPaceOnTop = paceDetail.paceOnTop - } - } - } else if let paceDetail = Self.resetWindowPaceDetail( - window: primary, + var presentation = PrimaryMetricPresentation( + resetText: Self.resetText(for: primary, style: input.resetTimeDisplayStyle, now: input.now), + detailText: input.provider == .zai ? zaiTokenDetail : nil) + Self.applyPrimaryQuotaPresentation( + &presentation, input: input, - pace: input.provider == .kimi ? input.weeklyPace : nil) - { - primaryDetailLeft = paceDetail.leftLabel - primaryDetailRight = paceDetail.rightLabel - primaryPacePercent = paceDetail.pacePercent - primaryPaceOnTop = paceDetail.paceOnTop - } - // Legacy request-based Cursor plans: surface the raw used/limit quota on its own line, - // since the percentage bar and pace detail alone never spell out the request cap. - if input.provider == .cursor, let requests = input.snapshot?.cursorRequests { - primaryDetailText = String( - format: L("Request quota: %@ / %@"), - "\(requests.used)", - "\(requests.limit)") - } - if input.provider == .synthetic, - let regen = Self.syntheticRollingRegenDetail( - window: primary, - now: input.now, - showUsed: input.usageBarsShowUsed) - { - primaryResetText = regen.resetText - primaryDetailLeft = regen.pace.leftLabel - primaryDetailRight = regen.pace.rightLabel - primaryPacePercent = regen.pace.pacePercent - primaryPaceOnTop = regen.pace.paceOnTop - } - let usesBalanceStatusText = input.provider == .deepseek || input.provider == .deepinfra - let primaryStatusText = usesBalanceStatusText ? primaryDetailText : nil - if usesBalanceStatusText { - primaryDetailText = nil - } + primary: primary, + openRouterQuotaDetail: openRouterQuotaDetail) + Self.applyPrimaryBalancePresentation(&presentation, input: input, primary: primary) + Self.applyPrimaryResetPresentation(&presentation, input: input, primary: primary) + Self.applyPrimaryPacePresentation(&presentation, input: input, primary: primary) + Self.applyPrimaryFinalOverrides(&presentation, input: input, primary: primary) return Metric( id: "primary", title: title ?? L(input.metadata.sessionLabel), percent: Self.clamped( input.usageBarsShowUsed ? primary.usedPercent : primary.remainingPercent), percentStyle: percentStyle, - statusText: primaryStatusText, - resetText: primaryResetText, - detailText: primaryDetailText, - detailLeftText: primaryDetailLeft, - detailRightText: primaryDetailRight, - pacePercent: primaryPacePercent, - paceOnTop: primaryPaceOnTop, + statusText: presentation.statusText, + resetText: presentation.resetText, + detailText: presentation.detailText, + detailLeftText: presentation.detailLeft, + detailRightText: presentation.detailRight, + pacePercent: presentation.pacePercent, + paceOnTop: presentation.paceOnTop, warningMarkerPercents: Self.warningMarkerPercents( thresholds: input.quotaWarningThresholds[.session], showUsed: input.usageBarsShowUsed), diff --git a/Sources/CodexBar/MenuDescriptor+ProviderUsage.swift b/Sources/CodexBar/MenuDescriptor+ProviderUsage.swift index c3269c5a5..413e7d521 100644 --- a/Sources/CodexBar/MenuDescriptor+ProviderUsage.swift +++ b/Sources/CodexBar/MenuDescriptor+ProviderUsage.swift @@ -4,23 +4,30 @@ import Foundation extension MenuDescriptor { static func appendOpenAIAPIUsageSummary( entries: inout [Entry], - usage: OpenAIAPIUsageSnapshot) + usage: OpenAIAPIUsageSnapshot, + preferredCurrencyCode: String = "auto") { let today = usage.currentDay let last7 = usage.last7Days let last30 = usage.last30Days let historyLabel = usage.historyWindowLabel + let todayCost = UsageFormatter.convertedCostString( + today.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + let last7Cost = UsageFormatter.convertedCostString( + last7.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + let last30Cost = UsageFormatter.convertedCostString( + last30.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") entries.append(.text( - "\(L("Today")): \(UsageFormatter.usdString(today.costUSD)) · " + + "\(L("Today")): \(todayCost) · " + "\(UsageFormatter.tokenCountString(today.totalTokens)) \(L("tokens"))", .secondary)) entries.append(.text( - "7d: \(UsageFormatter.usdString(last7.costUSD)) · " + + "7d: \(last7Cost) · " + "\(UsageFormatter.tokenCountString(last7.requests)) \(L("requests"))", .secondary)) entries.append(.text( - "\(historyLabel): \(UsageFormatter.usdString(last30.costUSD)) · " + + "\(historyLabel): \(last30Cost) · " + "\(UsageFormatter.tokenCountString(last30.requests)) \(L("requests"))", .secondary)) if let topModel = usage.topModels.first?.name { @@ -30,22 +37,29 @@ extension MenuDescriptor { static func appendClaudeAdminAPIUsageSummary( entries: inout [Entry], - usage: ClaudeAdminAPIUsageSnapshot) + usage: ClaudeAdminAPIUsageSnapshot, + preferredCurrencyCode: String = "auto") { let today = usage.currentDay let last7 = usage.last7Days let last30 = usage.last30Days + let todayCost = UsageFormatter.convertedCostString( + today.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + let last7Cost = UsageFormatter.convertedCostString( + last7.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + let last30Cost = UsageFormatter.convertedCostString( + last30.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") entries.append(.text( - "\(L("Today")): \(UsageFormatter.usdString(today.costUSD)) · " + + "\(L("Today")): \(todayCost) · " + "\(UsageFormatter.tokenCountString(today.totalTokens)) \(L("tokens"))", .secondary)) entries.append(.text( - "7d: \(UsageFormatter.usdString(last7.costUSD)) · " + + "7d: \(last7Cost) · " + "\(UsageFormatter.tokenCountString(last7.totalTokens)) \(L("tokens"))", .secondary)) entries.append(.text( - "30d: \(UsageFormatter.usdString(last30.costUSD)) · " + + "30d: \(last30Cost) · " + "\(UsageFormatter.tokenCountString(last30.totalTokens)) \(L("tokens"))", .secondary)) if let topModel = usage.topModels.first?.name { @@ -55,33 +69,49 @@ extension MenuDescriptor { static func appendOpenRouterUsageSummary( entries: inout [Entry], - usage: OpenRouterUsageSnapshot) + usage: OpenRouterUsageSnapshot, + preferredCurrencyCode: String = "auto") { if let daily = usage.keyUsageDaily { - entries.append(.text("\(L("Today")): \(UsageFormatter.usdString(daily))", .secondary)) + let cost = UsageFormatter.convertedCostString( + daily, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + entries.append(.text("\(L("Today")): \(cost)", .secondary)) } if let weekly = usage.keyUsageWeekly { - entries.append(.text("\(L("Week")): \(UsageFormatter.usdString(weekly))", .secondary)) + let cost = UsageFormatter.convertedCostString( + weekly, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + entries.append(.text("\(L("Week")): \(cost)", .secondary)) } if let monthly = usage.keyUsageMonthly { - entries.append(.text("\(L("Month")): \(UsageFormatter.usdString(monthly))", .secondary)) + let cost = UsageFormatter.convertedCostString( + monthly, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + entries.append(.text("\(L("Month")): \(cost)", .secondary)) } } static func appendMistralUsageSummary( entries: inout [Entry], - usage: MistralUsageSnapshot) + usage: MistralUsageSnapshot, + preferredCurrencyCode: String = "auto") { let latest = usage.daily.last if let latest { + let cost = UsageFormatter.convertedCostString( + latest.cost, + preferredCurrency: preferredCurrencyCode, + providerCurrency: usage.currency) entries.append(.text( - "\(L("Latest")): \(usage.currencySymbol)\(String(format: "%.4f", max(0, latest.cost))) · " + + "\(L("Latest")): \(cost) · " + "\(UsageFormatter.tokenCountString(latest.totalTokens)) \(L("tokens"))", .secondary)) } let totalTokens = usage.totalInputTokens + usage.totalCachedTokens + usage.totalOutputTokens + let totalCost = UsageFormatter.convertedCostString( + usage.totalCost, + preferredCurrency: preferredCurrencyCode, + providerCurrency: usage.currency) entries.append(.text( - "\(L("Month")): \(usage.currencySymbol)\(String(format: "%.4f", max(0, usage.totalCost))) · " + + "\(L("Month")): \(totalCost) · " + "\(UsageFormatter.tokenCountString(totalTokens)) \(L("tokens"))", .secondary)) if let top = Self.topMistralModel(from: usage.daily) { @@ -91,14 +121,27 @@ extension MenuDescriptor { static func appendPoeUsageSummary( entries: inout [Entry], - usage: PoeUsageHistorySnapshot) + usage: PoeUsageHistorySnapshot, + preferredCurrencyCode: String = "auto") { let today = usage.currentDay() let week = usage.last7Days let month = usage.last30Days - let todayCostSuffix = today.costUSD.map { " · \(UsageFormatter.usdString($0))" } ?? "" - let weekCostSuffix = week.costUSD.map { " · \(UsageFormatter.usdString($0))" } ?? "" - let monthCostSuffix = month.costUSD.map { " · \(UsageFormatter.usdString($0))" } ?? "" + let todayCostSuffix = today.costUSD.map { value in + let cost = UsageFormatter.convertedCostString( + value, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + return " · \(cost)" + } ?? "" + let weekCostSuffix = week.costUSD.map { value in + let cost = UsageFormatter.convertedCostString( + value, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + return " · \(cost)" + } ?? "" + let monthCostSuffix = month.costUSD.map { value in + let cost = UsageFormatter.convertedCostString( + value, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + return " · \(cost)" + } ?? "" entries.append(.text( "\(L("Today")): \(Self.pointsString(today.points)) · " + "\(UsageFormatter.tokenCountString(today.requests)) \(L("requests"))\(todayCostSuffix)", diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index bc4e920b9..d4d0ecafb 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -232,12 +232,13 @@ struct MenuDescriptor { if let snap = store.snapshot(for: provider) { let resetStyle = settings.resetTimeDisplayStyle let labels = Self.rateWindowLabels(provider: provider, metadata: meta, snapshot: snap) + let crofShowsCreditsOnly = provider == .crof && snap.secondary == nil if let primary = snap.primary { let primaryDetail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines) let primaryDescriptionIsDetail = provider == .warp || provider == .kilo || provider == .abacus || provider == .deepseek || provider == .deepinfra || provider == .neuralwatt || provider == .azureopenai || provider == .mimo || provider == .qoder || provider == .sub2api || - provider == .chutes + crofShowsCreditsOnly || provider == .chutes let primaryWindow = if primaryDescriptionIsDetail { // Some providers use resetDescription for non-reset detail // (e.g., "Unlimited", "X/Y credits"). Avoid rendering it as a "Resets ..." line. @@ -263,10 +264,10 @@ struct MenuDescriptor { } if provider == .crof, primary.resetsAt != nil, - let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), - !detail.isEmpty + let primaryDetail, + !primaryDetail.isEmpty { - entries.append(.text(detail, .secondary)) + entries.append(.text(primaryDetail, .secondary)) } if provider == .abacus, let pace = store.weeklyPace(provider: provider, window: primary) @@ -326,7 +327,8 @@ struct MenuDescriptor { Self.appendProviderUsageSummaries( entries: &entries, snapshot: snap, - showOptionalUsage: settings.showOptionalCreditsAndExtraUsage) + showOptionalUsage: settings.showOptionalCreditsAndExtraUsage, + preferredCurrencyCode: settings.preferredCurrencyCode) if snap.rateLimitsUnavailable(for: provider) { entries.append(.text(L("Limits not available"), .secondary)) } @@ -353,7 +355,8 @@ struct MenuDescriptor { private static func appendProviderUsageSummaries( entries: inout [Entry], snapshot: UsageSnapshot, - showOptionalUsage: Bool) + showOptionalUsage: Bool, + preferredCurrencyCode: String = "auto") { if let cost = snapshot.providerCost { if cost.currencyCode == "Quota" { @@ -363,13 +366,22 @@ struct MenuDescriptor { } } if let openAIAPIUsage = snapshot.openAIAPIUsage { - Self.appendOpenAIAPIUsageSummary(entries: &entries, usage: openAIAPIUsage) + Self.appendOpenAIAPIUsageSummary( + entries: &entries, + usage: openAIAPIUsage, + preferredCurrencyCode: preferredCurrencyCode) } if let claudeAdminAPIUsage = snapshot.claudeAdminAPIUsage { - Self.appendClaudeAdminAPIUsageSummary(entries: &entries, usage: claudeAdminAPIUsage) + Self.appendClaudeAdminAPIUsageSummary( + entries: &entries, + usage: claudeAdminAPIUsage, + preferredCurrencyCode: preferredCurrencyCode) } if let openRouterUsage = snapshot.openRouterUsage { - Self.appendOpenRouterUsageSummary(entries: &entries, usage: openRouterUsage) + Self.appendOpenRouterUsageSummary( + entries: &entries, + usage: openRouterUsage, + preferredCurrencyCode: preferredCurrencyCode) } if let clawRouterUsage = snapshot.clawRouterUsage { entries.append(.text( @@ -387,14 +399,37 @@ struct MenuDescriptor { Self.appendWayfinderUsageSummary(entries: &entries, usage: wayfinderUsage) } if let poeUsage = snapshot.poeUsage, !poeUsage.daily.isEmpty { - Self.appendPoeUsageSummary(entries: &entries, usage: poeUsage) + Self.appendPoeUsageSummary( + entries: &entries, + usage: poeUsage, + preferredCurrencyCode: preferredCurrencyCode) } if let mistralUsage = snapshot.mistralUsage, !mistralUsage.daily.isEmpty { - Self.appendMistralUsageSummary(entries: &entries, usage: mistralUsage) + Self.appendMistralUsageSummary( + entries: &entries, + usage: mistralUsage, + preferredCurrencyCode: preferredCurrencyCode) } if let mimoUsage = snapshot.mimoUsage { entries.append(.text("\(L("Balance")): \(mimoUsage.balanceDetail)", .primary)) } + if let xaiUsage = snapshot.xaiUsage { + let balance = UsageFormatter.convertedCostString( + xaiUsage.balanceUSD, + preferredCurrency: preferredCurrencyCode, + providerCurrency: "USD") + entries.append(.text("\(L("Posted balance")): \(balance)", .primary)) + entries.append(.text(L("Ledger may lag current-cycle spend"), .secondary)) + if !xaiUsage.daily.isEmpty { + let spend = UsageFormatter.convertedCostString( + xaiUsage.windowCostUSD, + preferredCurrency: preferredCurrencyCode, + providerCurrency: "USD") + entries.append(.text( + "\(xaiUsage.historyWindowPeriodLabel): \(spend)", + .secondary)) + } + } // Sakana pay-as-you-go is optional data gated by "Show optional credits and extra usage". // Gate the render on the setting too, not just the fetch: toggling the setting off only // rebuilds the menu, it does not immediately refetch, so a previously-populated @@ -402,8 +437,12 @@ struct MenuDescriptor { if showOptionalUsage, let sakanaPayAsYouGo = snapshot.sakanaPayAsYouGo { entries.append(.text("\(L("Balance")): \(sakanaPayAsYouGo.balanceDetail)", .primary)) if let periodUsageTotal = sakanaPayAsYouGo.periodUsageTotal { + let cost = UsageFormatter.convertedCostString( + periodUsageTotal, + preferredCurrency: preferredCurrencyCode, + providerCurrency: "USD") entries.append(.text( - "\(L("Usage")): \(UsageFormatter.usdString(periodUsageTotal))", + "\(L("Usage")): \(cost)", .secondary)) } } @@ -682,6 +721,8 @@ struct MenuDescriptor { Self.cursorPrimaryRateWindowLabel(snapshot: snapshot, fallback: metadata.sessionLabel) } else if provider == .grok { GrokProviderDescriptor.primaryLabel(window: snapshot.primary) ?? metadata.sessionLabel + } else if provider == .crof { + CrofProviderDescriptor.primaryLabel(snapshot: snapshot) } else if provider == .doubao { DoubaoProviderDescriptor.primaryLabel(window: snapshot.primary) ?? metadata.sessionLabel } else if provider == .sub2api { diff --git a/Sources/CodexBar/Notifications+CodexBar.swift b/Sources/CodexBar/Notifications+CodexBar.swift index 301a5362f..93c6eb016 100644 --- a/Sources/CodexBar/Notifications+CodexBar.swift +++ b/Sources/CodexBar/Notifications+CodexBar.swift @@ -11,6 +11,8 @@ extension Notification.Name { static let codexbarSessionLimitReset = Notification.Name("codexbarSessionLimitReset") static let codexbarWeeklyLimitReset = Notification.Name("codexbarWeeklyLimitReset") static let codexbarProviderConfigDidChange = Notification.Name("codexbarProviderConfigDidChange") + static let codexbarCurrencyExchangeRatesDidChange = + Notification.Name("codexbarCurrencyExchangeRatesDidChange") static let codexbarQuotaWarningDidPost = Notification.Name("codexbarQuotaWarningDidPost") } diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index fd2f37344..7a167a542 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -75,6 +75,42 @@ enum AppLanguage: String, CaseIterable, Identifiable { } } +enum PreferredCurrencyOption: String, CaseIterable, Identifiable { + case auto + case usd = "USD" + case gbp = "GBP" + case eur = "EUR" + case cny = "CNY" + case jpy = "JPY" + case cad = "CAD" + case aud = "AUD" + case hkd = "HKD" + case twd = "TWD" + case sgd = "SGD" + case inr = "INR" + + var id: String { + self.rawValue + } + + var label: String { + switch self { + case .auto: L("currency_auto") + case .usd: "USD ($)" + case .gbp: "GBP (£)" + case .eur: "EUR (€)" + case .cny: "CNY (¥)" + case .jpy: "JPY (¥)" + case .cad: "CAD ($)" + case .aud: "AUD ($)" + case .hkd: "HKD ($)" + case .twd: "TWD (NT$)" + case .sgd: "SGD ($)" + case .inr: "INR (₹)" + } + } +} + @MainActor struct GeneralPane: View { @Bindable var settings: SettingsStore @@ -92,6 +128,28 @@ struct GeneralPane: View { Text(verbatim: AppLanguage(rawValue: rawValue)?.label ?? rawValue) }) + SettingsMenuPicker( + selection: self.$settings.preferredCurrencyCode, + options: PreferredCurrencyOption.allCases.map(\.rawValue), + label: { + SettingsRowLabel(L("currency_title"), subtitle: L("currency_subtitle")) + }, + optionLabel: { rawValue in + Text(verbatim: PreferredCurrencyOption(rawValue: rawValue)?.label ?? rawValue) + }) + .onChange(of: self.settings.preferredCurrencyCode) { _, newValue in + guard CurrencyExchange.requiresLiveRates(preferredCurrencyCode: newValue) else { return } + Task { + let ratesChanged = await CurrencyExchange.shared.fetchLatestRatesIfNeeded( + preferredCurrencyCode: newValue) + if ratesChanged { + NotificationCenter.default.post( + name: .codexbarCurrencyExchangeRatesDidChange, + object: nil) + } + } + } + SettingsMenuPicker( selection: self.$settings.terminalApp, options: GeneralSettingsMenuOptions.terminalApps(selected: self.settings.terminalApp), diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 83e7f3103..1cc4f4c20 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -87,6 +87,9 @@ struct SpendDashboardPane: View { .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in self.controller.refreshDateWindow() } + .onReceive(NotificationCenter.default.publisher(for: .codexbarCurrencyExchangeRatesDidChange)) { _ in + self.controller.refreshDateWindow() + } } private var configuration: SpendDashboardConfiguration { diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index 397a3409f..0d452e9c1 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -299,13 +299,19 @@ struct ClaudeProviderImplementation: ProviderImplementation { context.settings.showOptionalCreditsAndExtraUsage, cost.currencyCode != "Quota" { + func formatCost(_ value: Double) -> String { + UsageFormatter.convertedCostString( + value, + preferredCurrency: context.settings.preferredCurrencyCode, + providerCurrency: cost.currencyCode) + } if cost.limit > 0 { - let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) - let limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) + let used = formatCost(cost.used) + let limit = formatCost(cost.limit) entries.append(.text(String(format: L("extra_usage_format"), used, limit), .primary)) } if let balance = cost.balance { - let value = UsageFormatter.currencyString(balance, currencyCode: cost.currencyCode) + let value = formatCost(balance) let label = cost.limit > 0 ? L("Balance") : L("Credits") entries.append(.text("\(label): \(value)", .primary)) } diff --git a/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift b/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift index 51717620e..4cf6c9c19 100644 --- a/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift @@ -122,9 +122,15 @@ struct CursorProviderImplementation: ProviderImplementation { @MainActor func appendUsageMenuEntries(context: ProviderMenuUsageContext, entries: inout [ProviderMenuEntry]) { guard let cost = context.snapshot?.providerCost, cost.currencyCode != "Quota" else { return } - let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + let used = UsageFormatter.convertedCostString( + cost.used, + preferredCurrency: context.settings.preferredCurrencyCode, + providerCurrency: cost.currencyCode) if cost.limit > 0 { - let limitStr = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) + let limitStr = UsageFormatter.convertedCostString( + cost.limit, + preferredCurrency: context.settings.preferredCurrencyCode, + providerCurrency: cost.currencyCode) entries.append(.text(String(format: L("cursor_on_demand_with_limit"), used, limitStr), .primary)) } else { entries.append(.text(String(format: L("cursor_on_demand"), used), .primary)) diff --git a/Sources/CodexBar/Providers/Devin/DevinProviderImplementation.swift b/Sources/CodexBar/Providers/Devin/DevinProviderImplementation.swift index 3d2ae2341..f69c52971 100644 --- a/Sources/CodexBar/Providers/Devin/DevinProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Devin/DevinProviderImplementation.swift @@ -126,7 +126,10 @@ struct DevinProviderImplementation: ProviderImplementation { cost.period == "Extra usage balance" else { return } - let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + let balance = UsageFormatter.convertedCostString( + cost.used, + preferredCurrency: context.settings.preferredCurrencyCode, + providerCurrency: cost.currencyCode) entries.append(.text(L("Extra usage balance: %@", balance), .primary)) } } diff --git a/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift b/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift index 71883560d..dc3b35d65 100644 --- a/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift @@ -157,7 +157,10 @@ struct FactoryProviderImplementation: ProviderImplementation { cost.period == "Extra usage balance" else { return } - let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + let balance = UsageFormatter.convertedCostString( + cost.used, + preferredCurrency: context.settings.preferredCurrencyCode, + providerCurrency: cost.currencyCode) entries.append(.text(L("Extra usage balance: %@", balance), .primary)) } } diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 722f54617..c81159099 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -78,6 +78,7 @@ enum ProviderImplementationRegistry { case .zenmux: ZenMuxProviderImplementation() case .aiand: AiAndProviderImplementation() case .zoommate: ZoomMateProviderImplementation() + case .xai: XAIProviderImplementation() } } diff --git a/Sources/CodexBar/Providers/Sub2API/Sub2APIProviderImplementation.swift b/Sources/CodexBar/Providers/Sub2API/Sub2APIProviderImplementation.swift index 9d8682f8e..4fa483cc7 100644 --- a/Sources/CodexBar/Providers/Sub2API/Sub2APIProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Sub2API/Sub2APIProviderImplementation.swift @@ -52,21 +52,38 @@ struct Sub2APIProviderImplementation: ProviderImplementation { func appendUsageMenuEntries(context: ProviderMenuUsageContext, entries: inout [ProviderMenuEntry]) { guard let usage = context.snapshot?.sub2APIUsage else { return } if let balance = usage.balance { - entries.append(.text( - "\(L("Balance")): \(UsageFormatter.currencyString(balance, currencyCode: usage.unit))", - .primary)) + let balanceText = UsageFormatter.convertedCostString( + balance, + preferredCurrency: context.settings.preferredCurrencyCode, + providerCurrency: usage.unit) + entries.append(.text("\(L("Balance")): \(balanceText)", .primary)) } if let today = usage.today { - entries.append(.text("\(L("Today")): \(self.totalsText(today, unit: usage.unit))", .secondary)) + let totals = self.totalsText( + today, + unit: usage.unit, + preferredCurrencyCode: context.settings.preferredCurrencyCode) + entries.append(.text("\(L("Today")): \(totals)", .secondary)) } if let total = usage.total { - entries.append(.text("\(L("Total")): \(self.totalsText(total, unit: usage.unit))", .secondary)) + let totals = self.totalsText( + total, + unit: usage.unit, + preferredCurrencyCode: context.settings.preferredCurrencyCode) + entries.append(.text("\(L("Total")): \(totals)", .secondary)) } } - private func totalsText(_ totals: Sub2APIUsageDetails.Totals, unit: String) -> String { + private func totalsText( + _ totals: Sub2APIUsageDetails.Totals, + unit: String, + preferredCurrencyCode: String) -> String + { "\(UsageFormatter.tokenCountString(totals.requests)) \(L("requests")) · " + "\(UsageFormatter.tokenCountString(totals.totalTokens)) \(L("tokens")) · " + - UsageFormatter.currencyString(totals.actualCostUSD, currencyCode: unit) + UsageFormatter.convertedCostString( + totals.actualCostUSD, + preferredCurrency: preferredCurrencyCode, + providerCurrency: unit) } } diff --git a/Sources/CodexBar/Providers/XAI/XAIProviderImplementation.swift b/Sources/CodexBar/Providers/XAI/XAIProviderImplementation.swift new file mode 100644 index 000000000..44f6b3272 --- /dev/null +++ b/Sources/CodexBar/Providers/XAI/XAIProviderImplementation.swift @@ -0,0 +1,55 @@ +import CodexBarCore +import Foundation + +struct XAIProviderImplementation: ProviderImplementation { + let id: UsageProvider = .xai + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.xaiManagementAPIKey + _ = settings.xaiTeamID + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if XAISettingsReader.apiKey(environment: context.environment) != nil, + XAISettingsReader.teamID(environment: context.environment) != nil + { + return true + } + return !context.settings.xaiManagementAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !context.settings.xaiTeamID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "xai-management-api-key", + title: "Management API key", + subtitle: "Stored in ~/.quotakit/config.json. Create one at console.x.ai under " + + "Settings > Management Keys; inference API keys are not accepted.", + kind: .secure, + placeholder: "xai-...", + binding: context.stringBinding(\.xaiManagementAPIKey), + actions: [], + isVisible: nil, + onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "xai-team-id", + title: "Team ID", + subtitle: "Required. Shown in the xAI Console URL and team settings.", + kind: .plain, + placeholder: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + binding: context.stringBinding(\.xaiTeamID), + actions: [], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/XAI/XAISettingsStore.swift b/Sources/CodexBar/Providers/XAI/XAISettingsStore.swift new file mode 100644 index 000000000..2c75bee09 --- /dev/null +++ b/Sources/CodexBar/Providers/XAI/XAISettingsStore.swift @@ -0,0 +1,27 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var xaiManagementAPIKey: String { + get { + self.configSnapshot.providerConfig(for: .xai)?.sanitizedAPIKey ?? "" + } + set { + self.updateProviderConfig(provider: .xai) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .xai, field: "apiKey", value: newValue) + } + } + + var xaiTeamID: String { + get { + self.configSnapshot.providerConfig(for: .xai)?.sanitizedWorkspaceID ?? "" + } + set { + self.updateProviderConfig(provider: .xai) { entry in + entry.workspaceID = self.normalizedConfigValue(newValue) + } + } + } +} diff --git a/Sources/CodexBar/Resources/ProviderIcon-xai.svg b/Sources/CodexBar/Resources/ProviderIcon-xai.svg new file mode 100644 index 000000000..f0a69128c --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-xai.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 5e09ef418..ac8939887 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -415,6 +415,9 @@ "section_agent_sessions" = "جلسات الوكلاء"; "language_title" = "اللغة"; "language_subtitle" = "غير لغة العرض. يتطلب إعادة تشغيل التطبيق ليكون مفعوله بالكامل."; +"currency_title" = "العملة المفضلة"; +"currency_subtitle" = "عملة تقديرات التكلفة ومقاييس الإنفاق. تستخدم أسعار صرف تُحدّث يوميًا."; +"currency_auto" = "تلقائي (حسب المزوّد / USD)"; "language_system" = "النظام"; "language_english" = "الإنجليزية"; "language_spanish" = "الإسبانيول"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 19b77763a..24bed1bf9 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -414,6 +414,9 @@ "section_agent_sessions" = "Sessions d'agents"; "language_title" = "Idioma"; "language_subtitle" = "Canvia l'idioma de la interfície. Cal reiniciar l'app perquè s'apliqui completament."; +"currency_title" = "Moneda preferida"; +"currency_subtitle" = "Moneda per a estimacions de cost i despeses. Utilitza tipus de canvi actualitzats diàriament."; +"currency_auto" = "Automàtic (segons el proveïdor / USD)"; "language_system" = "Sistema"; "language_english" = "English"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index c74e5c5bc..4693b9db3 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -426,6 +426,9 @@ "section_agent_sessions" = "Agenten-Sitzungen"; "language_title" = "Sprache"; "language_subtitle" = "Anzeigesprache wechseln. Ein App-Neustart wird empfohlen."; +"currency_title" = "Bevorzugte Währung"; +"currency_subtitle" = "Währung für Kostenschätzungen und Ausgaben. Verwendet täglich aktualisierte Wechselkurse."; +"currency_auto" = "Automatisch (Anbieter / USD)"; "language_system" = "System"; "language_english" = "Englisch"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 3e7bacaf8..ac5c9d272 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -415,6 +415,9 @@ "section_agent_sessions" = "Agent sessions"; "language_title" = "Language"; "language_subtitle" = "Change the display language. Requires app restart to take full effect."; +"currency_title" = "Preferred Currency"; +"currency_subtitle" = "Currency for cost estimates and spend metrics. Uses live exchange rates updated daily."; +"currency_auto" = "Auto (Follow Provider / USD)"; "language_system" = "System"; "language_english" = "English"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index aac8103b3..5e46842d1 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -432,6 +432,9 @@ "section_agent_sessions" = "Sesiones de agentes"; "language_title" = "Idioma"; "language_subtitle" = "Cambia el idioma de la interfaz. Requiere reiniciar la app para aplicarse por completo."; +"currency_title" = "Moneda preferida"; +"currency_subtitle" = "Moneda para estimaciones de coste y gastos. Usa tipos de cambio actualizados diariamente."; +"currency_auto" = "Automático (según proveedor / USD)"; "language_system" = "Sistema"; "language_english" = "English"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 12a3b97cf..3295828ca 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -415,6 +415,9 @@ "section_agent_sessions" = "جلسات عامل‌ها"; "language_title" = "زبان"; "language_subtitle" = "زبان نمایش را تغییر دهید. برای اجرایی شدن کامل برنامه نیاز به ریستارت دارد."; +"currency_title" = "ارز ترجیحی"; +"currency_subtitle" = "ارز برآورد هزینه و مصرف. از نرخ‌های تبدیل روزانه استفاده می‌کند."; +"currency_auto" = "خودکار (بر اساس ارائه‌دهنده / USD)"; "language_system" = "سیستم"; "language_english" = "انگلیسی"; "language_spanish" = "اسپانیایی"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index a15146fcb..4229c966a 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -428,6 +428,9 @@ "section_agent_sessions" = "Sessions d’agents"; "language_title" = "Langue"; "language_subtitle" = "Change la langue d'affichage. Nécessite de redémarrer l'app pour une prise en compte complète."; +"currency_title" = "Devise préférée"; +"currency_subtitle" = "Devise des estimations de coût et des dépenses. Utilise des taux de change actualisés chaque jour."; +"currency_auto" = "Automatique (selon le fournisseur / USD)"; "language_system" = "Système"; "language_english" = "Anglais"; "language_spanish" = "Espagnol"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index e9fdf3091..ae64d24ff 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -410,6 +410,9 @@ "section_agent_sessions" = "Sesións de axentes"; "language_title" = "Idioma"; "language_subtitle" = "Cambia o idioma da interface. Cómpre reiniciar a aplicación para que se aplique por completo."; +"currency_title" = "Moeda preferida"; +"currency_subtitle" = "Moeda para estimacións de custo e gasto. Usa tipos de cambio actualizados diariamente."; +"currency_auto" = "Automático (segundo o provedor / USD)"; "language_system" = "Sistema"; "language_english" = "Inglés"; "language_spanish" = "Castelán"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index bfa4980de..e6439c2b2 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -415,6 +415,9 @@ "section_agent_sessions" = "Sesi agen"; "language_title" = "Bahasa"; "language_subtitle" = "Ubah bahasa tampilan. Memerlukan restart aplikasi agar berlaku penuh."; +"currency_title" = "Mata uang pilihan"; +"currency_subtitle" = "Mata uang untuk estimasi biaya dan pengeluaran. Menggunakan kurs yang diperbarui setiap hari."; +"currency_auto" = "Otomatis (ikuti penyedia / USD)"; "language_system" = "Sistem"; "language_english" = "English"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 60d0d48a4..cd477bdf0 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -415,6 +415,9 @@ "section_agent_sessions" = "Sessioni degli agenti"; "language_title" = "Lingua"; "language_subtitle" = "Cambia la lingua dell'interfaccia. Richiede il riavvio dell'app per applicare completamente la modifica."; +"currency_title" = "Valuta preferita"; +"currency_subtitle" = "Valuta per stime dei costi e spese. Usa tassi di cambio aggiornati ogni giorno."; +"currency_auto" = "Automatica (in base al provider / USD)"; "language_system" = "Sistema"; "language_english" = "Inglese"; "language_spanish" = "Spagnolo"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index b0f6a2334..5a4e55033 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -428,6 +428,9 @@ "section_agent_sessions" = "エージェントセッション"; "language_title" = "言語"; "language_subtitle" = "表示言語を変更します。完全に反映するにはアプリの再起動が必要です。"; +"currency_title" = "優先通貨"; +"currency_subtitle" = "費用見積もりと支出指標に使う通貨です。毎日更新される為替レートを使用します。"; +"currency_auto" = "自動(プロバイダー / USD に従う)"; "language_system" = "システム"; "language_english" = "英語"; "language_spanish" = "スペイン語"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 0d981a29f..91db01816 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -426,6 +426,9 @@ "section_agent_sessions" = "에이전트 세션"; "language_title" = "언어"; "language_subtitle" = "표시 언어를 변경합니다. 적용하려면 앱을 다시 시작해야 합니다."; +"currency_title" = "기본 통화"; +"currency_subtitle" = "비용 추정 및 지출 지표에 사용할 통화입니다. 매일 갱신되는 환율을 사용합니다."; +"currency_auto" = "자동(제공업체 / USD 따름)"; "language_system" = "시스템"; "language_english" = "영어"; "language_spanish" = "스페인어"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 66717acd6..8b0079081 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -428,6 +428,9 @@ "section_agent_sessions" = "Agentsessies"; "language_title" = "Taal"; "language_subtitle" = "Wijzig de weergavetaal. Vereist een herstart van de app om volledig effect te krijgen."; +"currency_title" = "Voorkeursvaluta"; +"currency_subtitle" = "Valuta voor kostenramingen en uitgaven. Gebruikt dagelijks bijgewerkte wisselkoersen."; +"currency_auto" = "Automatisch (provider / USD volgen)"; "language_system" = "Systeem"; "language_english" = "Engels"; "language_spanish" = "Spaans"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 2cde39d1f..c1ceec38f 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -415,6 +415,9 @@ "section_agent_sessions" = "Sesje agentów"; "language_title" = "Język"; "language_subtitle" = "Zmień język interfejsu. Aby zmiana zaczęła w pełni obowiązywać, uruchom aplikację ponownie."; +"currency_title" = "Preferowana waluta"; +"currency_subtitle" = "Waluta szacowanych kosztów i wydatków. Używa kursów aktualizowanych codziennie."; +"currency_auto" = "Automatycznie (według dostawcy / USD)"; "language_system" = "System"; "language_english" = "English"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 7d62a21ef..c7ed76811 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -428,6 +428,9 @@ "section_agent_sessions" = "Sessões de agentes"; "language_title" = "Idioma"; "language_subtitle" = "Altera o idioma de exibição. Requer reiniciar o app para ter efeito completo."; +"currency_title" = "Moeda preferida"; +"currency_subtitle" = "Moeda para estimativas de custo e gastos. Usa taxas de câmbio atualizadas diariamente."; +"currency_auto" = "Automático (seguir provedor / USD)"; "language_system" = "Sistema"; "language_english" = "Inglês"; "language_spanish" = "Espanhol"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index a8c989873..c4ba071b0 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -431,6 +431,9 @@ "section_agent_sessions" = "Сеансы агентов"; "language_title" = "Язык"; "language_subtitle" = "Изменяет язык интерфейса. Для полного применения нужен перезапуск приложения."; +"currency_title" = "Предпочитаемая валюта"; +"currency_subtitle" = "Валюта для оценки затрат и расходов. Используются курсы, обновляемые ежедневно."; +"currency_auto" = "Автоматически (валюта провайдера / USD)"; "language_system" = "Системный"; "language_english" = "English"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index b25228096..ca30814e8 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -429,6 +429,9 @@ "section_agent_sessions" = "Agentsessioner"; "language_title" = "Språk"; "language_subtitle" = "Byt visningsspråk. Appen behöver startas om för att ändringen ska slå igenom helt."; +"currency_title" = "Önskad valuta"; +"currency_subtitle" = "Valuta för kostnadsuppskattningar och utgifter. Använder växelkurser som uppdateras dagligen."; +"currency_auto" = "Automatiskt (följ leverantör / USD)"; "language_system" = "System"; "language_english" = "English"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index ffcad0151..56ad278a4 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -415,6 +415,9 @@ "section_agent_sessions" = "เซสชันเอเจนต์"; "language_title" = "ภาษา"; "language_subtitle" = "เปลี่ยนภาษาที่แสดง ต้องรีสตาร์ทแอปเพื่อให้มีผลเต็มที่"; +"currency_title" = "สกุลเงินที่ต้องการ"; +"currency_subtitle" = "สกุลเงินสำหรับประมาณการต้นทุนและค่าใช้จ่าย ใช้อัตราแลกเปลี่ยนที่อัปเดตทุกวัน"; +"currency_auto" = "อัตโนมัติ (ตามผู้ให้บริการ / USD)"; "language_system" = "ระบบ"; "language_english" = "อังกฤษ"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 34892da72..c936d4a27 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -413,6 +413,9 @@ "section_agent_sessions" = "Ajan oturumları"; "language_title" = "Dil"; "language_subtitle" = "Görüntüleme dilini değiştirin. Tam olarak geçerli olması için uygulamanın yeniden başlatılması gerekir."; +"currency_title" = "Tercih edilen para birimi"; +"currency_subtitle" = "Maliyet tahminleri ve harcamalar için para birimi. Günlük güncellenen kurları kullanır."; +"currency_auto" = "Otomatik (sağlayıcı / USD)"; "language_system" = "Sistem"; "language_english" = "English"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 954e616c6..cadc9d5b3 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -428,6 +428,9 @@ "section_agent_sessions" = "Сеанси агентів"; "language_title" = "Мова"; "language_subtitle" = "Змінює мову інтерфейсу. Для повного застосування потрібно перезапустити застосунок."; +"currency_title" = "Бажана валюта"; +"currency_subtitle" = "Валюта для оцінки вартості та витрат. Використовує курси, що оновлюються щодня."; +"currency_auto" = "Автоматично (валюта постачальника / USD)"; "language_system" = "Система"; "language_english" = "English"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index e8886a28c..472b48491 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -428,6 +428,9 @@ "section_agent_sessions" = "Phiên tác nhân"; "language_title" = "Ngôn ngữ"; "language_subtitle" = "Thay đổi ngôn ngữ hiển thị. Yêu cầu khởi động lại ứng dụng để có hiệu lực đầy đủ."; +"currency_title" = "Tiền tệ ưu tiên"; +"currency_subtitle" = "Tiền tệ dùng cho ước tính chi phí và chi tiêu. Sử dụng tỷ giá được cập nhật hằng ngày."; +"currency_auto" = "Tự động (theo nhà cung cấp / USD)"; "language_system" = "Hệ thống"; "language_english" = "Tiếng Anh"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 480b20600..713a6893b 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -435,6 +435,9 @@ "section_agent_sessions" = "智能体会话"; "language_title" = "语言"; "language_subtitle" = "更改显示语言。需要重启应用才能完全生效。"; +"currency_title" = "首选货币"; +"currency_subtitle" = "用于费用估算和支出指标的货币。使用每日更新的实时汇率。"; +"currency_auto" = "自动(跟随提供商 / USD)"; "language_system" = "跟随系统"; "language_english" = "English"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 3d26f5d7a..ef17ea771 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -436,6 +436,9 @@ "section_agent_sessions" = "Agent 工作階段"; "language_title" = "語言"; "language_subtitle" = "更改顯示語言。需要重新啟動 App 才會完全生效。"; +"currency_title" = "偏好貨幣"; +"currency_subtitle" = "用於費用估算與支出指標的貨幣。使用每日更新的即時匯率。"; +"currency_auto" = "自動(依供應商 / USD)"; "language_system" = "依照系統"; "language_english" = "English"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/SessionQuotaNotifications.swift b/Sources/CodexBar/SessionQuotaNotifications.swift index ebc7c77f1..195d09a92 100644 --- a/Sources/CodexBar/SessionQuotaNotifications.swift +++ b/Sources/CodexBar/SessionQuotaNotifications.swift @@ -410,6 +410,8 @@ extension UsageStore { provider: UsageProvider, snapshot: UsageSnapshot) -> (window: RateWindow, source: SessionQuotaWindowSource)? { + // MiMo/Qoder balances are never session quotas. Crof is handled below so quota-backed + // Crof snapshots can still participate when a real request-quota window is present. guard provider != .mimo, provider != .qoder else { return nil } if provider == .antigravity { guard let window = Self.antigravityWindow(snapshot: snapshot, windowMinutes: 5 * 60) else { @@ -425,6 +427,13 @@ extension UsageStore { if provider == .zai, let tertiary = snapshot.tertiary { return (tertiary, .zaiTertiary) } + // Crof's real request quota is a daily (1440-minute) window, so it does not pass the + // generic <=6-hour session heuristic. Its credits-only shape has no secondary window; + // only the quota-backed request-primary + credits-secondary shape participates here. + if provider == .crof { + guard snapshot.secondary != nil, let primary = snapshot.primary else { return nil } + return (primary, .primary) + } if let primary = snapshot.primary, Self.isSessionWindow(primary) { return (primary, .primary) } diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 63e778013..b28b5322e 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -1016,6 +1016,14 @@ extension SettingsStore { self.userDefaults.set(newValue, forKey: "agentSessionsManualHosts") } } + + var preferredCurrencyCode: String { + get { self.defaultsState.preferredCurrencyCode } + set { + self.defaultsState.preferredCurrencyCode = newValue + self.userDefaults.set(newValue, forKey: "preferredCurrencyCode") + } + } } extension SettingsStore { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index f4ecdee6f..cd7e0c352 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -46,6 +46,7 @@ extension SettingsStore { _ = self.costUsageHistoryDays _ = self.costComparisonPeriodsEnabled _ = self.costSummaryDisplayStyle + _ = self.preferredCurrencyCode _ = self.appLanguage _ = self.hidePersonalInfo _ = self.randomBlinkEnabled diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 409137f75..86f223bce 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -515,6 +515,7 @@ extension SettingsStore { let agentSessionLabelStyleRaw = userDefaults.string(forKey: "agentSessionLabelStyle") ?? AgentSessionLabelStyle.project.rawValue let agentSessionsManualHosts = userDefaults.string(forKey: "agentSessionsManualHosts") ?? "" + let preferredCurrencyCode = userDefaults.string(forKey: "preferredCurrencyCode") ?? "auto" return SettingsDefaultsState( refreshFrequency: refreshFrequency, adaptiveActivityScanConsent: adaptiveActivityScanConsent, @@ -590,7 +591,8 @@ extension SettingsStore { terminalAppRaw: userDefaults.string(forKey: "terminalApp"), agentSessionsEnabled: agentSessionsEnabled, agentSessionLabelStyleRaw: agentSessionLabelStyleRaw, - agentSessionsManualHosts: agentSessionsManualHosts) + agentSessionsManualHosts: agentSessionsManualHosts, + preferredCurrencyCode: preferredCurrencyCode) } private static func loadOptionalCreditsDefaults(userDefaults: UserDefaults) -> OptionalCreditsDefaults { diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 02459e8cf..4ca79cfd8 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -76,4 +76,5 @@ struct SettingsDefaultsState { var agentSessionsEnabled: Bool var agentSessionLabelStyleRaw: String var agentSessionsManualHosts: String + var preferredCurrencyCode: String } diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 6f9ec8c36..8b7de621e 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -5,6 +5,7 @@ import Observation struct SpendDashboardConfiguration: Equatable, Sendable { let costUsageEnabled: Bool + let preferredCurrencyCode: String let providerIDs: [String] let codexAccountIdentities: [String] let codexAccountDisplayNames: [String: String] @@ -13,6 +14,7 @@ struct SpendDashboardConfiguration: Equatable, Sendable { init( costUsageEnabled: Bool, + preferredCurrencyCode: String = "auto", providerIDs: [String], codexAccountIdentities: [String], codexAccountDisplayNames: [String: String] = [:], @@ -20,6 +22,7 @@ struct SpendDashboardConfiguration: Equatable, Sendable { sourceRevisions: [String] = []) { self.costUsageEnabled = costUsageEnabled + self.preferredCurrencyCode = preferredCurrencyCode self.providerIDs = providerIDs self.codexAccountIdentities = codexAccountIdentities self.codexAccountDisplayNames = codexAccountDisplayNames @@ -142,6 +145,7 @@ enum SpendDashboardSource { { SpendDashboardConfiguration( costUsageEnabled: settings.costUsageEnabled, + preferredCurrencyCode: settings.preferredCurrencyCode, providerIDs: providers.map(\.rawValue), codexAccountIdentities: codexRequests.map { "\($0.id)|\($0.cacheIdentity)" }, codexAccountDisplayNames: self.codexDisplayNamesByID(codexRequests), @@ -964,7 +968,8 @@ final class SpendDashboardController { self.model = SpendDashboardModel.build( inputs: self.loadedInputs, requestedDays: self.selectedDays, - now: self.loadedAt) + now: self.loadedAt, + preferredCurrencyCode: self.configuration?.preferredCurrencyCode ?? "auto") } private func refreshRetainedCodexDisplayNames(_ displayNamesByID: [String: String]) { diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index c3ed207f9..77daee855 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -89,19 +89,30 @@ struct SpendDashboardModel: Equatable, Sendable { inputs: [ProviderInput], requestedDays: Int, now: Date, - calendar: Calendar = .current) -> Self + calendar: Calendar = .current, + preferredCurrencyCode: String = "auto") -> Self { let days = max(1, min(30, requestedDays)) let calculationCalendar = Self.gregorianCalendar(timeZone: calendar.timeZone) - let classifiedInputs = inputs.compactMap { input -> (currencyCode: String, input: ProviderInput)? in - guard let currencyCode = Self.currencyCode(input.snapshot.currencyCode) else { return nil } - return (currencyCode, input) + let classifiedInputs = inputs.compactMap { input -> ClassifiedInput? in + guard let sourceCurrencyCode = Self.currencyCode(input.snapshot.currencyCode) else { return nil } + let targetCurrencyCode = UsageFormatter.effectiveCurrencyCode( + preferred: preferredCurrencyCode, + providerCurrency: sourceCurrencyCode) + let conversion = CurrencyExchange.shared.convert( + amount: 1, + from: sourceCurrencyCode, + to: targetCurrencyCode) + return ClassifiedInput( + currencyCode: conversion == nil ? sourceCurrencyCode : targetCurrencyCode, + input: input, + costMultiplier: conversion ?? 1) } let groups = Dictionary(grouping: classifiedInputs, by: { $0.currencyCode }) .map { currencyCode, inputs in Self.buildCurrencyGroup( currencyCode: currencyCode, - inputs: inputs.map(\.input), + inputs: inputs, days: days, now: now, calendar: calculationCalendar) @@ -110,8 +121,15 @@ struct SpendDashboardModel: Equatable, Sendable { return Self(requestedDays: days, groups: groups) } + private struct ClassifiedInput { + let currencyCode: String + let input: ProviderInput + let costMultiplier: Double + } + private struct InputSummary { let input: ProviderInput + let costMultiplier: Double let entries: [WindowEntry] let totalTokens: Int? let totalCost: Double? @@ -162,14 +180,18 @@ struct SpendDashboardModel: Equatable, Sendable { private static func buildCurrencyGroup( currencyCode: String, - inputs: [ProviderInput], + inputs: [ClassifiedInput], days: Int, now: Date, calendar: Calendar) -> CurrencyGroup { let bounds = Self.bounds(days: days, now: now, calendar: calendar) - let summaries = inputs.map { input in - Self.inputSummary(input: input, bounds: bounds, calendar: calendar) + let summaries = inputs.map { classified in + Self.inputSummary( + input: classified.input, + costMultiplier: classified.costMultiplier, + bounds: bounds, + calendar: calendar) } let providers = Self.providerRows(summaries) let completeModelSummaries = summaries.filter { summary in @@ -195,6 +217,7 @@ struct SpendDashboardModel: Equatable, Sendable { private static func inputSummary( input: ProviderInput, + costMultiplier: Double, bounds: ClosedRange, calendar: Calendar) -> InputSummary { @@ -234,9 +257,12 @@ struct SpendDashboardModel: Equatable, Sendable { ? nil : entries.isEmpty ? (coveredDayCount > 0 && hasCompleteCostHistory ? 0 : nil) - : Self.completeCostSum(entries.map { Self.validCost($0.entry.costUSD) }) + : Self.completeCostSum(entries.map { + Self.validCost($0.entry.costUSD).map { $0 * costMultiplier } + }) return InputSummary( input: input, + costMultiplier: costMultiplier, entries: entries, totalTokens: totalTokens, totalCost: totalCost, @@ -301,7 +327,7 @@ struct SpendDashboardModel: Equatable, Sendable { } else { aggregate.invalidTokens = true } - if let cost = Self.validCost(breakdown.costUSD) { + if let cost = Self.validCost(breakdown.costUSD).map({ $0 * summary.costMultiplier }) { aggregate.sawCost = true aggregate.cost = Self.add(cost, to: aggregate.cost, overflowed: &aggregate.overflowedCost) } else { @@ -482,7 +508,7 @@ struct SpendDashboardModel: Equatable, Sendable { provider: input.provider, providerName: input.displayName, cost: 0) - if let cost = Self.validCost(entry.costUSD) { + if let cost = Self.validCost(entry.costUSD).map({ $0 * summary.costMultiplier }) { aggregate.cost = Self.add(cost, to: aggregate.cost, overflowed: &aggregate.overflowed) } else { aggregate.invalid = true @@ -606,9 +632,9 @@ struct SpendDashboardModel: Equatable, Sendable { } private static func bucketCalendar(for provider: UsageProvider, displayCalendar: Calendar) -> Calendar { - guard provider == .mistral else { return displayCalendar } - // Mistral labels both daily buckets and snapshot coverage by UTC day. Map each UTC boundary into the - // containing local dashboard day instead of reinterpreting the label as a local date. + guard provider == .mistral || provider == .xai else { return displayCalendar } + // Mistral and xAI label both daily buckets and snapshot coverage by UTC day. Map each UTC boundary into + // the containing local dashboard day instead of reinterpreting the label as a local date. return self.gregorianCalendar(timeZone: TimeZone(secondsFromGMT: 0) ?? .gmt) } diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index e14db6fe6..25f7b2db4 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -976,7 +976,10 @@ extension StatusItemController { self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) == .automatic, let balance = snapshot?.openRouterUsage?.balance { - return UsageFormatter.usdString(balance) + return UsageFormatter.convertedCostString( + balance, + preferredCurrency: self.settings.preferredCurrencyCode, + providerCurrency: "USD") } if provider == .opencodego, let balance = Self.openCodeGoZenBalanceDisplayText(snapshot: snapshot) diff --git a/Sources/CodexBar/StatusItemController+MenuBarLayout.swift b/Sources/CodexBar/StatusItemController+MenuBarLayout.swift index 1899bce45..f39fbd21f 100644 --- a/Sources/CodexBar/StatusItemController+MenuBarLayout.swift +++ b/Sources/CodexBar/StatusItemController+MenuBarLayout.swift @@ -90,12 +90,20 @@ extension StatusItemController { -> (today: String?, last30Days: String?) { let snapshot = self.store.tokenSnapshotForCurrentProviderConfig(for: provider)?.snapshot - let currencyCode = snapshot?.currencyCode ?? "USD" + let sourceCurrencyCode = snapshot?.currencyCode ?? "USD" + let preferredCurrencyCode = self.settings.preferredCurrencyCode + let today = MenuBarLayoutCostResolver.todayCostUSD(snapshot: snapshot, now: now).map { - UsageFormatter.currencyString($0, currencyCode: currencyCode) + UsageFormatter.convertedCostString( + $0, + preferredCurrency: preferredCurrencyCode, + providerCurrency: sourceCurrencyCode) } let last30Days = snapshot?.last30DaysCostUSD.map { - UsageFormatter.currencyString($0, currencyCode: currencyCode) + UsageFormatter.convertedCostString( + $0, + preferredCurrency: preferredCurrencyCode, + providerCurrency: sourceCurrencyCode) } return (today, last30Days) } diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index 43d02f638..6e53de36b 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -88,48 +88,13 @@ extension StatusItemController { let sourceLabel = surface == .liveCard ? self.store.sourceLabel(for: target) : nil let kiloAutoMode = target == .kilo && self.settings.kiloUsageDataSource == .auto - // Abacus and Kimi carry their long-cadence window in primary rather than secondary. - let paceWindow = target == .abacus || target == .kimi ? snapshot?.primary : snapshot?.secondary - let sessionEquivalentHistorySelection = self.sessionEquivalentHistorySelection( - provider: target, + let (weeklyPace, sessionEquivalentForecast) = self.resolvePaceAndForecast( + target: target, snapshot: snapshot, + codexProjection: codexProjection, usesOverrideCard: surface == .overrideCard, - override: historySelectionOverride) - let weeklyPace = if let codexProjection, - let weekly = codexProjection.rateWindow(for: .weekly) - { - self.store.weeklyPace(provider: target, window: weekly, now: now) - } else { - paceWindow.flatMap { window in - self.store.weeklyPace(provider: target, window: window, now: now) - } - } - let sessionEquivalentForecast: SessionEquivalentForecast? = if let codexProjection, - let session = codexProjection - .rateWindow(for: .session), - let weekly = codexProjection - .rateWindow(for: .weekly) - { - self.store.sessionEquivalentForecast( - provider: target, - sessionWindow: session, - weeklyWindow: weekly, - historySelection: sessionEquivalentHistorySelection, - now: now) - } else if let snapshot, - let windows = self.store.sessionEquivalentWindows(provider: target, snapshot: snapshot) - { - self.store.sessionEquivalentForecast( - provider: target, - sessionWindow: windows.session, - weeklyWindow: windows.weekly, - weeklyWindowID: windows.weeklyWindowID, - historyIdentity: windows.historyIdentity, - historySelection: sessionEquivalentHistorySelection, - now: now) - } else { - nil - } + historySelectionOverride: historySelectionOverride, + now: now) let fallbackAccount = accountOverride ?? (metadata.usesAccountFallback ? self.store.accountInfo(for: target) @@ -187,10 +152,67 @@ extension StatusItemController { ], workDaysPerWeek: self.settings.weeklyProgressWorkDays, usesLiveSubtitle: surface == .liveCard, + preferredCurrencyCode: self.settings.preferredCurrencyCode, now: now) return UsageMenuCardView.Model.make(input) } + // swiftlint:disable:next function_parameter_count + private func resolvePaceAndForecast( + target: UsageProvider, + snapshot: UsageSnapshot?, + codexProjection: CodexConsumerProjection?, + usesOverrideCard: Bool, + historySelectionOverride: PlanUtilizationHistorySelection?, + now: Date) + -> (weeklyPace: UsagePace?, sessionEquivalentForecast: SessionEquivalentForecast?) + { + let paceWindow = target == .abacus || target == .kimi + ? snapshot?.primary : snapshot?.secondary + let historySelection = self.sessionEquivalentHistorySelection( + provider: target, + snapshot: snapshot, + usesOverrideCard: usesOverrideCard, + override: historySelectionOverride) + let weeklyPace = if let codexProjection, + let weekly = codexProjection.rateWindow(for: .weekly) + { + self.store.weeklyPace(provider: target, window: weekly, now: now) + } else { + paceWindow.flatMap { window in + self.store.weeklyPace(provider: target, window: window, now: now) + } + } + let forecast: SessionEquivalentForecast? = if let codexProjection, + let session = codexProjection + .rateWindow(for: .session), + let weekly = codexProjection + .rateWindow(for: .weekly) + { + self.store.sessionEquivalentForecast( + provider: target, + sessionWindow: session, + weeklyWindow: weekly, + historySelection: historySelection, + now: now) + } else if let snapshot, + let windows = self.store.sessionEquivalentWindows( + provider: target, snapshot: snapshot) + { + self.store.sessionEquivalentForecast( + provider: target, + sessionWindow: windows.session, + weeklyWindow: windows.weekly, + weeklyWindowID: windows.weeklyWindowID, + historyIdentity: windows.historyIdentity, + historySelection: historySelection, + now: now) + } else { + nil + } + return (weeklyPace, forecast) + } + private func sessionEquivalentHistorySelection( provider: UsageProvider, snapshot: UsageSnapshot?, diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index d663780e7..6943601fd 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -455,6 +455,11 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin selector: #selector(self.handleQuotaWarningPosted(_:)), name: .codexbarQuotaWarningDidPost, object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.handleCurrencyExchangeRatesDidChange), + name: .codexbarCurrencyExchangeRatesDidChange, + object: nil) if observeProviderConfigNotifications { NotificationCenter.default.addObserver( self, @@ -602,6 +607,11 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.handleProviderConfigChange(reason: "notification:\(reason)") } + @objc private func handleCurrencyExchangeRatesDidChange() { + self.invalidateMenus(refreshOpenMenus: true) + self.updateIcons() + } + @objc private func handleQuotaWarningPosted(_ notification: Notification) { guard let event = notification.object as? QuotaWarningPostedEvent else { return } self.startQuotaWarningFlash(provider: event.provider, postedAt: event.postedAt) diff --git a/Sources/CodexBar/Sync/SyncCoordinator.swift b/Sources/CodexBar/Sync/SyncCoordinator.swift index 778574324..24881835c 100644 --- a/Sources/CodexBar/Sync/SyncCoordinator.swift +++ b/Sources/CodexBar/Sync/SyncCoordinator.swift @@ -719,7 +719,9 @@ final class SyncCoordinator { statusMessage: syncedStatusMessage, isError: error != nil, lastUpdated: snapshot?.updatedAt ?? Date(), - costSummary: sharedCostSummary ?? Self.mapMistralCostSummary(provider: provider, snapshot: snapshot), + costSummary: sharedCostSummary + ?? Self.mapMistralCostSummary(provider: provider, snapshot: snapshot) + ?? Self.mapXAICostSummary(provider: provider, snapshot: snapshot), budget: budgetSnap, rateWindows: rateWindows, utilizationHistory: sharedUtilizationHistory, @@ -766,6 +768,10 @@ final class SyncCoordinator { let period = providerCost.period ?? "Last 30 days" return "\(period) spend: \(providerCost.currencyCode) \(amount)" } + if provider == .xai, let xaiUsage = snapshot?.xaiUsage { + let amount = String(format: "%.2f", xaiUsage.balanceUSD) + return "Prepaid credits: USD \(amount)" + } guard provider == .copilot, rateWindows.isEmpty, let plan = snapshot?.identity?.loginMethod?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -780,10 +786,16 @@ final class SyncCoordinator { provider: UsageProvider, providerCost: ProviderCostSnapshot?) -> SyncBudgetSnapshot? { - // ZenMux and Neuralwatt report remaining balances through + // ZenMux, Neuralwatt, and xAI report remaining balances through // ProviderCostSnapshot with a zero limit. Those are not used/limit // budgets and would render on iOS as the false statement "$balance / $0". - guard provider != .zenmux, provider != .neuralwatt, provider != .aiand else { return nil } + guard provider != .zenmux, + provider != .neuralwatt, + provider != .aiand, + provider != .xai + else { + return nil + } if provider == .claude, let providerCost, providerCost.limit <= 0, @@ -1513,6 +1525,34 @@ final class SyncCoordinator { currencyCode: tokenSnapshot?.currencyCode) } + static func mapXAICostSummary( + provider: UsageProvider, + snapshot: UsageSnapshot?) -> SyncCostSummary? + { + guard provider == .xai, + let usage = snapshot?.xaiUsage, + let projected = usage.costHistorySnapshot() + else { + return nil + } + let isEstimated = usage.limitReached ? true : nil + return SyncCostSummary( + sessionCostUSD: projected.sessionCostUSD, + sessionTokens: nil, + last30DaysCostUSD: projected.last30DaysCostUSD, + last30DaysTokens: nil, + daily: projected.daily.map { entry in + SyncDailyPoint( + dayKey: entry.date, + costUSD: entry.costUSD ?? 0, + totalTokens: 0, + isEstimated: isEstimated) + }, + isEstimated: isEstimated, + historyDays: projected.historyDays, + currencyCode: projected.currencyCode) + } + private func modelBreakdowns( from entry: CostUsageDailyReport.Entry?, provider: UsageProvider) -> [SyncCostBreakdown] @@ -1582,7 +1622,7 @@ final class SyncCoordinator { // from their own APIs/local sessions — never via the local // pricing tables. .devin, .zed, .sakana, .poe, .chutes, .qoder, .clawrouter, .wayfinder, .sub2api, - .zenmux, .clinepass, .longcat, .neuralwatt, .deepinfra, .aiand, .qwencloud, .zoommate: + .zenmux, .clinepass, .longcat, .neuralwatt, .deepinfra, .aiand, .qwencloud, .zoommate, .xai: // These providers never reach the local pricing table — their // costs come pre-computed from upstream APIs (or don't exist). // No fallback applies, so they are never "estimated". diff --git a/Sources/CodexBar/UsageStore+QuotaWarnings.swift b/Sources/CodexBar/UsageStore+QuotaWarnings.swift index ccc92bc46..b3acef7b6 100644 --- a/Sources/CodexBar/UsageStore+QuotaWarnings.swift +++ b/Sources/CodexBar/UsageStore+QuotaWarnings.swift @@ -39,8 +39,13 @@ extension UsageStore { primaryWindow = Self.antigravityWindow(snapshot: snapshot, windowMinutes: 5 * 60) secondaryWindow = Self.antigravityWindow(snapshot: snapshot, windowMinutes: 7 * 24 * 60) } else { - primaryWindow = provider == .mimo || provider == .qoder ? nil : snapshot.primary - secondaryWindow = provider == .mimo || provider == .qoder ? nil : snapshot.secondary + // Crof credits-only accounts publish a duration-less balance as `primary`; a drained + // prepaid balance is not a quota threshold crossing, so it must not raise warnings. + // Crof accounts that do expose request quotas (secondary present) keep normal warnings. + let isBalanceOnlyCrof = provider == .crof && snapshot.secondary == nil + let suppressWindows = provider == .mimo || provider == .qoder || isBalanceOnlyCrof + primaryWindow = suppressWindows ? nil : snapshot.primary + secondaryWindow = suppressWindows ? nil : snapshot.secondary } let primaryWindowDisplayLabel = provider == .amp ? AmpProviderDescriptor.primaryLabel(details: snapshot.ampUsage) diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index e5b16dbfa..f27bb27da 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -439,6 +439,9 @@ extension UsageStore { { return dyn } + if provider == .crof { + return CrofProviderDescriptor.primaryLabel(snapshot: snapshot) + } if provider == .alibabatokenplan, let dyn = AlibabaTokenPlanProviderDescriptor.primaryLabel(window: snapshot.primary) { diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 0a13e2849..549e22c39 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1148,6 +1148,7 @@ extension UsageStore { .groq: "Groq debug log not yet implemented", .t3chat: "T3 Chat debug log not yet implemented", .zoommate: "ZoomMate debug log not yet implemented", + .xai: "xAI debug log not yet implemented", .llmproxy: "LLM Proxy debug log not yet implemented", .litellm: "LiteLLM debug log not yet implemented", .deepgram: "Deepgram debug log not yet implemented", @@ -1236,7 +1237,7 @@ extension UsageStore { .mimo, .doubao, .sakana, .abacus, .mistral, .deepinfra, .codebuff, .crof, .windsurf, .venice, .manus, .commandcode, .qoder, .stepfun, .bedrock, .grok, .groq, .t3chat, .llmproxy, .litellm, .zed, .deepgram, .poe, .chutes, .neuralwatt, .clawrouter, .longcat, .wayfinder, - .sub2api, .zenmux, .aiand, .zoommate: + .sub2api, .zenmux, .aiand, .zoommate, .xai: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } } diff --git a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift index 22ab65ded..756b70587 100644 --- a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift +++ b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift @@ -178,6 +178,8 @@ extension CodexBarCLI { } let hasConfigAPIAuth = if provider == .bedrock { config?.sanitizedAPIKey != nil && config?.sanitizedSecretKey != nil + } else if provider == .xai { + config?.sanitizedAPIKey != nil && config?.sanitizedWorkspaceID != nil } else { config?.sanitizedAPIKey != nil || config?.sanitizedSecretKey != nil } @@ -283,6 +285,9 @@ extension CodexBarCLI { VeniceSettingsReader.apiKey(environment: environment) != nil case .warp: WarpSettingsReader.apiKey(environment: environment) != nil + case .xai: + XAISettingsReader.apiKey(environment: environment) != nil && + XAISettingsReader.teamID(environment: environment) != nil case .zai: ZaiSettingsReader.apiToken(environment: environment) != nil default: diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index c9f90eaf9..b9346082a 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -41,6 +41,7 @@ enum CLIRenderer { self.appendWayfinderUsageLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) self.appendDeepgramLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) self.appendAmpBalanceLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) + self.appendXAIUsageLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) self.appendDevinOverageBalanceLine( provider: provider, snapshot: snapshot, @@ -109,6 +110,7 @@ enum CLIRenderer { self.appendWayfinderUsageLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) self.appendDeepgramLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) self.appendAmpBalanceLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) + self.appendXAIUsageLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) self.appendDevinOverageBalanceLine( provider: provider, snapshot: snapshot, @@ -496,6 +498,7 @@ enum CLIRenderer { self.appendWayfinderUsageLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) self.appendDeepgramLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) self.appendAmpBalanceLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) + self.appendXAIUsageLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) self.appendDevinOverageBalanceLine( provider: provider, snapshot: snapshot, @@ -613,7 +616,10 @@ enum CLIRenderer { provider != .clawrouter, let cost = snapshot.providerCost, !(provider == .devin && cost.period == "Extra usage balance"), - !(provider == .claude && cost.used == 0 && cost.limit == 0 && cost.balance != nil) + !(provider == .claude && cost.used == 0 && cost.limit == 0 && cost.balance != nil), + // xAI's providerCost carries the prepaid balance, not a spend/limit + // pair; the dedicated balance line renders it instead. + !(provider == .xai && cost.period == "Prepaid credits") else { return } // Fallback to cost/quota display if no primary rate window. let label = cost.currencyCode == "Quota" ? "Quota" : "Cost" @@ -678,6 +684,20 @@ enum CLIRenderer { lines.append(self.labelValueLine("Extra usage balance", value: value, useColor: useColor)) } + private static func appendXAIUsageLines( + snapshot: UsageSnapshot, + useColor: Bool, + lines: inout [String]) + { + guard let usage = snapshot.xaiUsage else { return } + let balance = UsageFormatter.currencyString(usage.balanceUSD, currencyCode: "USD") + lines.append(self.labelValueLine("Posted balance", value: balance, useColor: useColor)) + lines.append(self.labelValueLine("Ledger", value: "May lag current-cycle spend", useColor: useColor)) + guard !usage.daily.isEmpty else { return } + let spend = UsageFormatter.currencyString(usage.windowCostUSD, currencyCode: "USD") + lines.append(self.labelValueLine(usage.historyWindowPeriodLabel, value: spend, useColor: useColor)) + } + private static func appendClawRouterUsageLines( snapshot: UsageSnapshot, useColor: Bool, @@ -839,6 +859,8 @@ enum CLIRenderer { } let primaryLabel = if provider == .grok { GrokProviderDescriptor.primaryLabel(window: snapshot.primary) ?? metadata.sessionLabel + } else if provider == .crof { + CrofProviderDescriptor.primaryLabel(snapshot: snapshot) } else if provider == .sub2api { Sub2APIProviderDescriptor.primaryLabel(details: snapshot.sub2APIUsage) ?? metadata.sessionLabel } else if provider == .amp { @@ -949,7 +971,9 @@ enum CLIRenderer { } private static func nonCodexPlanDisplay(provider: UsageProvider, plan: String) -> String { - if provider == .gemini || provider == .mimo { + // cleanPlanName preserves acronyms ("Management API"); `.capitalized` + // would mangle them into "Management Api". + if provider == .gemini || provider == .mimo || provider == .xai { return UsageFormatter.cleanPlanName(plan) } return plan.capitalized diff --git a/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift b/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift index a14c2aeff..50cb2ab67 100644 --- a/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift +++ b/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift @@ -201,6 +201,8 @@ enum DashboardSnapshotBuilder { let primaryLabel = if provider == .amp { AmpProviderDescriptor.primaryLabel(details: usage.ampUsage) ?? metadata?.sessionLabel ?? "Session" + } else if provider == .crof { + CrofProviderDescriptor.primaryLabel(snapshot: usage) } else { metadata?.sessionLabel ?? "Session" } diff --git a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift index 2c6e7cdca..c92bbac8f 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift @@ -46,6 +46,7 @@ public enum CodexBarConfigValidator { .opencodego, .devin, .deepgram, + .xai, ] public static func validate(_ config: CodexBarConfig) -> [CodexBarConfigIssue] { @@ -224,6 +225,8 @@ public enum CodexBarConfigValidator { self.validateZaiTeamContext(entry, issues: &issues) + self.validateXAIManagementContext(entry, issues: &issues) + if let workspaceID = entry.workspaceID, !workspaceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !self.providerSupportsWorkspaceID(provider) @@ -317,6 +320,24 @@ public enum CodexBarConfigValidator { message: "z.ai Team mode requires both organizationID and workspaceID.") } + private static func validateXAIManagementContext( + _ entry: ProviderConfig, + issues: inout [CodexBarConfigIssue]) + { + guard entry.id == .xai else { return } + + let hasAPIKey = entry.sanitizedAPIKey != nil + let hasTeamID = entry.sanitizedWorkspaceID != nil + guard hasAPIKey != hasTeamID else { return } + + issues.append(CodexBarConfigIssue( + severity: .warning, + provider: .xai, + field: hasAPIKey ? "workspaceID" : "apiKey", + code: "xai_management_context_missing", + message: "xAI Management API access requires both apiKey and workspaceID (team ID).")) + } + private static func providerSupportsWorkspaceID(_ provider: UsageProvider) -> Bool { self.workspaceIDProviders.contains(provider) } diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index ee51b8639..0fbe43339 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -97,6 +97,8 @@ public enum ProviderConfigEnvironment { self.applyDeepSeekOverrides(base: base, config: config) case .deepgram: self.applyDeepgramOverrides(base: base, config: config) + case .xai: + self.applyXAIOverrides(base: base, config: config) case .azureopenai: self.applyAzureOpenAIOverrides(base: base, config: config) case .kimi: @@ -185,7 +187,8 @@ public enum ProviderConfigEnvironment { GroqSettingsReader.apiKeyEnvironmentKey case .llmproxy: LLMProxySettingsReader.apiKeyEnvironmentKey - case .chutes, .poe, .litellm, .clawrouter, .factory, .sub2api, .neuralwatt, .zenmux, .deepinfra, .aiand: + case .chutes, .poe, .litellm, .clawrouter, .factory, .sub2api, .neuralwatt, .zenmux, .deepinfra, .aiand, + .xai: self.additionalAPIKeyEnvironmentKey(for: provider) default: nil @@ -214,6 +217,8 @@ public enum ProviderConfigEnvironment { DeepInfraSettingsReader.apiKeyEnvironmentKey case .aiand: AiAndSettingsReader.apiKeyEnvironmentKey + case .xai: + XAISettingsReader.apiKeyEnvironmentKey default: nil } @@ -310,6 +315,25 @@ public enum ProviderConfigEnvironment { return env } + private static func applyXAIOverrides( + base: [String: String], + config: ProviderConfig?) -> [String: String] + { + guard let config else { return base } + + var env = base + + if let apiKey = config.sanitizedAPIKey { + env[XAISettingsReader.apiKeyEnvironmentKey] = apiKey + } + + if let teamID = config.sanitizedWorkspaceID { + env[XAISettingsReader.teamIDEnvironmentKey] = teamID + } + + return env + } + private static func applyAPIKeyAndBaseURLOverrides( base: [String: String], provider: UsageProvider, diff --git a/Sources/CodexBarCore/CurrencyExchange.swift b/Sources/CodexBarCore/CurrencyExchange.swift new file mode 100644 index 000000000..da9b455e3 --- /dev/null +++ b/Sources/CodexBarCore/CurrencyExchange.swift @@ -0,0 +1,194 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Manages currency exchange rates for converting USD-denominated AI model token estimates +/// into user-preferred currencies (GBP, EUR, CNY, JPY, CAD, AUD, etc.). +/// +/// Rates are sourced from the ExchangeRate-API (open.er-api.com), a free service +/// aggregating data from central banks and market sources. Rates are updated daily +/// and cached locally for 24 hours. Hardcoded fallback rates are used when the +/// network is unavailable (e.g. first launch offline). +public final class CurrencyExchange: @unchecked Sendable { + public static let shared = CurrencyExchange() + + /// All currency codes supported by the converter. + public static let supportedCurrencies: [String] = [ + "USD", "GBP", "EUR", "CNY", "JPY", "CAD", "AUD", "HKD", "TWD", "SGD", "INR", + ] + + private let lock = NSLock() + /// Hardcoded fallback rates (approximate mid-market rates as of 2025-07). + /// These are only used when no cached or live rates are available. + private var rates: [String: Double] = [ + "USD": 1.0, + "GBP": 0.79, + "EUR": 0.92, + "CNY": 7.27, + "JPY": 154.0, + "CAD": 1.38, + "AUD": 1.55, + "HKD": 7.80, + "TWD": 32.30, + "SGD": 1.34, + "INR": 84.50, + ] + private var lastFetchTime: Date? + + private static let userDefaultsKey = "QuotaKit.CurrencyExchangeRates" + private static let lastFetchKey = "QuotaKit.CurrencyExchangeLastFetch" + + public init() { + self.loadCachedRates() + } + + /// Converts a USD amount to the specified target currency code. + /// Returns `nil` when the requested rate is unavailable so callers cannot + /// accidentally relabel the unchanged amount as the target currency. + public func convert(usdAmount: Double, to currencyCode: String) -> Double? { + let code = currencyCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + guard usdAmount.isFinite, Self.supportedCurrencies.contains(code) else { return nil } + guard code != "USD" else { return usdAmount } + + self.lock.lock() + let rate = self.rates[code] + self.lock.unlock() + + guard let rate, rate.isFinite, rate > 0 else { return nil } + let converted = usdAmount * rate + return converted.isFinite ? converted : nil + } + + /// Converts an amount from one currency to another via USD as the pivot. + /// For example, `convert(amount: 10, from: "GBP", to: "CNY")` converts £10 to yuan. + /// Returns `nil` when either currency rate is unavailable. + public func convert(amount: Double, from sourceCurrency: String, to targetCurrency: String) -> Double? { + let source = sourceCurrency.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + let target = targetCurrency.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + guard amount.isFinite, + Self.supportedCurrencies.contains(source), + Self.supportedCurrencies.contains(target) + else { + return nil + } + guard source != target else { return amount } + + self.lock.lock() + let sourceRate = source == "USD" ? 1.0 : self.rates[source] + let targetRate = target == "USD" ? 1.0 : self.rates[target] + self.lock.unlock() + + guard let sourceRate, + sourceRate.isFinite, + sourceRate > 0, + let targetRate, + targetRate.isFinite, + targetRate > 0 + else { + return nil + } + let usdAmount = amount / sourceRate + let converted = usdAmount * targetRate + return converted.isFinite ? converted : nil + } + + /// Returns the exchange rate for a given currency code relative to USD. + public func rate(for currencyCode: String) -> Double? { + let code = currencyCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + guard Self.supportedCurrencies.contains(code) else { return nil } + self.lock.lock() + defer { self.lock.unlock() } + guard let rate = self.rates[code], rate.isFinite, rate > 0 else { return nil } + return rate + } + + private func getLastFetchTime() -> Date? { + self.lock.lock() + defer { self.lock.unlock() } + return self.lastFetchTime + } + + private func updateRates(_ newRates: [String: Double]) -> (accepted: [String: Double], changed: Bool) { + let accepted = Self.validatedRates(newRates) + guard accepted.count == Self.supportedCurrencies.count else { return ([:], false) } + self.lock.lock() + var changed = false + for (code, rate) in accepted { + changed = changed || self.rates[code] != rate + self.rates[code] = rate + } + self.lastFetchTime = Date() + self.lock.unlock() + return (accepted, changed) + } + + /// Loads cached rates from `UserDefaults`. + private func loadCachedRates() { + if let data = UserDefaults.standard.dictionary(forKey: Self.userDefaultsKey) as? [String: Double] { + let accepted = Self.validatedRates(data) + self.lock.lock() + for (key, val) in accepted { + self.rates[key] = val + } + self.lock.unlock() + } + if let timestamp = UserDefaults.standard.object(forKey: Self.lastFetchKey) as? Date { + self.lastFetchTime = timestamp + } + } + + public static func requiresLiveRates(preferredCurrencyCode: String) -> Bool { + let code = preferredCurrencyCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + return code != "AUTO" && Self.supportedCurrencies.contains(code) + } + + /// Asynchronously fetches latest rates from open.er-api.com if the user selected an + /// explicit currency and the cache is older than 24 hours. + /// + /// The ExchangeRate-API free tier provides daily-updated rates sourced from central banks + /// and financial data providers. This is sufficient for cost estimation purposes. + /// On failure, the previously cached (or hardcoded fallback) rates remain in use. + @discardableResult + public func fetchLatestRatesIfNeeded(preferredCurrencyCode: String) async -> Bool { + guard Self.requiresLiveRates(preferredCurrencyCode: preferredCurrencyCode) else { return false } + if let lastFetch = self.getLastFetchTime(), Date().timeIntervalSince(lastFetch) < 86400 { + return false + } + + guard let url = URL(string: "https://open.er-api.com/v6/latest/USD") else { return false } + + do { + let (data, response) = try await URLSession.shared.data(from: url) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { return false } + + struct ExchangeResponse: Decodable { + let result: String + let rates: [String: Double]? + } + + let decoded = try JSONDecoder().decode(ExchangeResponse.self, from: data) + if decoded.result == "success", let newRates = decoded.rates { + let update = self.updateRates(newRates) + guard !update.accepted.isEmpty else { return false } + + UserDefaults.standard.set(update.accepted, forKey: Self.userDefaultsKey) + UserDefaults.standard.set(Date(), forKey: Self.lastFetchKey) + return update.changed + } + } catch { + // Ignore fetch errors, keep using fallback / cached rates. + } + return false + } + + static func validatedRates(_ candidateRates: [String: Double]) -> [String: Double] { + let supported = Set(Self.supportedCurrencies) + return candidateRates.reduce(into: [:]) { result, entry in + let code = entry.key.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + guard supported.contains(code), entry.value.isFinite, entry.value > 0 else { return } + guard code != "USD" || entry.value == 1 else { return } + result[code] = entry.value + } + } +} diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 8bd4ed473..09f058043 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "36f15d07d58b71f2" + static let value = "a452fc82b53e558e" } diff --git a/Sources/CodexBarCore/ProviderIdentitySnapshot.swift b/Sources/CodexBarCore/ProviderIdentitySnapshot.swift new file mode 100644 index 000000000..fe94ae404 --- /dev/null +++ b/Sources/CodexBarCore/ProviderIdentitySnapshot.swift @@ -0,0 +1,42 @@ +import Foundation + +public struct ProviderIdentitySnapshot: Codable, Sendable { + public let providerID: UsageProvider? + public let accountEmail: String? + public let accountOrganization: String? + public let loginMethod: String? + public let accountID: String? + + public init( + providerID: UsageProvider?, + accountEmail: String?, + accountOrganization: String?, + loginMethod: String?, + accountID: String? = nil) + { + self.providerID = providerID + self.accountEmail = accountEmail + self.accountOrganization = accountOrganization + self.loginMethod = loginMethod + self.accountID = accountID + } + + public func scoped(to provider: UsageProvider) -> ProviderIdentitySnapshot { + if self.providerID == provider { + return self + } + return ProviderIdentitySnapshot( + providerID: provider, + accountEmail: self.accountEmail, + accountOrganization: self.accountOrganization, + loginMethod: self.loginMethod, + accountID: self.accountID) + } +} + +public enum UsageDataConfidence: String, Codable, Equatable, Sendable { + case exact + case estimated + case percentOnly + case unknown +} diff --git a/Sources/CodexBarCore/Providers/Crof/CrofProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Crof/CrofProviderDescriptor.swift index 825044b11..ace227fe9 100644 --- a/Sources/CodexBarCore/Providers/Crof/CrofProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Crof/CrofProviderDescriptor.swift @@ -9,7 +9,7 @@ public enum CrofProviderDescriptor { metadata: ProviderMetadata( id: .crof, displayName: "Crof", - sessionLabel: "Requests", + sessionLabel: "Credits", weeklyLabel: "Credits", opusLabel: nil, supportsOpus: false, @@ -48,4 +48,8 @@ public enum CrofProviderDescriptor { aliases: ["crofai"], versionDetector: nil)) } + + public static func primaryLabel(snapshot: UsageSnapshot) -> String { + snapshot.secondary == nil ? "Credits" : "Requests" + } } diff --git a/Sources/CodexBarCore/Providers/Crof/CrofUsageFetcher.swift b/Sources/CodexBarCore/Providers/Crof/CrofUsageFetcher.swift index c8c3a4cac..b9463c874 100644 --- a/Sources/CodexBarCore/Providers/Crof/CrofUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Crof/CrofUsageFetcher.swift @@ -5,8 +5,8 @@ import FoundationNetworking public struct CrofUsageResponse: Decodable, Sendable { public let credits: Double - public let requestsPlan: Double - public let usableRequests: Double + public let requestsPlan: Double? + public let usableRequests: Double? enum CodingKeys: String, CodingKey { case credits @@ -80,6 +80,11 @@ public enum CrofUsageFetcher { throw CrofUsageError.parseFailed(error.localizedDescription) } + guard (decoded.requestsPlan == nil) == (decoded.usableRequests == nil) else { + throw CrofUsageError.parseFailed( + "requests_plan and usable_requests must both be present or both be null") + } + return CrofUsageSnapshot( credits: decoded.credits, requestsPlan: decoded.requestsPlan, diff --git a/Sources/CodexBarCore/Providers/Crof/CrofUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Crof/CrofUsageSnapshot.swift index ee76bcb9e..077ae5ad4 100644 --- a/Sources/CodexBarCore/Providers/Crof/CrofUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Crof/CrofUsageSnapshot.swift @@ -5,14 +5,14 @@ public struct CrofUsageSnapshot: Sendable { private static let resetTimeZone = TimeZone(identifier: "America/Chicago") ?? TimeZone(secondsFromGMT: -5)! public let credits: Double - public let requestsPlan: Double - public let usableRequests: Double + public let requestsPlan: Double? + public let usableRequests: Double? public let updatedAt: Date public init( credits: Double, - requestsPlan: Double, - usableRequests: Double, + requestsPlan: Double? = nil, + usableRequests: Double? = nil, updatedAt: Date = Date()) { self.credits = credits @@ -22,32 +22,37 @@ public struct CrofUsageSnapshot: Sendable { } public func toUsageSnapshot() -> UsageSnapshot { - let usedPercent: Double - if self.requestsPlan > 0 { - let usableRequests = max(0, min(self.requestsPlan, self.usableRequests)) - let remainingPercent = floor(usableRequests / self.requestsPlan * 100).clamped(to: 0...100) - usedPercent = 100 - remainingPercent - } else { - usedPercent = 100 - } - - let primary = RateWindow( - usedPercent: usedPercent, - windowMinutes: Self.requestWindowMinutes, - resetsAt: Self.nextRequestReset(after: self.updatedAt), - resetDescription: Self.formatRequestsLeft(self.usableRequests)) - let creditsDetail = Self.formatCredits(self.credits) - let secondary = RateWindow( + let creditsWindow = RateWindow( // Crof returns a balance but no credit cap, so the bar only indicates present vs. exhausted credits. usedPercent: self.credits > 0 ? 0 : 100, windowMinutes: nil, resetsAt: nil, resetDescription: creditsDetail) + let windows: (primary: RateWindow, secondary: RateWindow?) + if let requestsPlan, let usableRequests { + let usedPercent: Double + if requestsPlan > 0 { + let usableRequests = max(0, min(requestsPlan, usableRequests)) + let remainingPercent = floor(usableRequests / requestsPlan * 100).clamped(to: 0...100) + usedPercent = 100 - remainingPercent + } else { + usedPercent = 100 + } + windows = ( + RateWindow( + usedPercent: usedPercent, + windowMinutes: Self.requestWindowMinutes, + resetsAt: Self.nextRequestReset(after: self.updatedAt), + resetDescription: Self.formatRequestsLeft(usableRequests)), + creditsWindow) + } else { + windows = (creditsWindow, nil) + } return UsageSnapshot( - primary: primary, - secondary: secondary, + primary: windows.primary, + secondary: windows.secondary, providerCost: nil, updatedAt: self.updatedAt, identity: ProviderIdentitySnapshot( diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 165746c0e..6e24825e5 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -188,6 +188,7 @@ public enum ProviderDescriptorRegistry { .zenmux: ZenMuxProviderDescriptor.descriptor, .aiand: AiAndProviderDescriptor.descriptor, .zoommate: ZoomMateProviderDescriptor.descriptor, + .xai: XAIProviderDescriptor.descriptor, ] private static let bootstrap: Void = { for provider in UsageProvider.allCases { diff --git a/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift b/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift index 42734da77..5b383aa8e 100644 --- a/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift +++ b/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift @@ -185,6 +185,7 @@ public struct ProviderDiagnosticUsageSummary: Codable, Sendable { if snapshot.claudeAdminAPIUsage != nil { providerSpecificData.append("claudeAdminAPIUsage") } if snapshot.mistralUsage != nil { providerSpecificData.append("mistralUsage") } if snapshot.deepgramUsage != nil { providerSpecificData.append("deepgramUsage") } + if snapshot.xaiUsage != nil { providerSpecificData.append("xaiUsage") } if snapshot.cursorRequests != nil { providerSpecificData.append("cursorRequests") } self.updatedAt = snapshot.updatedAt diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 391b0c3a3..7033a5ff2 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -68,6 +68,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case zenmux case aiand case zoommate + case xai } // swiftformat:enable sortDeclarations @@ -136,6 +137,7 @@ public enum IconStyle: String, Sendable, CaseIterable { case zenmux case aiand case zoommate + case xai case combined } diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift index 886061ff5..6d7fac3f5 100644 --- a/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift @@ -81,28 +81,31 @@ public struct StepFunRateLimitResponse: Decodable, Sendable { self.status == 1 } - /// Credit-based plans (plan_family=2) report usage via `plan_credit_rate_limit` - /// instead of the five-hour / weekly rate windows. Those rate fields are 0 with - /// reset_time "0" — meaning "no window configured", NOT "fully consumed". + /// StepFun runs two Step Plan billing models side by side after the 2026-06-18 + /// upgrade (docs/zh/step-plan/upgrade-notice): the grandfathered **Coding Plan** + /// meters rolling **5-hour / weekly** windows, while the current **Token Plan** + /// meters a monthly **Credit** pool via `plan_credit_rate_limit` (its rate windows + /// come back as 0 with `reset_time` `"0"` — "no window configured", not "used up"). + /// + /// Classify by the shape the payload actually carries rather than trusting + /// `plan_family` alone: a live rolling window means Coding Plan; no window plus a + /// Credit pool means Token Plan. `plan_family` (2 == the Credit family) is only a + /// tie-breaker for an ambiguous payload (e.g. a brand-new plan with neither a live + /// window nor credit yet), so a future family-id change can't silently flip a + /// windowed plan onto the credit renderer or vice versa. var isCreditPlan: Bool { - // plan_family 2 = credit-based subscription plans (e.g. Mini, Pro). - if let family = self.planFamily?.value, family > 0 { - return family == 2 - } - // Fallback heuristic: if both rate windows are 0 with no reset time, but - // credit data is present, treat as a credit plan. - if let credit = self.planCreditRateLimit, - (credit.subscriptionCreditLeftRate?.value ?? 0) > 0 - { - let fiveHourZero = (self.fiveHourUsageLeftRate?.value ?? 1) == 0 - let weeklyZero = (self.weeklyUsageLeftRate?.value ?? 1) == 0 - let fiveHourNoReset = (self.fiveHourUsageResetTime?.value ?? 0) == 0 - let weeklyNoReset = (self.weeklyUsageResetTime?.value ?? 0) == 0 - if (fiveHourZero && fiveHourNoReset) || (weeklyZero && weeklyNoReset) { - return true - } - } - return false + let hasLiveWindow = (self.fiveHourUsageResetTime?.value ?? 0) > 0 + || (self.weeklyUsageResetTime?.value ?? 0) > 0 + if hasLiveWindow { + return false + } + let hasCreditPool = self.planCreditRateLimit?.subscriptionCreditLeftRate != nil + || self.planCreditRateLimit?.topupCreditLeftRate != nil + || !(self.planCreditRateLimit?.creditBuckets?.isEmpty ?? true) + if hasCreditPool { + return true + } + return (self.planFamily?.value).map { $0 == 2 } ?? false } } @@ -253,9 +256,9 @@ public struct StepFunUsageSnapshot: Sendable { accountOrganization: nil, loginMethod: loginMethod) - // Credit-based plans (plan_family=2) don't have 5h/weekly rate windows. - // Show the credit balance as the primary window and drop the meaningless - // 0%-left rate windows entirely. + // Token Plan (Credit pool) carries no live 5h/weekly windows. Show the credit + // balance as the primary window and drop the meaningless 0%-left rate windows + // entirely. Coding Plan keeps its rolling windows and falls through below. if self.isCreditPlan, let creditRate = self.creditLeftRate { let creditUsedPercent = max(0, min(100, (1.0 - creditRate) * 100)) let resetDate = self.creditResetTime ?? Date.distantFuture diff --git a/Sources/CodexBarCore/Providers/XAI/XAIBillingFetcher.swift b/Sources/CodexBarCore/Providers/XAI/XAIBillingFetcher.swift new file mode 100644 index 000000000..c00d89a46 --- /dev/null +++ b/Sources/CodexBarCore/Providers/XAI/XAIBillingFetcher.swift @@ -0,0 +1,312 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum XAIBillingError: LocalizedError, Sendable, Equatable { + case notConfigured + case missingTeamID + case invalidTeamID + case authenticationRejected + case teamNotFound + case rateLimited + case apiError(Int) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .notConfigured: + "Missing xAI Management API key. Add one in Settings or set XAI_MANAGEMENT_API_KEY. " + + "Inference API keys are not accepted by the Management API." + case .missingTeamID: + "Missing xAI team ID. Add it in Settings or set XAI_TEAM_ID " + + "(shown in the xAI Console URL and team settings)." + case .invalidTeamID: + "The xAI team ID must be a single identifier without path separators." + case .authenticationRejected: + "xAI rejected the Management API key. Create one in the xAI Console under " + + "Settings > Management Keys; inference API keys are not accepted." + case .teamNotFound: + "xAI returned 404 for this team. Check the team ID, and that the Management key " + + "belongs to the same team." + case .rateLimited: + "xAI Management API rate limit exceeded. Usage will refresh on the next cycle." + case let .apiError(statusCode): + "xAI Management API returned HTTP \(statusCode)." + case let .parseFailed(message): + "Could not parse xAI billing data: \(message)" + } + } +} + +public enum XAIBillingFetcher { + static let baseURL = URL(string: "https://management-api.x.ai")! + public static let historyDays = 30 + private static let requestTimeoutSeconds: TimeInterval = 15 + + public static func fetchUsage( + managementKey rawKey: String, + teamID rawTeamID: String, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + now: Date = Date()) async throws -> XAIUsageSnapshot + { + guard let key = XAISettingsReader.cleaned(rawKey) else { + throw XAIBillingError.notConfigured + } + guard let teamID = XAISettingsReader.cleaned(rawTeamID) else { + throw XAIBillingError.missingTeamID + } + guard !teamID.contains("/"), teamID != ".", teamID != ".." else { + throw XAIBillingError.invalidTeamID + } + + let balanceUSD = try await self.fetchBalanceUSD(key: key, teamID: teamID, transport: transport) + + // History is best-effort enrichment: the balance is independently useful, + // so only credential problems (and cancellation) are allowed to escalate. + var daily: [XAIUsageSnapshot.DailyBucket] = [] + var limitReached = false + do { + (daily, limitReached) = try await self.fetchDailyUsage( + key: key, + teamID: teamID, + transport: transport, + now: now) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch XAIBillingError.authenticationRejected { + throw XAIBillingError.authenticationRejected + } catch { + if Task.isCancelled { + throw CancellationError() + } + } + + return XAIUsageSnapshot( + balanceUSD: balanceUSD, + daily: daily, + historyDays: self.historyDays, + limitReached: limitReached, + updatedAt: now) + } + + // MARK: - Balance + + private static func fetchBalanceUSD( + key: String, + teamID: String, + transport: any ProviderHTTPTransport) async throws -> Double + { + var request = URLRequest(url: self.teamURL(teamID: teamID, suffix: ["prepaid", "balance"])) + request.httpMethod = "GET" + request.timeoutInterval = self.requestTimeoutSeconds + request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let response = try await transport.response(for: request) + guard (200..<300).contains(response.statusCode) else { + throw self.error(statusCode: response.statusCode) + } + + let envelope: BalanceEnvelope + do { + envelope = try JSONDecoder().decode(BalanceEnvelope.self, from: response.data) + } catch { + throw XAIBillingError.parseFailed(error.localizedDescription) + } + return try self.balanceUSD(fromLedgerCents: envelope.total.val) + } + + /// The ledger records credit as negative cents (a $10 top-up is "-1000"), + /// so the remaining balance is the negated cent value in dollars. A body + /// without a parseable total must fail loudly — never read as $0.00. + static func balanceUSD(fromLedgerCents raw: String) throws -> Double { + let value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, + value.range(of: #"^-?\d+(\.\d+)?$"#, options: .regularExpression) != nil, + let cents = Double(value), cents.isFinite + else { + throw XAIBillingError.parseFailed("balance total.val is not a cent amount: \(raw)") + } + return -cents / 100.0 + } + + // MARK: - Usage history + + private static func fetchDailyUsage( + key: String, + teamID: String, + transport: any ProviderHTTPTransport, + now: Date) async throws -> ([XAIUsageSnapshot.DailyBucket], Bool) + { + var request = URLRequest(url: self.teamURL(teamID: teamID, suffix: ["usage"])) + request.httpMethod = "POST" + request.timeoutInterval = self.requestTimeoutSeconds + request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(self.usageRequestBody(now: now)) + + let response = try await transport.response(for: request) + guard (200..<300).contains(response.statusCode) else { + throw self.error(statusCode: response.statusCode) + } + + let envelope: UsageEnvelope + do { + envelope = try JSONDecoder().decode(UsageEnvelope.self, from: response.data) + } catch { + throw XAIBillingError.parseFailed(error.localizedDescription) + } + + var totalsByDay: [String: Double] = [:] + for series in envelope.timeSeries { + for point in series.dataPoints { + let day = try self.utcDay(fromTimestamp: point.timestamp) + guard let value = point.values.first, value.isFinite else { + throw XAIBillingError.parseFailed( + "usage point for \(point.timestamp) has no finite USD value") + } + totalsByDay[day, default: 0] += value + } + } + let daily = totalsByDay + .map { XAIUsageSnapshot.DailyBucket(day: $0.key, costUSD: $0.value) } + .sorted { $0.day < $1.day } + return (daily, envelope.limitReached ?? false) + } + + private static func usageRequestBody(now: Date) -> UsageRequestEnvelope { + let calendar = Self.utcCalendar + let windowStart = calendar.startOfDay( + for: calendar.date(byAdding: .day, value: -(self.historyDays - 1), to: now) ?? now) + return UsageRequestEnvelope( + analyticsRequest: .init( + timeRange: .init( + startTime: Self.requestTimestampFormatter.string(from: windowStart), + endTime: Self.requestTimestampFormatter.string(from: now), + timezone: "Etc/GMT"), + timeUnit: "TIME_UNIT_DAY", + values: [.init(name: "usd", aggregation: "AGGREGATION_SUM")], + groupBy: [], + filters: [])) + } + + private static func utcDay(fromTimestamp timestamp: String) throws -> String { + guard let date = iso8601Fractional.date(from: timestamp) + ?? iso8601.date(from: timestamp) + else { + throw XAIBillingError.parseFailed("usage timestamp is not ISO 8601: \(timestamp)") + } + return Self.dayFormatter.string(from: date) + } + + // MARK: - Shared + + private static func teamURL(teamID: String, suffix: [String]) -> URL { + var url = self.baseURL + .appendingPathComponent("v1") + .appendingPathComponent("billing") + .appendingPathComponent("teams") + .appendingPathComponent(teamID) + for component in suffix { + url = url.appendingPathComponent(component) + } + return url + } + + private static func error(statusCode: Int) -> XAIBillingError { + switch statusCode { + case 401, 403: + .authenticationRejected + case 404: + .teamNotFound + case 429: + .rateLimited + default: + .apiError(statusCode) + } + } + + private static var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + } + + private static func utcFormatter(dateFormat: String) -> DateFormatter { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC")! + formatter.dateFormat = dateFormat + return formatter + } + + private static var requestTimestampFormatter: DateFormatter { + self.utcFormatter(dateFormat: "yyyy-MM-dd HH:mm:ss") + } + + private static var dayFormatter: DateFormatter { + self.utcFormatter(dateFormat: "yyyy-MM-dd") + } + + private static var iso8601Fractional: ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + } + + private static var iso8601: ISO8601DateFormatter { + ISO8601DateFormatter() + } +} + +// MARK: - Wire types + +private struct BalanceEnvelope: Decodable { + struct Amount: Decodable { + /// Required: a 200 error envelope must not decode into a $0.00 balance. + let val: String + } + + let total: Amount +} + +private struct UsageRequestEnvelope: Encodable { + struct AnalyticsRequest: Encodable { + struct TimeRange: Encodable { + let startTime: String + let endTime: String + let timezone: String + } + + struct Value: Encodable { + let name: String + let aggregation: String + } + + let timeRange: TimeRange + let timeUnit: String + let values: [Value] + let groupBy: [String] + let filters: [String] + } + + let analyticsRequest: AnalyticsRequest +} + +private struct UsageEnvelope: Decodable { + struct Series: Decodable { + struct DataPoint: Decodable { + let timestamp: String + let values: [Double] + } + + let dataPoints: [DataPoint] + } + + let timeSeries: [Series] + let limitReached: Bool? +} diff --git a/Sources/CodexBarCore/Providers/XAI/XAIProviderDescriptor.swift b/Sources/CodexBarCore/Providers/XAI/XAIProviderDescriptor.swift new file mode 100644 index 000000000..62cedcdf0 --- /dev/null +++ b/Sources/CodexBarCore/Providers/XAI/XAIProviderDescriptor.swift @@ -0,0 +1,69 @@ +import Foundation + +public enum XAIProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .xai, + metadata: ProviderMetadata( + id: .xai, + displayName: "xAI", + sessionLabel: "Spend", + weeklyLabel: "Spend", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show xAI usage", + cliName: "xai", + defaultEnabled: false, + dashboardURL: "https://console.x.ai", + statusPageURL: nil, + statusLinkURL: "https://status.x.ai"), + branding: ProviderBranding( + iconStyle: .xai, + iconResourceName: "ProviderIcon-xai", + color: ProviderColor(red: 142 / 255, green: 142 / 255, blue: 160 / 255), + confettiPalette: [ + ProviderColor(hex: 0x1A1A1A), + ProviderColor(hex: 0x8E8E93), + ProviderColor(hex: 0xF5F5F7), + ]), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "xAI spend history comes from the Management API billing endpoints." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [XAIAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "xai", + versionDetector: nil)) + } +} + +struct XAIAPIFetchStrategy: ProviderFetchStrategy { + let id = "xai.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + XAISettingsReader.apiKey(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let key = XAISettingsReader.apiKey(environment: context.env) else { + throw XAIBillingError.notConfigured + } + guard let teamID = XAISettingsReader.teamID(environment: context.env) else { + throw XAIBillingError.missingTeamID + } + let usage = try await XAIBillingFetcher.fetchUsage(managementKey: key, teamID: teamID) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Sources/CodexBarCore/Providers/XAI/XAISettingsReader.swift b/Sources/CodexBarCore/Providers/XAI/XAISettingsReader.swift new file mode 100644 index 000000000..a5c42587f --- /dev/null +++ b/Sources/CodexBarCore/Providers/XAI/XAISettingsReader.swift @@ -0,0 +1,31 @@ +import Foundation + +public enum XAISettingsReader { + public static let apiKeyEnvironmentKey = "XAI_MANAGEMENT_API_KEY" + public static let teamIDEnvironmentKey = "XAI_TEAM_ID" + + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.apiKeyEnvironmentKey]) + } + + public static func teamID( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.teamIDEnvironmentKey]) + } + + static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value = String(value.dropFirst().dropLast()) + } + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/XAI/XAIUsageSnapshot.swift b/Sources/CodexBarCore/Providers/XAI/XAIUsageSnapshot.swift new file mode 100644 index 000000000..22ebe0bb3 --- /dev/null +++ b/Sources/CodexBarCore/Providers/XAI/XAIUsageSnapshot.swift @@ -0,0 +1,110 @@ +import Foundation + +/// Prepaid balance plus daily USD spend from the xAI Management API billing +/// endpoints. Mirrors the Groq/OpenAI daily cost-history shape so it renders +/// through the shared cost-history inline dashboard. +public struct XAIUsageSnapshot: Codable, Equatable, Sendable { + public struct DailyBucket: Codable, Equatable, Sendable, Identifiable { + /// UTC day in `yyyy-MM-dd` (usage is requested in `Etc/GMT`); the inline + /// dashboard derives its axis labels from this exact format. + public let day: String + public let costUSD: Double + + public var id: String { + self.day + } + + public init(day: String, costUSD: Double) { + self.day = day + self.costUSD = costUSD + } + } + + /// Remaining prepaid credit in dollars. The balance endpoint reports an + /// inverted ledger in string USD cents (a $10 top-up is "-1000"), so this + /// is `-cents / 100`; a negative value means the team is in deficit. + public let balanceUSD: Double + public let daily: [DailyBucket] + public let historyDays: Int + /// True when the usage endpoint reported its cardinality cap; daily sums + /// may then be incomplete and must not be presented as exact. + public let limitReached: Bool + public let updatedAt: Date + + public init( + balanceUSD: Double, + daily: [DailyBucket], + historyDays: Int = 30, + limitReached: Bool = false, + updatedAt: Date) + { + self.balanceUSD = balanceUSD + self.daily = daily.sorted { $0.day < $1.day } + self.historyDays = max(1, min(365, historyDays)) + self.limitReached = limitReached + self.updatedAt = updatedAt + } + + public var historyWindowPeriodLabel: String { + let base = self.historyDays == 1 ? "Today" : "Last \(self.historyDays) days" + return self.limitReached ? "\(base) (partial)" : base + } + + public var windowCostUSD: Double { + self.daily.reduce(0) { $0 + $1.costUSD } + } + + public func toUsageSnapshot() -> UsageSnapshot { + UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: self.balanceUSD, + limit: 0, + currencyCode: "USD", + period: "Prepaid credits", + updatedAt: self.updatedAt), + xaiUsage: self, + updatedAt: self.updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .xai, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Management API"), + dataConfidence: self.limitReached ? .estimated : .exact) + } + + /// Nil when no history came back: the inline dashboard should fall through + /// instead of charting an empty series as if the team genuinely spent $0. + public func costHistorySnapshot() -> CostUsageTokenSnapshot? { + guard !self.daily.isEmpty else { return nil } + let entries = self.daily.map { bucket in + CostUsageDailyReport.Entry( + date: bucket.day, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + costUSD: bucket.costUSD, + modelsUsed: nil, + modelBreakdowns: nil) + } + let today = self.daily.first { $0.day == Self.utcDayString(from: self.updatedAt) } + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: today?.costUSD ?? 0, + last30DaysTokens: nil, + last30DaysCostUSD: self.windowCostUSD, + historyDays: self.historyDays, + historyLabel: self.limitReached ? self.historyWindowPeriodLabel : nil, + daily: entries, + updatedAt: self.updatedAt) + } + + private static func utcDayString(from date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC")! + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } +} diff --git a/Sources/CodexBarCore/Sync/AccountIdentityComputer.swift b/Sources/CodexBarCore/Sync/AccountIdentityComputer.swift index 6ae6d26e4..32d398f83 100644 --- a/Sources/CodexBarCore/Sync/AccountIdentityComputer.swift +++ b/Sources/CodexBarCore/Sync/AccountIdentityComputer.swift @@ -78,7 +78,7 @@ public enum AccountIdentityComputer { // user files a cross-Mac merging request for them. .azureopenai, .alibabatokenplan, .t3chat, // Upstream 0.33+ new providers. Same rationale as above. - .devin, .zed, .sakana, .poe, .chutes, .qoder, .clawrouter, .wayfinder, .sub2api, + .devin, .zed, .sakana, .poe, .chutes, .qoder, .clawrouter, .wayfinder, .sub2api, .xai, .zenmux, .clinepass, .longcat, .neuralwatt, .deepinfra, .aiand, .qwencloud, .zoommate: // Non-Tier-A providers: no stable account model required by // iOS today. Return nil → iOS falls back to per-device legacy diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 9ca839741..a9f931e02 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -143,47 +143,6 @@ public struct NamedRateWindow: Codable, Equatable, Sendable { } } -public struct ProviderIdentitySnapshot: Codable, Sendable { - public let providerID: UsageProvider? - public let accountEmail: String? - public let accountOrganization: String? - public let loginMethod: String? - public let accountID: String? - - public init( - providerID: UsageProvider?, - accountEmail: String?, - accountOrganization: String?, - loginMethod: String?, - accountID: String? = nil) - { - self.providerID = providerID - self.accountEmail = accountEmail - self.accountOrganization = accountOrganization - self.loginMethod = loginMethod - self.accountID = accountID - } - - public func scoped(to provider: UsageProvider) -> ProviderIdentitySnapshot { - if self.providerID == provider { - return self - } - return ProviderIdentitySnapshot( - providerID: provider, - accountEmail: self.accountEmail, - accountOrganization: self.accountOrganization, - loginMethod: self.loginMethod, - accountID: self.accountID) - } -} - -public enum UsageDataConfidence: String, Codable, Equatable, Sendable { - case exact - case estimated - case percentOnly - case unknown -} - public struct UsageSnapshot: Codable, Sendable { public let primary: RateWindow? public let secondary: RateWindow? @@ -223,6 +182,7 @@ public struct UsageSnapshot: Codable, Sendable { public let groqUsage: GroqUsageSnapshot? public let llmProxyUsage: LLMProxyUsageSnapshot? public let poeUsage: PoeUsageHistorySnapshot? + public let xaiUsage: XAIUsageSnapshot? public let cursorRequests: CursorRequestUsage? public let cursorRateWindowLayout: CursorRateWindowLayout? /// iOS 1.9.0 / Mac 0.29.0 — gap E. Transient (fetched fresh, not persisted), @@ -269,6 +229,7 @@ public struct UsageSnapshot: Codable, Sendable { case llmProxyUsage case poeUsage case cursorRateWindowLayout + case xaiUsage case subscriptionExpiresAt case subscriptionRenewsAt case updatedAt @@ -313,6 +274,7 @@ public struct UsageSnapshot: Codable, Sendable { groqUsage: GroqUsageSnapshot? = nil, llmProxyUsage: LLMProxyUsageSnapshot? = nil, poeUsage: PoeUsageHistorySnapshot? = nil, + xaiUsage: XAIUsageSnapshot? = nil, cursorRequests: CursorRequestUsage? = nil, cursorRateWindowLayout: CursorRateWindowLayout? = nil, azureOpenAIUsage: AzureOpenAIUsageSnapshot? = nil, @@ -359,6 +321,7 @@ public struct UsageSnapshot: Codable, Sendable { self.groqUsage = groqUsage self.llmProxyUsage = llmProxyUsage self.poeUsage = poeUsage + self.xaiUsage = xaiUsage self.cursorRequests = cursorRequests self.cursorRateWindowLayout = cursorRateWindowLayout self.azureOpenAIUsage = azureOpenAIUsage @@ -435,6 +398,7 @@ public struct UsageSnapshot: Codable, Sendable { self.groqUsage = try container.decodeIfPresent(GroqUsageSnapshot.self, forKey: .groqUsage) self.llmProxyUsage = try container.decodeIfPresent(LLMProxyUsageSnapshot.self, forKey: .llmProxyUsage) self.poeUsage = try container.decodeIfPresent(PoeUsageHistorySnapshot.self, forKey: .poeUsage) + self.xaiUsage = try container.decodeIfPresent(XAIUsageSnapshot.self, forKey: .xaiUsage) self.cursorRequests = nil // Not persisted, fetched fresh each time if let rawCursorLayout = try container.decodeIfPresent(String.self, forKey: .cursorRateWindowLayout) { self.cursorRateWindowLayout = CursorRateWindowLayout(rawValue: rawCursorLayout) @@ -501,6 +465,7 @@ public struct UsageSnapshot: Codable, Sendable { try container.encodeIfPresent(self.llmProxyUsage, forKey: .llmProxyUsage) try container.encodeIfPresent(self.poeUsage, forKey: .poeUsage) try container.encodeIfPresent(self.cursorRateWindowLayout?.rawValue, forKey: .cursorRateWindowLayout) + try container.encodeIfPresent(self.xaiUsage, forKey: .xaiUsage) try container.encodeIfPresent(self.subscriptionExpiresAt, forKey: .subscriptionExpiresAt) try container.encodeIfPresent(self.subscriptionRenewsAt, forKey: .subscriptionRenewsAt) try container.encode(self.updatedAt, forKey: .updatedAt) @@ -685,6 +650,7 @@ public struct UsageSnapshot: Codable, Sendable { groqUsage: self.groqUsage, llmProxyUsage: self.llmProxyUsage, poeUsage: self.poeUsage, + xaiUsage: self.xaiUsage, cursorRequests: self.cursorRequests, cursorRateWindowLayout: self.cursorRateWindowLayout, azureOpenAIUsage: self.azureOpenAIUsage, diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index c8674fab5..f81fe6297 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -224,6 +224,83 @@ public enum UsageFormatter { return String(format: "%.2f", value) } + /// Formats a USD value into a target currency code with exchange rate conversion applied. + public static func convertedCostString(_ usdValue: Double, targetCurrency: String) -> String { + let converted = Self.convertedCost( + usdValue, + preferredCurrency: targetCurrency, + providerCurrency: "USD") + return self.currencyString(converted.value, currencyCode: converted.currencyCode) + } + + /// Formats a value from one currency into another via USD pivot conversion. + /// Useful when displaying provider costs that are denominated in non-USD currencies + /// (e.g., Anthropic extra usage returned in GBP) under the user's preferred currency. + public static func convertedCostString( + _ value: Double, + fromCurrency: String, + targetCurrency: String) -> String + { + guard let converted = CurrencyExchange.shared.convert( + amount: value, + from: fromCurrency, + to: targetCurrency) + else { + return self.currencyString(value, currencyCode: fromCurrency) + } + return self.currencyString(converted, currencyCode: targetCurrency) + } + + /// Resolves the effective currency code for cost display given user preference + /// and an optional provider currency. Returns the provider currency when preference + /// is "auto", otherwise returns the explicit preference. + public static func effectiveCurrencyCode( + preferred: String, + providerCurrency: String?) -> String + { + guard preferred != "auto", !preferred.isEmpty else { + return providerCurrency ?? "USD" + } + return preferred + } + + /// Formats a cost value with smart currency conversion. + /// - When `preferredCurrency` is "auto", renders in `providerCurrency` (or USD fallback) without conversion. + /// - When `preferredCurrency` is an explicit code, converts from `providerCurrency` to the target. + public static func convertedCostString( + _ value: Double, + preferredCurrency: String, + providerCurrency: String?) -> String + { + let converted = Self.convertedCost( + value, + preferredCurrency: preferredCurrency, + providerCurrency: providerCurrency) + return Self.currencyString(converted.value, currencyCode: converted.currencyCode) + } + + /// Resolves and converts a numeric cost while preserving its source currency + /// when the requested exchange rate is unavailable. + public static func convertedCost( + _ value: Double, + preferredCurrency: String, + providerCurrency: String?) -> (value: Double, currencyCode: String) + { + let sourceCurrency = providerCurrency ?? "USD" + let targetCurrency = Self.effectiveCurrencyCode( + preferred: preferredCurrency, + providerCurrency: providerCurrency) + guard targetCurrency != sourceCurrency, + let converted = CurrencyExchange.shared.convert( + amount: value, + from: sourceCurrency, + to: targetCurrency) + else { + return (value, sourceCurrency) + } + return (converted, targetCurrency) + } + /// Formats a USD value with proper negative handling and thousand separators. /// Uses Swift's modern FormatStyle API (iOS 15+/macOS 12+) for robust, locale-aware formatting. public static func usdString(_ value: Double) -> String { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index b438912db..d45ac51a0 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -1045,7 +1045,7 @@ enum CostUsageScanner { .ollama, .t3chat, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .sakana, .abacus, .mistral, .deepseek, .deepinfra, .codebuff, .crof, .windsurf, .zed, .venice, .commandcode, .qoder, .stepfun, .bedrock, .grok, .groq, .llmproxy, .litellm, .deepgram, .poe, .chutes, .neuralwatt, - .clawrouter, .longcat, .sub2api, .wayfinder, .zenmux, .aiand, .zoommate: + .clawrouter, .longcat, .sub2api, .wayfinder, .zenmux, .aiand, .zoommate, .xai: return emptyReport } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 325c3f6da..86acf1cae 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -134,6 +134,7 @@ enum ProviderChoice: String, AppEnum { case .neuralwatt: return nil // Neuralwatt not yet supported in widgets case .zenmux: return nil // ZenMux not yet supported in widgets case .aiand: return nil // ai& not yet supported in widgets + case .xai: return nil // xAI not yet supported in widgets } } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index cd4f188a6..43f4b63d9 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -364,6 +364,7 @@ private struct ProviderSwitchChip: View { case .neuralwatt: "Neuralwatt" case .zenmux: "ZenMux" case .aiand: "ai&" + case .xai: "xAI" } } } @@ -1124,6 +1125,8 @@ enum WidgetColors { Color(red: 108 / 255, green: 92 / 255, blue: 231 / 255) case .aiand: Color(red: 226 / 255, green: 92 / 255, blue: 43 / 255) + case .xai: + Color(red: 142 / 255, green: 142 / 255, blue: 147 / 255) } } } diff --git a/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift b/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift index 3248b42d5..b8aa3e5d3 100644 --- a/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift +++ b/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift @@ -136,6 +136,51 @@ struct CLIDiagnoseCommandTests { #expect(complete.modes == ["api"]) } + @Test + func `generic diagnose auth summary requires complete xai management context`() { + let keyOnlyEnvironment = CodexBarCLI._diagnosticAuthSummaryForTesting( + provider: .xai, + account: nil, + config: nil, + environment: [XAISettingsReader.apiKeyEnvironmentKey: "xai-fixture"], + settings: nil) + #expect(!keyOnlyEnvironment.configured) + #expect(keyOnlyEnvironment.modes.isEmpty) + + let completeEnvironment = CodexBarCLI._diagnosticAuthSummaryForTesting( + provider: .xai, + account: nil, + config: nil, + environment: [ + XAISettingsReader.apiKeyEnvironmentKey: "xai-fixture", + XAISettingsReader.teamIDEnvironmentKey: "team-fixture", + ], + settings: nil) + #expect(completeEnvironment.configured) + #expect(completeEnvironment.modes == ["api"]) + + let keyOnlyConfig = CodexBarCLI._diagnosticAuthSummaryForTesting( + provider: .xai, + account: nil, + config: ProviderConfig(id: .xai, apiKey: "xai-fixture"), + environment: [:], + settings: nil) + #expect(!keyOnlyConfig.configured) + #expect(keyOnlyConfig.modes.isEmpty) + + let completeConfig = CodexBarCLI._diagnosticAuthSummaryForTesting( + provider: .xai, + account: nil, + config: ProviderConfig( + id: .xai, + apiKey: "xai-fixture", + workspaceID: "team-fixture"), + environment: [:], + settings: nil) + #expect(completeConfig.configured) + #expect(completeConfig.modes == ["api"]) + } + @Test func `generic diagnose auth summary does not assume ambient credentials`() { let summary = CodexBarCLI._diagnosticAuthSummaryForTesting( diff --git a/Tests/CodexBarTests/CLISnapshotTests.swift b/Tests/CodexBarTests/CLISnapshotTests.swift index 123748fd8..6e2137ba5 100644 --- a/Tests/CodexBarTests/CLISnapshotTests.swift +++ b/Tests/CodexBarTests/CLISnapshotTests.swift @@ -349,6 +349,28 @@ struct CLISnapshotTests { @Test func `renders crof dollar balance as detail not reset`() { let meta = ProviderDescriptorRegistry.descriptor(for: .crof).metadata + let snap = CrofUsageSnapshot( + credits: 9.9999, + updatedAt: Date(timeIntervalSince1970: 0)).toUsageSnapshot() + + let output = CLIRenderer.renderText( + provider: .crof, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Crof", + status: nil, + useColor: false, + resetStyle: .countdown)) + + #expect(output.contains("\(meta.sessionLabel): 100% left")) + #expect(output.contains("$9.99")) + #expect(!output.contains("Resets $9.99")) + #expect(!output.contains("requests left")) + } + + @Test + func `renders crof request quota when returned`() { let snap = CrofUsageSnapshot( credits: 9.9999, requestsPlan: 1000, @@ -365,10 +387,10 @@ struct CLISnapshotTests { useColor: false, resetStyle: .countdown)) - #expect(output.contains("\(meta.sessionLabel): 99% left")) - #expect(output.contains("\(meta.weeklyLabel): 100% left")) + #expect(output.contains("Requests: 99% left")) + #expect(output.contains("998 requests left")) + #expect(output.contains("Credits: 100% left")) #expect(output.contains("$9.99")) - #expect(!output.contains("Resets $9.99")) } @Test diff --git a/Tests/CodexBarTests/ConfigValidationTests.swift b/Tests/CodexBarTests/ConfigValidationTests.swift index 893c4f398..8bfe96ee1 100644 --- a/Tests/CodexBarTests/ConfigValidationTests.swift +++ b/Tests/CodexBarTests/ConfigValidationTests.swift @@ -320,6 +320,35 @@ struct ConfigValidationTests { #expect(!issues.contains(where: { $0.provider == .openai && $0.code == "workspace_unused" })) } + @Test + func `xai management API requires key and team ID together`() { + let incompleteConfigs = [ + ProviderConfig(id: .xai, source: .api, apiKey: "xai-fixture"), + ProviderConfig(id: .xai, source: .api, workspaceID: "team-fixture"), + ] + + for providerConfig in incompleteConfigs { + var config = CodexBarConfig.makeDefault() + config.setProviderConfig(providerConfig) + let issue = CodexBarConfigValidator.validate(config).first(where: { + $0.provider == .xai && $0.code == "xai_management_context_missing" + }) + + #expect(issue != nil) + } + + var completeConfig = CodexBarConfig.makeDefault() + completeConfig.setProviderConfig(ProviderConfig( + id: .xai, + source: .api, + apiKey: "xai-fixture", + workspaceID: "team-fixture")) + let completeIssues = CodexBarConfigValidator.validate(completeConfig) + #expect(!completeIssues.contains(where: { + $0.provider == .xai && $0.code == "xai_management_context_missing" + })) + } + @Test func `allows doubao coding plan credential fields`() { var config = CodexBarConfig.makeDefault() diff --git a/Tests/CodexBarTests/CrofMenuCardTests.swift b/Tests/CodexBarTests/CrofMenuCardTests.swift index ade932a3a..4e48ddeb5 100644 --- a/Tests/CodexBarTests/CrofMenuCardTests.swift +++ b/Tests/CodexBarTests/CrofMenuCardTests.swift @@ -5,7 +5,43 @@ import Testing struct CrofMenuCardTests { @Test - func `model shows request count and avoids duplicate credits section`() throws { + func `model shows credit balance without request quota`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.crof]) + let snapshot = CrofUsageSnapshot( + credits: 10, + updatedAt: now).toUsageSnapshot() + + let model = UsageMenuCardView.Model.make(.init( + provider: .crof, + metadata: metadata, + snapshot: snapshot, + 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.creditsText == nil) + #expect(model.metrics.map(\.title) == ["Credits"]) + #expect(model.metrics.first?.percent == 100) + #expect(model.metrics.first?.resetText == nil) + #expect(model.metrics.first?.statusText == "$10.00") + #expect(model.metrics.first?.detailRightText == nil) + } + + @Test + func `model keeps request quota rows when the API returns them`() throws { let now = Date() let metadata = try #require(ProviderDefaults.metadata[.crof]) let snapshot = CrofUsageSnapshot( diff --git a/Tests/CodexBarTests/CrofUsageFetcherTests.swift b/Tests/CodexBarTests/CrofUsageFetcherTests.swift index 02544ba9d..79a66f35b 100644 --- a/Tests/CodexBarTests/CrofUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CrofUsageFetcherTests.swift @@ -10,7 +10,32 @@ struct CrofUsageFetcherTests { } @Test - func `usage response parses credits and request quota`() throws { + func `usage response parses credits with null request quota fields`() throws { + let json = """ + { + "credits":9.0441, + "requests_plan":null, + "usable_requests":null, + "usage":{ + "deepseek-v4-flash":{ + "cached_tokens":0, + "input_tokens":23, + "output_tokens":132, + "total_tokens":155 + } + } + } + """ + + let snapshot = try CrofUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + + #expect(snapshot.credits == 9.0441) + #expect(snapshot.requestsPlan == nil) + #expect(snapshot.usableRequests == nil) + } + + @Test + func `usage response preserves request quota fields when present`() throws { let json = """ {"credits":10.0,"requests_plan":1000,"usable_requests":998} """ @@ -23,76 +48,65 @@ struct CrofUsageFetcherTests { } @Test - func `usage snapshot maps usable requests to remaining quota`() { + func `usage response rejects half populated request quota fields`() { + for json in [ + #"{"credits":10.0,"requests_plan":1000,"usable_requests":null}"#, + #"{"credits":10.0,"requests_plan":null,"usable_requests":998}"#, + ] { + #expect(throws: CrofUsageError.self) { + _ = try CrofUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + } + } + } + + @Test + func `usage snapshot maps credit balance as primary window`() { let snapshot = CrofUsageSnapshot( credits: 10, - requestsPlan: 1000, - usableRequests: 998, updatedAt: Date(timeIntervalSince1970: 1_777_800_000)) let usage = snapshot.toUsageSnapshot() - #expect(usage.primary?.usedPercent == 1) - #expect(usage.primary?.windowMinutes == 1440) - #expect(usage.primary?.resetDescription == "998 requests left") - #expect(usage.secondary?.usedPercent == 0) - #expect(usage.secondary?.resetDescription == "$10.00") + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.windowMinutes == nil) + #expect(usage.primary?.resetsAt == nil) + #expect(usage.primary?.resetDescription == "$10.00") + #expect(usage.secondary == nil) #expect(usage.identity?.providerID == .crof) #expect(usage.identity?.loginMethod == "API key") } @Test - func `usage snapshot floors credit balance to cents`() { - let snapshot = CrofUsageSnapshot( - credits: 9.9999, - requestsPlan: 1000, - usableRequests: 998) - - #expect(snapshot.toUsageSnapshot().secondary?.resetDescription == "$9.99") - } - - @Test - func `usage snapshot resets requests at next America Chicago midnight`() throws { - var utc = Calendar(identifier: .gregorian) - utc.timeZone = try #require(TimeZone(secondsFromGMT: 0)) - let updatedAt = try #require(utc.date(from: DateComponents( - year: 2026, - month: 5, - day: 8, - hour: 18, - minute: 30))) - let expectedReset = try #require(utc.date(from: DateComponents( - year: 2026, - month: 5, - day: 9, - hour: 5))) + func `usage snapshot prefers request quota when present`() { let snapshot = CrofUsageSnapshot( credits: 10, requestsPlan: 1000, usableRequests: 998, - updatedAt: updatedAt) + updatedAt: Date(timeIntervalSince1970: 1_777_800_000)) + + let usage = snapshot.toUsageSnapshot() - #expect(snapshot.toUsageSnapshot().primary?.resetsAt == expectedReset) + #expect(usage.primary?.usedPercent == 1) + #expect(usage.primary?.windowMinutes == 1440) + #expect(usage.primary?.resetsAt != nil) + #expect(usage.primary?.resetDescription == "998 requests left") + #expect(usage.secondary?.usedPercent == 0) + #expect(usage.secondary?.resetDescription == "$10.00") } @Test - func `usage snapshot clamps overreported usable requests`() { - let snapshot = CrofUsageSnapshot( - credits: 0, - requestsPlan: 1000, - usableRequests: 1200) + func `usage snapshot floors credit balance to cents`() { + let snapshot = CrofUsageSnapshot(credits: 9.9999) - #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 0) + #expect(snapshot.toUsageSnapshot().primary?.resetDescription == "$9.99") } @Test - func `usage snapshot treats zero plan as exhausted`() { - let snapshot = CrofUsageSnapshot( - credits: 0, - requestsPlan: 0, - usableRequests: 0) + func `usage snapshot treats zero credits as exhausted`() { + let snapshot = CrofUsageSnapshot(credits: 0) #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 100) + #expect(snapshot.toUsageSnapshot().primary?.resetDescription == "$0.00") } @Test @@ -108,12 +122,14 @@ struct CrofUsageFetcherTests { #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") return try Self.makeResponse( url: url, - body: #"{"credits":10.0,"requests_plan":1000,"usable_requests":998}"#) + body: #"{"credits":9.0441,"requests_plan":null,"usable_requests":null,"usage":{}}"#) } let snapshot = try await CrofUsageFetcher.fetchUsage(apiKey: "crof-test", session: Self.makeSession()) - #expect(snapshot.usableRequests == 998) + #expect(snapshot.credits == 9.0441) + #expect(snapshot.requestsPlan == nil) + #expect(snapshot.usableRequests == nil) #expect(CrofStubURLProtocol.requests.map(\.url?.absoluteString) == ["https://crof.ai/usage_api/"]) } @@ -122,6 +138,7 @@ struct CrofUsageFetcherTests { let descriptor = ProviderDescriptorRegistry.descriptor(for: .crof) #expect(descriptor.metadata.displayName == "Crof") #expect(descriptor.metadata.dashboardURL == "https://crof.ai/dashboard") + #expect(descriptor.metadata.sessionLabel == "Credits") #expect(descriptor.fetchPlan.sourceModes == [.auto, .api]) #expect(descriptor.branding.iconResourceName == "ProviderIcon-crof") } diff --git a/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift index 1f4e85381..f023ad440 100644 --- a/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift +++ b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift @@ -53,6 +53,63 @@ struct InlineCostHistoryDashboardLabelTests { #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-15: $0.25") } + @Test + func `local cost history converts from snapshot currency into preferred currency`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 100, + sessionCostUSD: 10, + last30DaysTokens: 100, + last30DaysCostUSD: 10, + currencyCode: "EUR", + daily: [ + CostUsageDailyReport.Entry( + date: "2023-11-15", + inputTokens: 75, + outputTokens: 25, + totalTokens: 100, + costUSD: 10, + 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, + preferredCurrencyCode: "USD", + now: now)) + + let expected = UsageFormatter.convertedCostString( + 10, + preferredCurrency: "USD", + providerCurrency: "EUR") + let expectedValue = UsageFormatter.convertedCost( + 10, + preferredCurrency: "USD", + providerCurrency: "EUR").value + #expect(model.inlineUsageDashboard?.currencyCode == "USD") + #expect(model.inlineUsageDashboard?.kpis.first?.value == expected) + #expect(model.inlineUsageDashboard?.points.first?.value == expectedValue) + #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-15: \(expected)") + } + @Test func `local cost history KPI titles preserve one day and dynamic windows`() throws { let now = Date(timeIntervalSince1970: 1_700_179_200) diff --git a/Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift b/Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift index dd869a0d2..eec9a75fb 100644 --- a/Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift +++ b/Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift @@ -9,7 +9,8 @@ import Testing struct MenuCardClaudeSwapAccountTests { private func makeModel( hidePersonalInfo: Bool, - planOverride: String? = nil) throws -> UsageMenuCardView.Model + planOverride: String? = nil, + additionalRateWindows: [NamedRateWindow] = []) throws -> UsageMenuCardView.Model { let now = Date(timeIntervalSince1970: 1_782_000_000) let metadata = try #require(ProviderDefaults.metadata[.claude]) @@ -31,11 +32,14 @@ struct MenuCardClaudeSwapAccountTests { ]), ]) let account = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: now).first) + let baseSnapshot = try #require(account.snapshot) + let snapshot = baseSnapshot.with( + extraRateWindows: (baseSnapshot.extraRateWindows ?? []) + additionalRateWindows) return UsageMenuCardView.Model.make(.init( provider: .claude, metadata: metadata, - snapshot: account.snapshot, + snapshot: snapshot, credits: nil, creditsError: nil, dashboard: nil, @@ -73,6 +77,30 @@ struct MenuCardClaudeSwapAccountTests { let scoped = try #require(model.metrics.first(where: { $0.id == "claude-weekly-scoped-fable" })) #expect(scoped.title == "Fable only") #expect(scoped.percent == 80) + #expect(scoped.detailLeftText == "6% in reserve") + #expect(scoped.pacePercent != nil) + } + + @Test + func `claude nonweekly extra window does not render pace detail`() throws { + let now = Date(timeIntervalSince1970: 1_782_000_000) + let model = try self.makeModel( + hidePersonalInfo: false, + additionalRateWindows: [ + NamedRateWindow( + id: "claude-nonweekly-extra", + title: "Nonweekly extra", + window: RateWindow( + usedPercent: 80, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil)), + ]) + + let extra = try #require(model.metrics.first(where: { $0.id == "claude-nonweekly-extra" })) + #expect(extra.detailLeftText == nil) + #expect(extra.detailRightText == nil) + #expect(extra.pacePercent == nil) } @Test diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index c93c67cab..064e4c33a 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -44,6 +44,7 @@ struct ProviderIconResourcesTests { "zenmux", "aiand", "zoommate", + "xai", ] for slug in slugs { let url = resources.appending(path: "ProviderIcon-\(slug).svg") @@ -66,6 +67,16 @@ struct ProviderIconResourcesTests { #expect(groq != grok) } + @Test + func `grok and xai provider icons are distinct`() throws { + let root = try Self.repoRoot() + let resources = root.appending(path: "Sources/CodexBar/Resources", directoryHint: .isDirectory) + let grok = try String(contentsOf: resources.appending(path: "ProviderIcon-grok.svg"), encoding: .utf8) + let xai = try String(contentsOf: resources.appending(path: "ProviderIcon-xai.svg"), encoding: .utf8) + + #expect(grok != xai) + } + @Test func `provider brand icons are cached after first load`() throws { ProviderBrandIcon.resetCacheForTesting() diff --git a/Tests/CodexBarTests/QuotaProviderListTests.swift b/Tests/CodexBarTests/QuotaProviderListTests.swift index 014f0e5ea..ef14755ff 100644 --- a/Tests/CodexBarTests/QuotaProviderListTests.swift +++ b/Tests/CodexBarTests/QuotaProviderListTests.swift @@ -15,7 +15,7 @@ import Testing @Suite("QuotaProviderList contract") struct QuotaProviderListTests { @Test - func `Provider list has expected count (58 after Qwen Cloud and ZoomMate catch-up)`() { + func `Provider list has expected count (59 after xAI catch-up)`() { // 25 base → 27 in iOS 1.5.0 (Abacus + Mistral) → 38 in iOS 1.6.0 // (11 new from Mac v0.24+v0.25) → 40 in iOS 1.7.0 (Moonshot + // AWS Bedrock from upstream v0.26.0) → 45 in iOS 1.8.0 (Grok, @@ -25,10 +25,11 @@ struct QuotaProviderListTests { // (Sakana AI from upstream v0.36.x) → 50 after Qoder from the // same upstream line → 51 after Sub2API → 52 after ZenMux → // 54 after ClinePass and LongCat → 55 after Neuralwatt → 56 after - // DeepInfra, then 58 after Qwen Cloud and ZoomMate. Must stay synced with the iOS-side test in + // DeepInfra, then 58 after Qwen Cloud and ZoomMate, and 59 after xAI. + // Must stay synced with the iOS-side test in // CodexBarMobileTests/QuotaProviderListTests.swift. ai& is spend-only, // so it intentionally has no quota-transition subscriptions. - #expect(QuotaProviderList.providers.count == 58) + #expect(QuotaProviderList.providers.count == 59) } @Test @@ -110,7 +111,7 @@ struct QuotaProviderListTests { } @Test - func `iOS subscription count is 58 × 3 = 174 (depleted + restored + warning)`() { + func `iOS subscription count is 59 × 3 = 177 (depleted + restored + warning)`() { // 54 → 76 in iOS 1.5.x → 114 in iOS 1.6.0 (38 × 3 after adding // the "warning" state for pre-depletion threshold pushes) → // 120 in iOS 1.7.0 (40 × 3 after the v0.26 catch-up) → @@ -128,7 +129,7 @@ struct QuotaProviderListTests { // `QuotaTransitionSubscriptions.makeConfigs()`. let states = ["depleted", "restored", "warning"] let subscriptionCount = QuotaProviderList.providers.count * states.count - #expect(subscriptionCount == 174) + #expect(subscriptionCount == 177) } // MARK: - iOS 1.7.0 / Mac 0.26.2 — v0.26.0 catch-up diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index 2740d199c..d8bfba207 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -851,6 +851,21 @@ struct SettingsStoreCoverageTests { #expect(reloaded4.weeklyProgressWorkDays == nil) } + @Test + func `preferred currency defaults to USD and persists an explicit selection`() throws { + let suite = "SettingsStoreCoverageTests-preferred-currency" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let fresh = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(fresh.preferredCurrencyCode == "auto") + + fresh.preferredCurrencyCode = "GBP" + let reloaded = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(reloaded.preferredCurrencyCode == "GBP") + } + private static func makeSettingsStore( suiteName: String = "SettingsStoreCoverageTests", antigravityOAuthCredentialsStore: AntigravityOAuthCredentialsStore = AntigravityOAuthCredentialsStore()) diff --git a/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift b/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift index 496b29739..ad67a3578 100644 --- a/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift +++ b/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift @@ -3,6 +3,7 @@ import Foundation import Testing @testable import CodexBar +// swiftlint:disable:next type_body_length struct SpendDashboardDateTruthTests { private struct MalformedMetricCase { let name: String @@ -41,6 +42,33 @@ struct SpendDashboardDateTruthTests { #expect(group.dailyPoints.map(\.cost) == [1, 2]) } + @Test + func `xAI UTC buckets map into Pacific dashboard days at midnight UTC`() throws { + var pacific = Calendar(identifier: .gregorian) + pacific.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let now = try #require(ISO8601DateFormatter().date(from: "2026-07-02T00:01:00Z")) + let june30 = try #require(pacific.date(from: DateComponents(year: 2026, month: 6, day: 30))) + let july1 = try #require(pacific.date(from: DateComponents(year: 2026, month: 7, day: 1))) + let snapshot = Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-07-01", cost: 1), + Self.entry(day: "2026-07-02", cost: 2), + ], + historyDays: 2, + updatedAt: now) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .xai, displayName: "xAI", snapshot: snapshot)], + requestedDays: 7, + now: now, + calendar: pacific).groups.first) + + #expect(group.totalCost == 3) + #expect(group.coveredDayCount == 2) + #expect(group.dailyPoints.map(\.day) == [june30, july1]) + #expect(group.dailyPoints.map(\.cost) == [1, 2]) + } + @Test func `Mistral coverage end preserves UTC bucket day after Pacific midnight`() throws { var pacific = Calendar(identifier: .gregorian) @@ -203,6 +231,29 @@ struct SpendDashboardDateTruthTests { #expect(usd.totalCost == 2) } + @Test + func `preferred currency combines convertible dashboard groups and preserves unavailable sources`() throws { + let eurRate = try #require(CurrencyExchange.shared.rate(for: "EUR")) + let model = SpendDashboardModel.build( + inputs: [ + Self.input(id: "usd", provider: .claude, currency: "USD", cost: 2), + Self.input(id: "eur", provider: .codex, currency: "EUR", cost: 3), + Self.input(id: "chf", provider: .mistral, currency: "CHF", cost: 5), + ], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar, + preferredCurrencyCode: "USD") + + #expect(model.groups.map(\.currencyCode) == ["CHF", "USD"]) + let chf = try #require(model.groups.first(where: { $0.currencyCode == "CHF" })) + let usd = try #require(model.groups.first(where: { $0.currencyCode == "USD" })) + #expect(chf.totalCost == 5) + #expect(usd.providers.map(\.id).sorted() == ["eur", "usd"]) + #expect(abs((usd.totalCost ?? 0) - (2 + 3 / eurRate)) < 1e-9) + #expect(abs((usd.dailyPoints.map(\.cost).reduce(0, +)) - (2 + 3 / eurRate)) < 1e-9) + } + @Test func `date with a valid prefix and trailing junk fails closed`() throws { let snapshot = Self.snapshot(currency: "USD", entries: [ diff --git a/Tests/CodexBarTests/StepFunUsageFetcherTests.swift b/Tests/CodexBarTests/StepFunUsageFetcherTests.swift index b70864458..888eb856b 100644 --- a/Tests/CodexBarTests/StepFunUsageFetcherTests.swift +++ b/Tests/CodexBarTests/StepFunUsageFetcherTests.swift @@ -308,6 +308,96 @@ struct StepFunUsageFetcherParsingTests { #expect(usage.primary?.usedPercent ?? -1 > 24.9 && usage.primary?.usedPercent ?? -1 < 25.1) } + @Test + func `live rolling windows win over a credit-family id`() throws { + // Robustness across the 2026-06-18 Coding Plan → Token Plan split: the + // grandfathered Coding Plan meters live 5h/weekly windows. A live window must + // route to the rolling-window renderer even if the same payload also reports + // plan_family=2, so a stale/changed family id can never send a windowed plan + // to the credit renderer (which would drop the real windows and show a bogus + // credit balance). + let json = """ + { + "status": 1, + "five_hour_usage_left_rate": 0.8, + "five_hour_usage_reset_time": "1746000000", + "weekly_usage_left_rate": 0.6, + "weekly_usage_reset_time": "1746500000", + "plan_family": 2, + "plan_credit_rate_limit": { "subscription_credit_left_rate": 1, "credit_buckets": [] } + } + """ + let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + + #expect(snapshot.isCreditPlan == false) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.secondary?.windowMinutes == 10080) + } + + @Test + func `falls back to the credit-family id only when the payload is otherwise ambiguous`() throws { + // A brand-new plan with neither a live window nor a credit pool yet leaves + // plan_family as the only signal: 2 == Token Plan (Credit family), else Coding. + let creditFamily = """ + {"status":1,"five_hour_usage_left_rate":0,"five_hour_usage_reset_time":"0",\ + "weekly_usage_left_rate":0,"weekly_usage_reset_time":"0","plan_family":2} + """ + let windowFamily = """ + {"status":1,"five_hour_usage_left_rate":0,"five_hour_usage_reset_time":"0",\ + "weekly_usage_left_rate":0,"weekly_usage_reset_time":"0","plan_family":1} + """ + #expect(try StepFunUsageFetcher._parseSnapshotForTesting(Data(creditFamily.utf8)).isCreditPlan == true) + #expect(try StepFunUsageFetcher._parseSnapshotForTesting(Data(windowFamily.utf8)).isCreditPlan == false) + } + + @Test + func `classifies exhausted zero-credit pool without a family id`() throws { + let json = """ + { + "status": 1, + "five_hour_usage_left_rate": 0, + "five_hour_usage_reset_time": "0", + "weekly_usage_left_rate": 0, + "weekly_usage_reset_time": "0", + "plan_credit_rate_limit": { + "subscription_credit_left_rate": 0, + "subscription_credit_reset_time": "1786288293" + } + } + """ + let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + + #expect(snapshot.isCreditPlan == true) + #expect(snapshot.creditLeftRate == 0) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 100) + #expect(usage.secondary == nil) + } + + @Test + func `classifies zero-credit pool when only top-up field is present`() throws { + let json = """ + { + "status": 1, + "five_hour_usage_left_rate": 0, + "five_hour_usage_reset_time": "0", + "weekly_usage_left_rate": 0, + "weekly_usage_reset_time": "0", + "plan_credit_rate_limit": { + "topup_credit_left_rate": 0 + } + } + """ + let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + + #expect(snapshot.isCreditPlan == true) + #expect(snapshot.creditLeftRate == 0) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 100) + #expect(usage.secondary == nil) + } + @Test func `weights mixed subscription and top-up credit buckets`() throws { let json = """ diff --git a/Tests/CodexBarTests/SyncCoordinatorTests.swift b/Tests/CodexBarTests/SyncCoordinatorTests.swift index 2507f03f6..14b841184 100644 --- a/Tests/CodexBarTests/SyncCoordinatorTests.swift +++ b/Tests/CodexBarTests/SyncCoordinatorTests.swift @@ -88,9 +88,71 @@ struct SyncCoordinatorTests { #expect(SyncCoordinator.syncBudgetSnapshot(provider: .zenmux, providerCost: balance) == nil) #expect(SyncCoordinator.syncBudgetSnapshot(provider: .neuralwatt, providerCost: balance) == nil) + #expect(SyncCoordinator.syncBudgetSnapshot(provider: .xai, providerCost: balance) == nil) #expect(SyncCoordinator.syncBudgetSnapshot(provider: .cursor, providerCost: balance) != nil) } + @Test + func `xAI cost history maps to existing sync summary with partial confidence`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let usage = XAIUsageSnapshot( + balanceUSD: 25, + daily: [ + .init(day: "2027-01-14", costUSD: 1.25), + .init(day: "2027-01-15", costUSD: 2.75), + ], + historyDays: 30, + limitReached: true, + updatedAt: now) + + let summary = try #require(SyncCoordinator.mapXAICostSummary( + provider: .xai, + snapshot: usage.toUsageSnapshot())) + + #expect(summary.sessionCostUSD == 2.75) + #expect(summary.last30DaysCostUSD == 4) + #expect(summary.historyDays == 30) + #expect(summary.currencyCode == "USD") + #expect(summary.isEstimated == true) + #expect(summary.daily.map(\.dayKey) == ["2027-01-14", "2027-01-15"]) + #expect(summary.daily.map(\.costUSD) == [1.25, 2.75]) + #expect(summary.daily.allSatisfy { $0.isEstimated == true }) + } + + @Test + func `xAI sync preserves balance and cost history without a false budget`() async throws { + let settings = self.makeSettingsStore(suite: "SyncCoord-xai") + settings.iCloudSyncEnabled = true + settings.xaiManagementAPIKey = "fixture-management-key" + settings.xaiTeamID = "fixture-team" + try settings.setProviderEnabled( + provider: .xai, + metadata: #require(ProviderDefaults.metadata[.xai]), + enabled: true) + + let store = self.makeUsageStore(settings: settings) + let usage = XAIUsageSnapshot( + balanceUSD: 25, + daily: [ + .init(day: "2027-01-14", costUSD: 1.25), + .init(day: "2027-01-15", costUSD: 2.75), + ], + limitReached: true, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000)) + store._setSnapshotForTesting(usage.toUsageSnapshot(), provider: .xai) + + let mock = MockSyncPusher() + let coordinator = SyncCoordinator(store: store, settings: settings, syncManager: mock) + await coordinator.pushCurrentSnapshot() + + let provider = try #require(mock.lastSnapshot?.providers + .first(where: { $0.providerID == UsageProvider.xai.rawValue })) + #expect(provider.statusMessage == "Prepaid credits: USD 25.00") + #expect(provider.budget == nil) + #expect(provider.costSummary?.last30DaysCostUSD == 4) + #expect(provider.costSummary?.isEstimated == true) + } + @Test func `Claude prepaid balance syncs without a false zero-limit budget`() throws { let now = Date(timeIntervalSince1970: 1_800_000_000) diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index c70e7969d..0c89fdc66 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -1,7 +1,7 @@ -import CodexBarCore import Foundation import Testing @testable import CodexBar +@testable import CodexBarCore @Suite(.serialized) struct UsageFormatterTests { @@ -469,6 +469,79 @@ struct UsageFormatterTests { #expect(UsageFormatter.byteCountStringLong(.min) == "-8589934592 [byte_unit_gigabytes]") } + @Test + func `currency exchange converts rates and formats correctly`() { + let exchange = CurrencyExchange.shared + let epsilon = 1e-9 + // USD → USD is identity + #expect(abs((exchange.convert(usdAmount: 10.0, to: "USD") ?? 0) - 10.0) < epsilon) + + // Cross-currency conversion via USD pivot + let gbpRate = exchange.rate(for: "GBP") ?? 0.79 + let eurRate = exchange.rate(for: "EUR") ?? 0.92 + #expect(abs((exchange.convert(usdAmount: 10.0, to: "GBP") ?? 0) - 10.0 * gbpRate) < epsilon) + #expect(abs((exchange.convert(usdAmount: 10.0, to: "EUR") ?? 0) - 10.0 * eurRate) < epsilon) + + // Cross-currency: GBP → EUR + let gbpToEur = exchange.convert(amount: 10.0, from: "GBP", to: "EUR") + let expectedGbpToEur = 10.0 / gbpRate * eurRate + #expect(abs((gbpToEur ?? 0) - expectedGbpToEur) < epsilon) + + // GBP → USD cross-currency + let gbpToUsd = exchange.convert(amount: 10.0, from: "GBP", to: "USD") + #expect(abs((gbpToUsd ?? 0) - 10.0 / gbpRate) < epsilon) + + // Formatting + let gbpFormatted = UsageFormatter.convertedCostString(10.0, targetCurrency: "GBP") + #expect(gbpFormatted.contains("£")) + + let usdFormatted = UsageFormatter.convertedCostString(10.0, targetCurrency: "USD") + #expect(usdFormatted == "$10.00") + + // Smart conversion with preferred currency + let autoResult = UsageFormatter.convertedCostString(10.0, preferredCurrency: "auto", providerCurrency: "GBP") + #expect(autoResult.contains("£")) + + let explicitCNY = UsageFormatter.convertedCostString(10.0, preferredCurrency: "CNY", providerCurrency: "USD") + #expect(explicitCNY.contains("¥")) + + #expect(exchange.convert(amount: 10.0, from: "CHF", to: "USD") == nil) + let unavailable = UsageFormatter.convertedCostString( + 10.0, + preferredCurrency: "USD", + providerCurrency: "CHF") + #expect(unavailable.contains("CHF")) + #expect(!unavailable.contains("$")) + } + + @Test + func `live exchange rates require any explicit supported currency`() { + #expect(CurrencyExchange.requiresLiveRates(preferredCurrencyCode: "USD")) + #expect(CurrencyExchange.requiresLiveRates(preferredCurrencyCode: " usd ")) + #expect(!CurrencyExchange.requiresLiveRates(preferredCurrencyCode: "auto")) + #expect(!CurrencyExchange.requiresLiveRates(preferredCurrencyCode: "CHF")) + #expect(CurrencyExchange.requiresLiveRates(preferredCurrencyCode: "GBP")) + #expect(CurrencyExchange.requiresLiveRates(preferredCurrencyCode: " eur ")) + } + + @Test + func `currency exchange rejects invalid and unsupported rates`() { + let valid = CurrencyExchange.validatedRates([ + " usd ": 1, + "GBP": 0.8, + "EUR": .infinity, + "JPY": .nan, + "CAD": 0, + "AUD": -1, + "CHF": 0.9, + " usd": 2, + ]) + + #expect(valid == ["USD": 1, "GBP": 0.8]) + #expect(CurrencyExchange.shared.convert(usdAmount: 10, to: "CHF") == nil) + #expect(CurrencyExchange.shared.convert(amount: 10, from: "CHF", to: "CHF") == nil) + } + @Test func `usage formatter localization keys exist in en and zh Hans with matching placeholders`() throws { let root = URL(fileURLWithPath: #filePath) diff --git a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift index 03f83bf30..88fa6f9fe 100644 --- a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift +++ b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift @@ -911,3 +911,81 @@ struct UsageStoreSessionQuotaTransitionTests { updatedAt: Date()) } } + +@MainActor +struct CrofQuotaNotificationTests { + private func makeSettings(suiteName: String) -> SettingsStore { + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + @Test + func `crof credits-only balance depletion and top-up do not emit quota notifications`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-crof-balance") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + + let notifier = UsageStoreSessionQuotaTransitionTests.SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + let funded = CrofUsageSnapshot(credits: 9.0441, updatedAt: Date()).toUsageSnapshot() + let depleted = CrofUsageSnapshot(credits: 0, updatedAt: Date()).toUsageSnapshot() + let toppedUp = CrofUsageSnapshot(credits: 5, updatedAt: Date()).toUsageSnapshot() + + #expect(funded.secondary == nil) + #expect(funded.primary?.windowMinutes == nil) + #expect(depleted.primary?.usedPercent == 100) + + for snapshot in [funded, depleted, toppedUp] { + store.handleSessionQuotaTransition(provider: .crof, snapshot: snapshot) + store.handleQuotaWarningTransitions(provider: .crof, snapshot: snapshot) + } + + #expect(notifier.posts.isEmpty) + #expect(notifier.quotaWarningPosts.isEmpty) + } + + @Test + func `crof quota-backed session window still emits session quota notifications`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-crof-quota") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + + let notifier = UsageStoreSessionQuotaTransitionTests.SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + let baseline = CrofUsageSnapshot( + credits: 10, + requestsPlan: 1000, + usableRequests: 800, + updatedAt: Date()).toUsageSnapshot() + #expect(baseline.primary?.windowMinutes == 24 * 60) + store.handleSessionQuotaTransition(provider: .crof, snapshot: baseline) + + let depleted = CrofUsageSnapshot( + credits: 10, + requestsPlan: 1000, + usableRequests: 0, + updatedAt: Date()).toUsageSnapshot() + store.handleSessionQuotaTransition(provider: .crof, snapshot: depleted) + + #expect(notifier.posts.map(\.provider) == [.crof]) + } +} diff --git a/Tests/CodexBarTests/XAIProviderTests.swift b/Tests/CodexBarTests/XAIProviderTests.swift new file mode 100644 index 000000000..4e62313b6 --- /dev/null +++ b/Tests/CodexBarTests/XAIProviderTests.swift @@ -0,0 +1,647 @@ +import CodexBarCore +import Foundation +import SwiftUI +import Testing +@testable import CodexBar +@testable import CodexBarCLI + +struct XAIProviderTests { + // MARK: - Settings reader + + @Test + func `settings reader trims whitespace and quotes`() { + #expect(XAISettingsReader.apiKey(environment: [ + XAISettingsReader.apiKeyEnvironmentKey: " 'fixture-management-key' ", + ]) == "fixture-management-key") + #expect(XAISettingsReader.apiKey(environment: [:]) == nil) + #expect(XAISettingsReader.apiKey(environment: [ + XAISettingsReader.apiKeyEnvironmentKey: " ", + ]) == nil) + #expect(XAISettingsReader.teamID(environment: [ + XAISettingsReader.teamIDEnvironmentKey: " \"team-1234\" ", + ]) == "team-1234") + #expect(XAISettingsReader.teamID(environment: [:]) == nil) + } + + // MARK: - Fetch: request shape + + @Test + func `balance and usage requests use documented endpoints and bearer auth`() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) // 2027-01-15 08:00:00 UTC + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.scheme == "https") + #expect(url.host == "management-api.x.ai") + #expect(url.user == nil) + #expect(url.password == nil) + #expect(url.query == nil) + #expect(url.fragment == nil) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-management-key") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + switch url.path { + case "/v1/billing/teams/team-1234/prepaid/balance": + #expect(request.httpMethod == "GET") + return Self.response(url: url, body: Self.balanceFixture) + case "/v1/billing/teams/team-1234/usage": + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + let body = try #require(request.httpBody) + let payload = try #require( + try JSONSerialization.jsonObject(with: body) as? [String: Any]) + let analytics = try #require(payload["analyticsRequest"] as? [String: Any]) + let timeRange = try #require(analytics["timeRange"] as? [String: Any]) + #expect(timeRange["startTime"] as? String == "2026-12-17 00:00:00") + #expect(timeRange["endTime"] as? String == "2027-01-15 08:00:00") + #expect(timeRange["timezone"] as? String == "Etc/GMT") + #expect(analytics["timeUnit"] as? String == "TIME_UNIT_DAY") + let values = try #require(analytics["values"] as? [[String: Any]]) + #expect(values.count == 1) + #expect(values.first?["name"] as? String == "usd") + #expect(values.first?["aggregation"] as? String == "AGGREGATION_SUM") + #expect(analytics["groupBy"] as? [String] == []) + #expect(analytics["filters"] as? [String] == []) + return Self.response(url: url, body: Self.usageFixture) + default: + Issue.record("unexpected request path \(url.path)") + throw URLError(.badURL) + } + } + + let usage = try await XAIBillingFetcher.fetchUsage( + managementKey: "fixture-management-key", + teamID: "team-1234", + transport: transport, + now: now) + + #expect(await transport.requests().count == 2) + // Docs example: a $10 top-up shows total.val = "-1000" (string USD cents, + // inverted ledger), so the remaining balance is +$10.00. + #expect(usage.balanceUSD == 10.0) + #expect(!usage.limitReached) + #expect(usage.historyDays == 30) + #expect(usage.updatedAt == now) + // Two series contribute to the same days; sums are per-day and zeros stay dense. + #expect(usage.daily.map(\.day) == ["2027-01-13", "2027-01-14", "2027-01-15"]) + let costs = usage.daily.map(\.costUSD) + #expect(costs.count == 3) + #expect(abs(costs[0] - 1.25973725) < 1e-9) + #expect(costs[1] == 0.5) + #expect(costs[2] == 0) + } + + @Test + func `management key is only sent as a bearer header`() async throws { + let transport = Self.happyTransport() + + _ = try await XAIBillingFetcher.fetchUsage( + managementKey: "fixture-management-key", + teamID: "team-1234", + transport: transport) + + for request in await transport.requests() { + let url = try #require(request.url) + #expect(!url.absoluteString.contains("fixture-management-key")) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-management-key") + } + } + + @Test + func `team id is encoded as a single path component`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(!url.absoluteString.contains("team one")) + #expect(url.absoluteString.contains("team%20one")) + if url.path.hasSuffix("/prepaid/balance") { + return Self.response(url: url, body: Self.balanceFixture) + } + return Self.response(url: url, body: Self.usageFixture) + } + + _ = try await XAIBillingFetcher.fetchUsage( + managementKey: "fixture-management-key", + teamID: "team one", + transport: transport) + } + + @Test + func `team id with path separators is rejected`() async { + await #expect { + _ = try await XAIBillingFetcher.fetchUsage( + managementKey: "fixture-management-key", + teamID: "team/../other", + transport: Self.happyTransport()) + } throws: { error in + error as? XAIBillingError == .invalidTeamID + } + } + + // MARK: - Fetch: balance mapping + + @Test + func `positive ledger total maps to a negative remaining balance`() async throws { + let usage = try await Self.fetch(balanceBody: #"{"changes":[],"total":{"val":"2500"}}"#) + #expect(usage.balanceUSD == -25.0) + } + + @Test + func `zero total maps to a zero balance`() async throws { + let usage = try await Self.fetch(balanceBody: #"{"changes":[],"total":{"val":"0"}}"#) + #expect(usage.balanceUSD == 0) + } + + @Test + func `fractional cent strings convert exactly`() async throws { + let usage = try await Self.fetch(balanceBody: #"{"changes":[],"total":{"val":"-333"}}"#) + #expect(usage.balanceUSD == 3.33) + } + + @Test + func `malformed 200 balance is a parse error not a zero balance`() async { + for body in [ + "{}", + #"{"total":{}}"#, + #"{"total":{"val":""}}"#, + #"{"total":{"val":"n/a"}}"#, + #"{"total":{"val":"12abc"}}"#, + #"{"error":"forbidden"}"#, + ] { + await #expect { + _ = try await Self.fetch(balanceBody: body) + } throws: { error in + guard case .parseFailed = error as? XAIBillingError else { return false } + return true + } + } + } + + // MARK: - Fetch: usage mapping + + @Test + func `usage history failure preserves the balance`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.hasSuffix("/prepaid/balance") { + return Self.response(url: url, body: Self.balanceFixture) + } + return Self.response(url: url, body: #"{"error":"unavailable"}"#, statusCode: 500) + } + + let usage = try await XAIBillingFetcher.fetchUsage( + managementKey: "fixture-management-key", + teamID: "team-1234", + transport: transport) + + #expect(usage.balanceUSD == 10.0) + #expect(usage.daily.isEmpty) + #expect(!usage.limitReached) + } + + @Test + func `usage auth failure is not hidden by the balance success`() async { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.hasSuffix("/prepaid/balance") { + return Self.response(url: url, body: Self.balanceFixture) + } + return Self.response(url: url, body: #"{"error":"unauthorized"}"#, statusCode: 401) + } + + await #expect { + _ = try await XAIBillingFetcher.fetchUsage( + managementKey: "fixture-management-key", + teamID: "team-1234", + transport: transport) + } throws: { error in + error as? XAIBillingError == .authenticationRejected + } + } + + @Test + func `limitReached marks the history partial`() async throws { + let body = Self.usageFixture.replacingOccurrences( + of: #""limitReached": false"#, + with: #""limitReached": true"#) + let usage = try await Self.fetch(usageBody: body) + #expect(usage.limitReached) + } + + @Test + func `malformed usage payload degrades to balance only`() async throws { + let usage = try await Self.fetch(usageBody: #"{"object":"list"}"#) + #expect(usage.balanceUSD == 10.0) + #expect(usage.daily.isEmpty) + } + + @Test + func `usage point without a finite USD value degrades to balance only`() async throws { + for body in [ + """ + { + "timeSeries":[{ + "dataPoints":[{ + "timestamp":"2027-01-15T00:00:00Z", + "values":[] + }] + }], + "limitReached":false + } + """, + """ + { + "timeSeries":[{ + "dataPoints":[{ + "timestamp":"2027-01-15T00:00:00Z", + "values":[1e400] + }] + }], + "limitReached":false + } + """, + ] { + let usage = try await Self.fetch(usageBody: body) + #expect(usage.balanceUSD == 10.0) + #expect(usage.daily.isEmpty) + } + } + + // MARK: - Fetch: errors + + @Test + func `missing or whitespace credential fails clearly`() async { + await #expect { + _ = try await XAIBillingFetcher.fetchUsage( + managementKey: " ", + teamID: "team-1234", + transport: Self.happyTransport()) + } throws: { error in + error as? XAIBillingError == .notConfigured + } + #expect(XAIBillingError.notConfigured.errorDescription?.contains("XAI_MANAGEMENT_API_KEY") == true) + } + + @Test + func `missing team id fails with actionable guidance`() async { + await #expect { + _ = try await XAIBillingFetcher.fetchUsage( + managementKey: "fixture-management-key", + teamID: " ", + transport: Self.happyTransport()) + } throws: { error in + error as? XAIBillingError == .missingTeamID + } + #expect(XAIBillingError.missingTeamID.errorDescription?.contains("XAI_TEAM_ID") == true) + } + + @Test + func `401 maps to management key guidance`() async { + await #expect { + _ = try await Self.fetch(balanceBody: #"{"error":"unauthorized"}"#, balanceStatus: 401) + } throws: { error in + error as? XAIBillingError == .authenticationRejected + } + #expect(XAIBillingError.authenticationRejected.errorDescription?.contains("Management") == true) + } + + @Test + func `403 maps to management key guidance`() async { + await #expect { + _ = try await Self.fetch(balanceBody: #"{"error":"forbidden"}"#, balanceStatus: 403) + } throws: { error in + error as? XAIBillingError == .authenticationRejected + } + } + + @Test + func `404 maps to team id guidance`() async { + await #expect { + _ = try await Self.fetch(balanceBody: #"{"error":"not found"}"#, balanceStatus: 404) + } throws: { error in + error as? XAIBillingError == .teamNotFound + } + #expect(XAIBillingError.teamNotFound.errorDescription?.contains("team") == true) + } + + @Test + func `429 is surfaced as rate limiting`() async { + await #expect { + _ = try await Self.fetch(balanceBody: #"{"error":"slow down"}"#, balanceStatus: 429) + } throws: { error in + error as? XAIBillingError == .rateLimited + } + } + + @Test + func `unexpected status is reported with its code`() async { + await #expect { + _ = try await Self.fetch(balanceBody: #"{"error":"boom"}"#, balanceStatus: 503) + } throws: { error in + error as? XAIBillingError == .apiError(503) + } + } + + // MARK: - Snapshot projections + + @Test + func `usage snapshot projects balance and history into shared models`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) // 2027-01-15 UTC + let usage = XAIUsageSnapshot( + balanceUSD: 7.36, + daily: [ + .init(day: "2027-01-15", costUSD: 0.25), + .init(day: "2027-01-14", costUSD: 1.5), + ], + updatedAt: now) + let snapshot = usage.toUsageSnapshot() + + #expect(snapshot.primary == nil) + #expect(snapshot.secondary == nil) + #expect(snapshot.providerCost?.used == 7.36) + #expect(snapshot.providerCost?.limit == 0) + #expect(snapshot.providerCost?.currencyCode == "USD") + #expect(snapshot.providerCost?.period == "Prepaid credits") + #expect(snapshot.xaiUsage == usage) + #expect(snapshot.identity?.providerID == .xai) + #expect(snapshot.identity?.loginMethod == "Management API") + #expect(snapshot.dataConfidence == .exact) + + let token = try #require(usage.costHistorySnapshot()) + // Ascending day order; a nil costUSD would silently drop the day from the chart. + #expect(token.daily.map(\.date) == ["2027-01-14", "2027-01-15"]) + #expect(token.daily.compactMap(\.costUSD) == [1.5, 0.25]) + #expect(token.sessionCostUSD == 0.25) + #expect(token.last30DaysCostUSD == 1.75) + #expect(token.currencyCode == "USD") + #expect(token.historyDays == 30) + #expect(token.sessionTokens == nil) + #expect(token.last30DaysTokens == nil) + } + + @Test + func `limitReached lowers confidence and labels the history partial`() throws { + let usage = XAIUsageSnapshot( + balanceUSD: 1, + daily: [.init(day: "2027-01-15", costUSD: 0.5)], + limitReached: true, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000)) + + #expect(usage.toUsageSnapshot().dataConfidence == .estimated) + let token = try #require(usage.costHistorySnapshot()) + #expect(token.historyLabel == "Last 30 days (partial)") + } + + @Test + func `empty history yields no cost history snapshot`() { + let usage = XAIUsageSnapshot( + balanceUSD: 1, + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_800_000_000)) + #expect(usage.costHistorySnapshot() == nil) + #expect(usage.toUsageSnapshot().providerCost?.used == 1) + } + + @Test + func `snapshot round trips through Codable with xai usage preserved`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let usage = XAIUsageSnapshot( + balanceUSD: 7.36, + daily: [.init(day: "2027-01-15", costUSD: 0.25)], + updatedAt: now) + let encoded = try JSONEncoder().encode(usage.toUsageSnapshot()) + let decoded = try JSONDecoder().decode(UsageSnapshot.self, from: encoded) + #expect(decoded.xaiUsage == usage) + #expect(decoded.providerCost?.used == 7.36) + } + + // MARK: - Registration and wiring + + @Test @MainActor + func `descriptor and app registry include xai`() throws { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .xai) + #expect(descriptor.metadata.displayName == "xAI") + #expect(descriptor.metadata.cliName == "xai") + #expect(descriptor.metadata.defaultEnabled == false) + #expect(!descriptor.metadata.supportsCredits) + #expect(!descriptor.tokenCost.supportsTokenCost) + #expect(descriptor.fetchPlan.sourceModes == [.auto, .api]) + #expect(descriptor.cli.aliases.isEmpty) + + let implementation = try #require(ProviderImplementationRegistry.implementation(for: .xai)) + #expect(implementation is XAIProviderImplementation) + } + + @Test @MainActor + func `management API field names the QuotaKit config path`() throws { + let suite = "XAIProviderTests-config-path" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let context = ProviderSettingsContext( + provider: .xai, + settings: settings, + store: store, + boolBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + stringBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + statusText: { _ in nil }, + setStatusText: { _, _ in }, + lastAppActiveRunAt: { _ in nil }, + setLastAppActiveRunAt: { _, _ in }, + requestConfirmation: { _ in }) + + let field = try #require( + XAIProviderImplementation().settingsFields(context: context).first(where: { + $0.id == "xai-management-api-key" + })) + #expect(field.subtitle.contains("~/.quotakit/config.json")) + #expect(!field.subtitle.contains("~/.codexbar/config.json")) + } + + @Test + func `config API key and team ID project into the fetch environment`() { + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + XAISettingsReader.apiKeyEnvironmentKey: "environment-key", + XAISettingsReader.teamIDEnvironmentKey: "environment-team", + ], + provider: .xai, + config: ProviderConfig(id: .xai, apiKey: "config-key", workspaceID: "config-team")) + + #expect(XAISettingsReader.apiKey(environment: env) == "config-key") + #expect(XAISettingsReader.teamID(environment: env) == "config-team") + #expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .xai)) + } + + @Test @MainActor + func `menu card renders the prepaid balance line`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let usage = XAIUsageSnapshot( + balanceUSD: 7.36, + daily: [.init(day: "2027-01-15", costUSD: 0.25)], + updatedAt: now) + let model = UsageMenuCardView.Model.make(.init( + provider: .xai, + metadata: XAIProviderDescriptor.descriptor.metadata, + snapshot: usage.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.isEmpty) + #expect(model.creditsText == nil) + #expect(model.providerCost?.title == "Credits") + #expect(model.providerCost?.spendLine == "Posted balance: $7.36") + #expect(model.providerCost?.percentUsed == nil) + #expect(model.providerCost?.percentLine == "Ledger may lag current-cycle spend") + } + + @Test + func `CLI text renders the prepaid balance instead of the generic cost fallback`() { + let usage = XAIUsageSnapshot( + balanceUSD: 7.36, + daily: [ + .init(day: "2027-01-14", costUSD: 1.5), + .init(day: "2027-01-15", costUSD: 0.25), + ], + updatedAt: Date(timeIntervalSince1970: 1_800_000_000)) + let text = CLIRenderer.renderText( + provider: .xai, + snapshot: usage.toUsageSnapshot(), + credits: nil, + context: RenderContext( + header: "xAI (api)", + status: nil, + useColor: false, + resetStyle: .countdown)) + + #expect(text.contains("Posted balance: $7.36")) + #expect(text.contains("Ledger: May lag current-cycle spend")) + #expect(text.contains("Last 30 days: $1.75")) + // The generic no-window fallback would print "Cost: 7.4 / 0.0", which + // presents the balance as a spend against a zero budget. + #expect(!text.contains("Cost:")) + // `plan.capitalized` would mangle the login method into "Management Api". + #expect(text.contains("Plan: Management API")) + } + + // MARK: - Helpers + + private static func fetch( + balanceBody: String = balanceFixture, + balanceStatus: Int = 200, + usageBody: String = usageFixture, + usageStatus: Int = 200, + now: Date = Date(timeIntervalSince1970: 1_800_000_000)) async throws -> XAIUsageSnapshot + { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.hasSuffix("/prepaid/balance") { + return Self.response(url: url, body: balanceBody, statusCode: balanceStatus) + } + return Self.response(url: url, body: usageBody, statusCode: usageStatus) + } + return try await XAIBillingFetcher.fetchUsage( + managementKey: "fixture-management-key", + teamID: "team-1234", + transport: transport, + now: now) + } + + private static func happyTransport() -> ProviderHTTPTransportStub { + ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.hasSuffix("/prepaid/balance") { + return Self.response(url: url, body: Self.balanceFixture) + } + return Self.response(url: url, body: Self.usageFixture) + } + } + + /// Shape from the documented balance example: a $10 top-up (PURCHASE) recorded + /// as "-1000" cents, with the running total in the same inverted-ledger unit. + static let balanceFixture = #""" + { + "changes": [ + { + "teamId": "team-1234", + "changeOrigin": "PURCHASE", + "topupStatus": "SUCCEEDED", + "amount": { "val": "-1000" }, + "invoiceId": "fixture-invoice-id", + "invoiceNumber": "000-000-000-001", + "createTime": "2026-12-24T15:28:02.308840Z", + "paymentProcessor": { "kind": "STRIPE" } + } + ], + "total": { "val": "-1000" } + } + """# + + /// Shape from the documented usage example: dense daily buckets per series, + /// numeric USD values, `limitReached` cardinality marker. + static let usageFixture = #""" + { + "timeSeries": [ + { + "group": ["Chat grok-4-fixture"], + "groupLabels": ["Chat grok-4-fixture"], + "dataPoints": [ + { "timestamp": "2027-01-13T00:00:00Z", "values": [0.75973725] }, + { "timestamp": "2027-01-14T00:00:00Z", "values": [0.5] }, + { "timestamp": "2027-01-15T00:00:00Z", "values": [0] } + ] + }, + { + "group": ["Live search"], + "groupLabels": ["Live search"], + "dataPoints": [ + { "timestamp": "2027-01-13T00:00:00Z", "values": [0.5] }, + { "timestamp": "2027-01-14T00:00:00Z", "values": [0] }, + { "timestamp": "2027-01-15T00:00:00Z", "values": [0] } + ] + } + ], + "limitReached": false + } + """# + + static func response( + url: URL, + body: String, + statusCode: Int = 200) -> (Data, URLResponse) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (Data(body.utf8), response) + } +} diff --git a/TestsLinux/CrofCreditsOnlyLinuxTests.swift b/TestsLinux/CrofCreditsOnlyLinuxTests.swift new file mode 100644 index 000000000..f447dda46 --- /dev/null +++ b/TestsLinux/CrofCreditsOnlyLinuxTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CrofCreditsOnlyLinuxTests { + @Test + func `credits-only API payload maps balance as primary without request quota`() throws { + let json = """ + { + "credits":9.0441, + "requests_plan":null, + "usable_requests":null, + "usage":{ + "deepseek-v4-flash":{ + "cached_tokens":0, + "input_tokens":23, + "output_tokens":132, + "total_tokens":155 + } + } + } + """ + + let snapshot = try CrofUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.credits == 9.0441) + #expect(snapshot.requestsPlan == nil) + #expect(snapshot.usableRequests == nil) + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.windowMinutes == nil) + #expect(usage.primary?.resetsAt == nil) + #expect(usage.primary?.resetDescription == "$9.04") + #expect(usage.secondary == nil) + #expect(CrofProviderDescriptor.primaryLabel(snapshot: usage) == "Credits") + } + + @Test + func `zero credit balance is exhausted without inventing a reset window`() { + let usage = CrofUsageSnapshot(credits: 0).toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.windowMinutes == nil) + #expect(usage.primary?.resetsAt == nil) + #expect(usage.primary?.resetDescription == "$0.00") + #expect(usage.secondary == nil) + #expect(CrofProviderDescriptor.primaryLabel(snapshot: usage) == "Credits") + } + + @Test + func `optional request quota still preferred when present`() { + let usage = CrofUsageSnapshot( + credits: 10, + requestsPlan: 1000, + usableRequests: 998, + updatedAt: Date(timeIntervalSince1970: 1_777_800_000)).toUsageSnapshot() + + #expect(usage.primary?.windowMinutes == 1440) + #expect(usage.primary?.resetDescription == "998 requests left") + #expect(usage.secondary?.resetDescription == "$10.00") + #expect(CrofProviderDescriptor.primaryLabel(snapshot: usage) != "Credits") + } +} diff --git a/docs/cli-configuration.md b/docs/cli-configuration.md index c22ade21e..e5402f66e 100644 --- a/docs/cli-configuration.md +++ b/docs/cli-configuration.md @@ -59,6 +59,7 @@ printf '%s' "$DEEPGRAM_API_KEY" | quotakit config set-api-key --provider deepgra printf '%s' "$GROQ_API_KEY" | quotakit config set-api-key --provider groq --stdin printf '%s' "$LLM_PROXY_API_KEY" | quotakit config set-api-key --provider llmproxy --stdin printf '%s' "$Z_AI_API_KEY" | quotakit config set-api-key --provider zai --stdin +printf '%s' "$XAI_MANAGEMENT_API_KEY" | quotakit config set-api-key --provider xai --stdin ``` For a z.ai team account: @@ -74,8 +75,10 @@ printf '%s' "$Z_AI_API_KEY" | quotakit config set-api-key --provider zai --stdin Use single-line BigModel organization/project IDs; see [z.ai](zai.md). Only providers that consume config-backed API keys accept this command. Admin API providers may require a key with -organization/usage permissions, not a normal inference key. Browser/OAuth providers such as Grok use their own provider -sessions instead of an xAI API key for QuotaKit's billing view, so enable them with +organization/usage permissions, not a normal inference key. The `xai` provider reads xAI developer-platform billing +with a Management key plus a team ID (set `workspaceID` in the provider entry, `XAI_TEAM_ID`, or the app settings +pane); inference API keys are not accepted. The separate Grok provider tracks consumer Grok/SuperGrok subscriptions +through its own browser/CLI session and takes no API key, so enable it with `quotakit config enable --provider grok`. LLM Proxy also needs a base URL. Use `LLM_PROXY_BASE_URL` for CLI runs, or add `"enterpriseHost"` to the provider entry diff --git a/docs/configuration.md b/docs/configuration.md index 686417d93..95ca0cd91 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -185,6 +185,7 @@ printf '%s' "$LITELLM_API_KEY" | quotakit config set-api-key --provider litellm printf '%s' "$CLAWROUTER_API_KEY" | quotakit config set-api-key --provider clawrouter --stdin printf '%s' "$SUB2API_API_KEY" | quotakit config set-api-key --provider sub2api --stdin printf '%s' "$AIAND_API_KEY" | quotakit config set-api-key --provider aiand --stdin +printf '%s' "$XAI_MANAGEMENT_API_KEY" | quotakit config set-api-key --provider xai --stdin ``` OpenAI API project scoping uses `workspaceID` in config. This maps to `OPENAI_PROJECT_ID` for Admin API usage and is @@ -271,7 +272,7 @@ z.ai team accounts also use `usageScope`, `organizationId`, and `workspaceID`; s ## Provider IDs Current IDs (see `Sources/CodexBarCore/Providers/Providers.swift`): -`codex`, `openai`, `azureopenai`, `claude`, `clinepass`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `qwencloud`, `factory`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `warp`, `openrouter`, `elevenlabs`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `deepinfra`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `neuralwatt`, `clawrouter`, `longcat`, `sub2api`, `wayfinder`, `zenmux`, `aiand`, `zoommate`. +`codex`, `openai`, `azureopenai`, `claude`, `clinepass`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `qwencloud`, `factory`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `warp`, `openrouter`, `elevenlabs`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `deepinfra`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `neuralwatt`, `clawrouter`, `longcat`, `sub2api`, `wayfinder`, `zenmux`, `aiand`, `zoommate`, `xai`. ## Ordering The order of `providers` controls display/order in the app and CLI. Reorder the array to change ordering. diff --git a/docs/crof.md b/docs/crof.md index 59fd9b72f..b96c1beed 100644 --- a/docs/crof.md +++ b/docs/crof.md @@ -1,15 +1,14 @@ --- -summary: "Crof provider data source: API key + usage_api request quota." +summary: "Crof provider data source: API key + usage_api credit balance and optional request quota." read_when: - Adding or tweaking Crof usage parsing - Updating Crof API key handling - - Documenting Crof reset behavior --- # Crof provider -Crof is API-only. CodexBar reads `GET https://crof.ai/usage_api/` with a -Bearer token and displays the returned request quota and dollar credit balance. +Crof is API-only. CodexBar reads `GET https://crof.ai/usage_api/` with a Bearer token +and displays the returned dollar credit balance plus request quota fields when available. ## Data sources @@ -18,19 +17,20 @@ Bearer token and displays the returned request quota and dollar credit balance. 2. **Usage endpoint** - `GET https://crof.ai/usage_api/` - Request headers: `Authorization: Bearer `, `Accept: application/json` - - Response fields: `credits`, `requests_plan`, `usable_requests` + - Response fields used: `credits`, plus optional `requests_plan` / `usable_requests` + - Ignored: per-model `usage` token totals ## Usage details -- The primary row shows request quota with the exact usable request count on the right. - The visible remaining percent is floored so partially used quotas like `998/1000` - do not round up to `100% left`. -- Crof support said quota reset is around midnight Central time; CodexBar models this - as the next `America/Chicago` midnight so daylight saving time maps to GMT-5 when - appropriate. -- The secondary row shows the current Crof dollar balance, floored to cents so tiny - microcent-level burns never overstate the remaining balance. -- Reset timing is inferred until Crof exposes reset metadata in the usage API. +- When both request-quota fields are present, the primary row shows request quota and + the exact usable request count; the secondary row shows the dollar balance. +- When the request-quota fields are null or absent, the primary row falls back to the + dollar balance so PAYG-only accounts still render successfully. +- Dollar balances are floored to cents so tiny microcent-level burns never overstate + the remaining balance. +- With no credit cap in the API, the bar only indicates present vs. exhausted credits. +- Request reset timing is inferred as the next `America/Chicago` midnight only for + accounts that return request-quota fields. - The provider icon is SVG and CodexBar renders it as a template image so it matches the other monochrome provider icons. - Dashboard: `https://crof.ai/dashboard`. diff --git a/docs/index.html b/docs/index.html index f5c34660d..9c3d1ea40 100644 --- a/docs/index.html +++ b/docs/index.html @@ -6,7 +6,7 @@ QuotaKit — every AI coding limit in your menu bar @@ -85,7 +85,7 @@

Every AI coding limit, in your menu bar.

-

65 providers, one menu bar

+

66 providers, one menu bar

Popular providers become status items with their own usage windows, reset countdowns, charts, and provider menus.

    @@ -188,6 +188,9 @@

    65 providers, one menu bar

  • ai&API key
  • +
  • + xAIManagement key +
  • T3 ChatCookies
  • diff --git a/docs/llms.txt b/docs/llms.txt index d8d59b021..b3cb511de 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -1,9 +1,9 @@ # QuotaKit -A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 65 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. +A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 66 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. Canonical documentation: -- QuotaKit — every AI coding limit in your menu bar: https://columbus-labs.com/quotakit/mac - A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 65 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. +- QuotaKit — every AI coding limit in your menu bar: https://columbus-labs.com/quotakit/mac - A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 66 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. Source: https://github.com/ColumbusLabs/QuotaKit diff --git a/docs/logos/xai.svg b/docs/logos/xai.svg new file mode 100644 index 000000000..f0a69128c --- /dev/null +++ b/docs/logos/xai.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/docs/providers.md b/docs/providers.md index 9d2e6e46d..2b5328564 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -8,7 +8,7 @@ read_when: # Providers -QuotaKit currently registers 65 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or +QuotaKit currently registers 66 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or OpenCode vs OpenCode Go, because the auth source and quota shape differ. ## Fetch strategies (current) @@ -92,6 +92,7 @@ scan fails, while provider/account configuration changes replace obsolete result | Chutes | API key from config/env → subscription usage and quota API (`api`). | | Neuralwatt | API key from config/env → `/v1/quota` subscription kWh usage and prepaid balance (`api`). | | ZenMux | Management API key from config/env → five-hour and seven-day quota windows plus PAYG balance (`api`). | +| xAI | Management key + team ID from config/env → prepaid balance and 30-day daily spend from the Management API (`api`). | | Zed | Zed editor Keychain session → `cloud.zed.dev/client/users/me` for plan and quota data (`local`). | ## Codex @@ -456,9 +457,9 @@ provider-specific cookie validation, endpoints, login detection, and error trans ## Crof - API key from `~/.quotakit/config.json`, `CROF_API_KEY`, or `CROFAI_API_KEY`. -- Reads `credits`, `requests_plan`, and `usable_requests` from `GET https://crof.ai/usage_api/`. -- Shows request quota as the primary usage window and dollar credits as the secondary row. -- Infers the daily request reset from midnight America/Chicago until the usage API exposes reset metadata. +- Reads `credits` and optional `requests_plan` / `usable_requests` from `GET https://crof.ai/usage_api/`. +- Prefers request quota plus a secondary dollar-balance row when quota fields are present; otherwise shows dollar + credits as the primary window without synthesizing quota alerts. - Status: none yet. - Details: `docs/crof.md`. @@ -579,4 +580,12 @@ provider-specific cookie validation, endpoints, login detection, and error trans - Status: none yet. - Details: `docs/stepfun.md`. +## xAI +- Management API key + team ID from config or `XAI_MANAGEMENT_API_KEY` / `XAI_TEAM_ID`; inference API keys are not + accepted. +- Reads prepaid balance and 30-day daily USD spend from the xAI Management API. +- Distinct from Grok: xAI tracks developer-platform billing while Grok tracks consumer subscription quota. +- Prepaid money is not synthesized into session or weekly quota. +- Details: `docs/xai.md`. + See also: `docs/provider.md` for architecture notes. diff --git a/docs/social.html b/docs/social.html index d9faeb2de..93282b848 100644 --- a/docs/social.html +++ b/docs/social.html @@ -199,7 +199,7 @@

    Every AI coding limit, in your menu bar.

    -

    65 providers·usage windows, credits, resets·one status item each, or merged.

    +

    66 providers·usage windows, credits, resets·one status item each, or merged.

      diff --git a/docs/xai.md b/docs/xai.md new file mode 100644 index 000000000..0e77f1a19 --- /dev/null +++ b/docs/xai.md @@ -0,0 +1,90 @@ +--- +summary: "xAI provider: Management API key + team ID setup, prepaid balance, and daily platform spend." +read_when: + - Configuring xAI platform usage + - Debugging xAI Management API requests +--- + +# xAI Provider + +QuotaKit reads the prepaid credit balance and daily USD spend of an xAI developer-platform team from xAI's documented +Management API. + +This provider is intentionally separate from the [Grok provider](grok.md): Grok tracks consumer Grok/SuperGrok +subscription quota through the Grok CLI or a grok.com session, while xAI tracks the developer-platform prepaid billing +surface. Credentials, balances, and identity are never shared between the two. + +## Authentication + +Create a **Management API key** in the [xAI Console](https://console.x.ai) under Settings > Management Keys, then add +it together with your **team ID** in QuotaKit Settings → Providers → xAI. Inference API keys are not accepted by the +Management API. The team ID is shown in the xAI Console URL and team settings. + +You can also use environment variables: + +```bash +export XAI_MANAGEMENT_API_KEY="..." +export XAI_TEAM_ID="..." +``` + +Or configure the key through the CLI and the team ID in the config file: + +```bash +printf '%s' "$XAI_MANAGEMENT_API_KEY" | quotakit config set-api-key --provider xai --stdin +``` + +```json +{ + "id": "xai", + "enabled": true, + "apiKey": "", + "workspaceID": "" +} +``` + +## Data Source + +QuotaKit requests: + +- `GET https://management-api.x.ai/v1/billing/teams/{team_id}/prepaid/balance` +- `POST https://management-api.x.ai/v1/billing/teams/{team_id}/usage` with a daily, USD-summed analytics query for the + last 30 days (UTC), as best-effort history enrichment. + +Both requests use `Authorization: Bearer `. QuotaKit does not read browser cookies, console sessions, +or inference traffic for this provider. + +The balance endpoint reports an inverted ledger in string USD cents — a $10 top-up appears as `"-1000"` — so the +remaining balance is the negated cent value. A response without a parseable total is treated as an error, never as a +$0.00 balance. + +The displayed balance is the **posted** prepaid ledger. xAI posts spend deductions to the ledger at billing-cycle +close (ledger entries are keyed by billing period), so mid-cycle the ledger balance can be higher than the Console's +live remaining credit by the current cycle's not-yet-posted spend. Live verification on a real account confirmed this: +posted balance ≈ live remaining + current-cycle spend. + +## Display + +The menu card shows the posted prepaid balance in the currency selected in QuotaKit, with a reminder that ledger +deductions can lag current-cycle spend. The inline dashboard shows the last 30 days of daily platform spend with +today/30-day totals. When xAI reports its analytics cardinality cap (`limitReached`), the history is labeled "Last 30 +days (partial)" and the snapshot is marked estimated instead of exact. Prepaid money is not a quota, so no session or +weekly meters are synthesized. + +## CLI Usage + +```bash +quotakit --provider xai +``` + +## Troubleshooting + +- A `401` or `403` means xAI rejected the Management API key. Confirm the key was created under Settings > Management + Keys and has the billing read ACLs; inference keys never work. +- A `404` usually means the team ID is wrong or the key belongs to a different team. +- A usage-history failure does not suppress an otherwise valid balance; the card keeps the balance and drops the chart. +- Organization-scoped management keys must still supply the explicit team ID to bill against. + +## Sources + +- [Management API guide](https://docs.x.ai/developers/management-api-guide) +- [Billing REST reference](https://docs.x.ai/developers/rest-api-reference/management/billing) diff --git a/version.env b/version.env index 6a9c8cd84..3b0765854 100644 --- a/version.env +++ b/version.env @@ -10,4 +10,4 @@ UPSTREAM_SYNC_DATE=2026-07-29 # Advance this when an upstream sync PR lands. It is independent of shipped # release tracking above, so the monitor does not reopen stale issues while a # merged upstream sync has not yet shipped to users. -UPSTREAM_MONITOR_BASE=ab31038ed8cf66a1231ef5f21a07e56a4037221a +UPSTREAM_MONITOR_BASE=8ef86077e70ac27d45ddddaf49e409824ccdf668