diff --git a/CHANGELOG.md b/CHANGELOG.md index d4dd7ac92c..0e66574b53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.43.1 — Unreleased ### Added +- ai&: add 30-day organization spend from the documented analytics summary API using org-scoped API keys. - ZenMux: add Management API usage with five-hour and weekly quotas, subscription expiry, and USD PAYG balance. Thanks @kays0x! ### Fixed diff --git a/README.md b/README.md index a7a2b8a9e9..7d73e3eaec 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![License: MIT](https://img.shields.io/badge/license-MIT-6e5aff?style=flat-square)](LICENSE) [![Site](https://img.shields.io/badge/site-codexbar.app-16d3b4?style=flat-square)](https://codexbar.app) -CodexBar — every AI coding limit in your menu bar. 60 providers. +CodexBar — every AI coding limit in your menu bar. 61 providers. Tiny macOS 14+ menu bar app that keeps **AI coding-provider limits visible** and shows when each window resets. Codex, OpenAI, Claude, Cursor, Gemini, Copilot, Grok, GroqCloud, ElevenLabs, Deepgram, z.ai, MiniMax, Kiro, Zed, Vertex AI, Augment, OpenRouter, LiteLLM, LLM Proxy, Codebuff, Command Code, ClinePass, AWS Bedrock, and many newer coding providers. One status item per provider, or Merge Icons mode with a provider switcher. No Dock icon, minimal UI, dynamic bar icons. diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index e7b71bfb82..5929389e35 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -450,6 +450,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { resetKind: String) { let origin = self.statusController?.celebrationOriginPoint(for: provider) + let palette = ProviderDescriptorRegistry.descriptor(for: provider).branding.confettiPalette self.confettiLogger.info( "Triggering confetti", metadata: [ @@ -458,7 +459,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { "resetKind": resetKind, "originKnown": origin == nil ? "0" : "1", ]) - self.confettiOverlayController.play(originInScreen: origin) + self.confettiOverlayController.play(originInScreen: origin, colors: palette) } /// Use the classic (non-Liquid Glass) app icon on macOS versions before 26. diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index f45b88e316..91621db129 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -48,6 +48,7 @@ struct CostHistoryChartMenuView: View { private let historyDays: Int private let windowLabel: String? private let projects: [CostUsageProjectBreakdown] + private let sessions: [CostUsageSessionBreakdown] private let width: CGFloat @State private var selectedDateKey: String? @@ -59,6 +60,7 @@ struct CostHistoryChartMenuView: View { historyDays: Int = 30, windowLabel: String? = nil, projects: [CostUsageProjectBreakdown] = [], + sessions: [CostUsageSessionBreakdown] = [], width: CGFloat) { self.provider = provider @@ -68,6 +70,7 @@ struct CostHistoryChartMenuView: View { self.historyDays = max(1, min(365, historyDays)) self.windowLabel = windowLabel self.projects = projects + self.sessions = sessions self.width = width } @@ -284,6 +287,10 @@ struct CostHistoryChartMenuView: View { } .frame(height: Self.projectBlockHeight(projects: self.projects), alignment: .topLeading) } + + if !self.sessions.isEmpty { + self.sessionsBlock + } } .padding(.horizontal, 16) .padding(.vertical, Self.verticalPadding) @@ -327,8 +334,95 @@ struct CostHistoryChartMenuView: View { private static let projectSourceIndent: CGFloat = 10 private static let projectMoreRowHeight: CGFloat = 16 private static let maxVisibleProjectSourceRows = 2 + private static let sessionRowHeight: CGFloat = 44 + private static let sessionRowSpacing: CGFloat = 5 + private static let maxVisibleSessionRows = 5 static let verticalPadding: CGFloat = 10 + private var sessionsBlock: some View { + let visibleCount = min(self.sessions.count, Self.maxVisibleSessionRows) + return VStack(alignment: .leading, spacing: Self.sessionRowSpacing) { + HStack { + Text("Conversations (\(self.windowLabel ?? Self.windowLabel(days: self.historyDays)))") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + Spacer() + Text("\(self.sessions.count)") + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + } + .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) + + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: Self.sessionRowSpacing) { + ForEach(self.sessions) { session in + self.sessionRow(session) + } + } + } + .scrollIndicators(self.sessions.count > visibleCount ? .visible : .hidden) + .frame( + height: CGFloat(visibleCount) * Self.sessionRowHeight + + CGFloat(max(visibleCount - 1, 0)) * Self.sessionRowSpacing, + alignment: .topLeading) + } + .frame( + height: Self.detailPrimaryLineHeight + Self.sessionRowSpacing + + CGFloat(visibleCount) * Self.sessionRowHeight + + CGFloat(max(visibleCount - 1, 0)) * Self.sessionRowSpacing, + alignment: .topLeading) + } + + private func sessionRow(_ session: CostUsageSessionBreakdown) -> some View { + HStack(alignment: .top, spacing: 8) { + VStack(alignment: .leading, spacing: 1) { + Text("Session \(Self.shortSessionID(session.sessionID))") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + Text(Self.sessionUsageLine(session)) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + Text(session.lastActivity, format: .dateTime.month(.abbreviated).day().hour().minute()) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + } + Spacer(minLength: 8) + Text(session.costUSD.map(self.costString) ?? "—") + .font(.caption) + .monospacedDigit() + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(height: Self.sessionRowHeight, alignment: .topLeading) + .accessibilityElement(children: .combine) + } + + static func shortSessionID(_ sessionID: String) -> String { + let trimmed = sessionID.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count > 12 else { return trimmed } + return "\(trimmed.prefix(4))...\(trimmed.suffix(8))" + } + + private static func sessionUsageLine(_ session: CostUsageSessionBreakdown) -> String { + let models = session.modelBreakdowns.map(\.modelName) + let modelLabel = if models.isEmpty { + "Unknown model" + } else if models.count == 1 { + models[0] + } else { + "\(models[0]) +\(models.count - 1)" + } + let input = session.inputTokens.map(UsageFormatter.tokenCountString) ?? "—" + let cached = session.cachedInputTokens.map(UsageFormatter.tokenCountString) ?? "—" + let output = session.outputTokens.map(UsageFormatter.tokenCountString) ?? "—" + return "\(modelLabel) · \(input) input · \(cached) cached · \(output) output" + } + static func windowLabel(days: Int) -> String { if days == 1 { return L("Today") @@ -788,6 +882,7 @@ extension CostHistoryChartMenuView { let hasDailyEntries: Bool let daily: [VisibleDailyFingerprint] let projects: [VisibleProjectFingerprint] + let sessions: [VisibleSessionFingerprint] } struct VisibleDailyFingerprint: Equatable { @@ -824,11 +919,23 @@ extension CostHistoryChartMenuView { let totalCostBitPattern: UInt64? } + struct VisibleSessionFingerprint: Equatable { + let sessionID: String + let lastActivityBitPattern: UInt64 + let inputTokens: Int? + let cachedInputTokens: Int? + let outputTokens: Int? + let totalTokens: Int? + let costBitPattern: UInt64? + let models: [VisibleModelBreakdownFingerprint] + } + static func renderFingerprint( from snapshot: CostUsageTokenSnapshot, provider: UsageProvider) -> RenderFingerprint { let projects = provider == .codex ? snapshot.projects : [] + let sessions = provider == .codex ? snapshot.sessions : [] return RenderFingerprint( currencyCode: snapshot.currencyCode, historyDays: snapshot.historyDays, @@ -854,6 +961,26 @@ extension CostHistoryChartMenuView { totalTokens: source.totalTokens, totalCostBitPattern: source.totalCostUSD.map(\.bitPattern)) }) + }, + sessions: sessions.map { session in + VisibleSessionFingerprint( + sessionID: session.sessionID, + lastActivityBitPattern: session.lastActivity.timeIntervalSince1970.bitPattern, + inputTokens: session.inputTokens, + cachedInputTokens: session.cachedInputTokens, + outputTokens: session.outputTokens, + totalTokens: session.totalTokens, + costBitPattern: session.costUSD.map(\.bitPattern), + models: session.modelBreakdowns.map { item in + VisibleModelBreakdownFingerprint( + modelName: item.modelName, + costBitPattern: item.costUSD.map(\.bitPattern), + totalTokens: item.totalTokens, + standardCostBitPattern: item.standardCostUSD.map(\.bitPattern), + priorityCostBitPattern: item.priorityCostUSD.map(\.bitPattern), + standardTokens: item.standardCostUSD == nil ? nil : item.standardTokens, + priorityTokens: item.priorityCostUSD == nil ? nil : item.priorityTokens) + }) }) } diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index fc587dd330..a2362fd3dd 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -342,7 +342,9 @@ extension UsageMenuCardView.Model { percentLine: nil) } - if provider == .openai || provider == .claude || provider == .litellm, cost.limit <= 0 { + if provider == .openai || provider == .claude || provider == .litellm || provider == .aiand, + cost.limit <= 0 + { let spend = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) let periodLabel = Self.localizedPeriodLabel(cost.period ?? "Last 30 days") return ProviderCostSection( diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 101ce3b7c7..ca2c5e6552 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -341,6 +341,15 @@ extension UsageMenuCardView.Model { else { return nil } + // Local Codex session costs are independent from OAuth, CLI quota, and OpenAI web + // dashboard access. Do not present a failed account-level quota fetch as a failure of + // a valid local API-key ledger. + if input.codexLocalSessionCostLedgerEnabled, + self.hasLocalCodexTokenUsage(input), + self.isRemoteCodexQuotaFetchError(lastError) + { + return nil + } if self.shouldShowRateLimitsUnavailablePlaceholder(input: input, lastError: lastError) { return nil } @@ -402,6 +411,10 @@ extension UsageMenuCardView.Model { self.tokenUsageSnapshot(input: input) != nil } + private static func isRemoteCodexQuotaFetchError(_ error: String) -> Bool { + error.localizedCaseInsensitiveContains("Codex usage is temporarily unavailable") + } + private static func shouldShowRateLimitsUnavailablePlaceholder(input: Input, lastError: String? = nil) -> Bool { let currentError = lastError ?? input.lastError if let currentError = currentError?.trimmingCharacters(in: .whitespacesAndNewlines), diff --git a/Sources/CodexBar/MenuCardView+ModelInput.swift b/Sources/CodexBar/MenuCardView+ModelInput.swift index c3bcde4ea4..1e7094a989 100644 --- a/Sources/CodexBar/MenuCardView+ModelInput.swift +++ b/Sources/CodexBar/MenuCardView+ModelInput.swift @@ -22,6 +22,7 @@ extension UsageMenuCardView.Model { let usageBarsShowUsed: Bool let resetTimeDisplayStyle: ResetTimeDisplayStyle let tokenCostUsageEnabled: Bool + let codexLocalSessionCostLedgerEnabled: Bool let tokenCostInlineDashboardEnabled: Bool let tokenCostMenuSectionEnabled: Bool let costComparisonPeriodsEnabled: Bool @@ -57,6 +58,7 @@ extension UsageMenuCardView.Model { usageBarsShowUsed: Bool, resetTimeDisplayStyle: ResetTimeDisplayStyle, tokenCostUsageEnabled: Bool, + codexLocalSessionCostLedgerEnabled: Bool = false, tokenCostInlineDashboardEnabled: Bool? = nil, tokenCostMenuSectionEnabled: Bool? = nil, costComparisonPeriodsEnabled: Bool = false, @@ -91,6 +93,7 @@ extension UsageMenuCardView.Model { self.usageBarsShowUsed = usageBarsShowUsed self.resetTimeDisplayStyle = resetTimeDisplayStyle self.tokenCostUsageEnabled = tokenCostUsageEnabled + self.codexLocalSessionCostLedgerEnabled = codexLocalSessionCostLedgerEnabled self.tokenCostInlineDashboardEnabled = tokenCostInlineDashboardEnabled ?? tokenCostUsageEnabled self.tokenCostMenuSectionEnabled = tokenCostMenuSectionEnabled ?? tokenCostUsageEnabled self.costComparisonPeriodsEnabled = costComparisonPeriodsEnabled diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 386c100738..abf01793e8 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -98,7 +98,9 @@ struct ProvidersPane: View { isPresented: Binding( get: { self.activeConfirmation != nil }, set: { isPresented in - if !isPresented { self.activeConfirmation = nil } + if !isPresented { + self.activeConfirmation = nil + } }), actions: { if let active = self.activeConfirmation { @@ -684,6 +686,7 @@ struct ProvidersPane: View { usageBarsShowUsed: self.settings.usageBarsShowUsed, resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: provider), + codexLocalSessionCostLedgerEnabled: self.settings.codexLocalSessionCostLedgerEnabled, tokenCostInlineDashboardEnabled: self.settings.costSummaryShowsInlineDashboard(for: provider), // Display style only controls the main menu. Provider details always expose // available cost data in their Usage section. diff --git a/Sources/CodexBar/Providers/AiAnd/AiAndProviderImplementation.swift b/Sources/CodexBar/Providers/AiAnd/AiAndProviderImplementation.swift new file mode 100644 index 0000000000..34baab3658 --- /dev/null +++ b/Sources/CodexBar/Providers/AiAnd/AiAndProviderImplementation.swift @@ -0,0 +1,52 @@ +import AppKit +import CodexBarCore +import Foundation + +struct AiAndProviderImplementation: ProviderImplementation { + let id: UsageProvider = .aiand + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.aiAndAPIKey + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if AiAndSettingsReader.apiKey(environment: context.environment) != nil { + return true + } + return !context.settings.aiAndAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "aiand-api-key", + title: "API key", + subtitle: "Stored in CodexBar's config file. Create a key in the ai& console (shown once).", + kind: .secure, + placeholder: "sk-…", + binding: context.stringBinding(\.aiAndAPIKey), + actions: [ + ProviderSettingsActionDescriptor( + id: "aiand-open-console", + title: "Open ai& Console", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://console.aiand.com") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/AiAnd/AiAndSettingsStore.swift b/Sources/CodexBar/Providers/AiAnd/AiAndSettingsStore.swift new file mode 100644 index 0000000000..73e5b26b24 --- /dev/null +++ b/Sources/CodexBar/Providers/AiAnd/AiAndSettingsStore.swift @@ -0,0 +1,14 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var aiAndAPIKey: String { + get { self.configSnapshot.providerConfig(for: .aiand)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .aiand) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .aiand, field: "apiKey", value: newValue) + } + } +} diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index f21275e6db..5744287c09 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -76,6 +76,22 @@ struct CodexProviderImplementation: ProviderImplementation { ].joined(separator: " ") return [ + ProviderSettingsToggleDescriptor( + id: "codex-local-session-cost-ledger", + title: "Local session cost estimates", + subtitle: [ + "Uses this Mac's Codex sessions instead of the selected managed account's session history.", + "Works with organization API keys and does not require OpenAI billing or administrator access.", + "Uses locally cached or bundled model prices without making a network request.", + "This provider-specific toggle does not enable cost summaries for other providers.", + ].joined(separator: " "), + binding: context.boolBinding(\.codexLocalSessionCostLedgerEnabled), + statusText: nil, + actions: [], + isVisible: nil, + onChange: nil, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), ProviderSettingsToggleDescriptor( id: "codex-historical-tracking", title: "Historical tracking", @@ -165,8 +181,11 @@ struct CodexProviderImplementation: ProviderImplementation { return [ ProviderSettingsPickerDescriptor( id: "codex-usage-source", - title: "Usage source", - subtitle: "Auto falls back to the next source if the preferred one fails.", + title: "Quota usage source", + subtitle: [ + "Controls live session and weekly quota fetching only.", + "Local session cost estimates work independently.", + ].joined(separator: " "), binding: usageBinding, options: usageOptions, isVisible: nil, diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift index af04e29329..e6b7d06682 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift @@ -9,7 +9,7 @@ extension UsageStore { func codexCreditsFetcher() -> UsageFetcher { // Credits are remote Codex account state, so they need the same managed-home routing as the - // primary Codex usage fetch. Local token-cost scanning intentionally stays ambient-system scoped. + // primary Codex usage fetch. Token-cost scanning owns its selected managed or ambient scope separately. self.makeFetchContext(provider: .codex, override: nil).fetcher } diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index e47311cde1..1883844b3a 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -73,6 +73,7 @@ enum ProviderImplementationRegistry { case .sub2api: Sub2APIProviderImplementation() case .wayfinder: WayfinderProviderImplementation() case .zenmux: ZenMuxProviderImplementation() + case .aiand: AiAndProviderImplementation() } } diff --git a/Sources/CodexBar/Resources/ProviderIcon-aiand.svg b/Sources/CodexBar/Resources/ProviderIcon-aiand.svg new file mode 100644 index 0000000000..68cba8283f --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-aiand.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Sources/CodexBar/ScreenConfettiOverlayController.swift b/Sources/CodexBar/ScreenConfettiOverlayController.swift index acacceb5e7..b6544bad42 100644 --- a/Sources/CodexBar/ScreenConfettiOverlayController.swift +++ b/Sources/CodexBar/ScreenConfettiOverlayController.swift @@ -11,7 +11,7 @@ final class ScreenConfettiOverlayController { private var windows: [NSWindow] = [] private var dismissalTask: Task? - func play(originInScreen origin: CGPoint?) { + func play(originInScreen origin: CGPoint?, colors: [ProviderColor]) { guard self.windows.isEmpty else { self.logger.debug("Ignoring confetti trigger while overlay is already active") return @@ -23,7 +23,9 @@ final class ScreenConfettiOverlayController { return } - let palette = Self.randomPalette() + let palette = colors.map { color in + Color(red: color.red, green: color.green, blue: color.blue) + } self.windows = screens.map { screen in let frame = screen.frame let localOrigin = Self.localOrigin(in: frame, from: origin) @@ -99,17 +101,6 @@ final class ScreenConfettiOverlayController { x: min(max(resolved.x, insetFrame.minX), insetFrame.maxX) - screenFrame.minX, y: min(max(resolved.y, insetFrame.minY), insetFrame.maxY) - screenFrame.minY) } - - private static func randomPalette() -> [Color] { - let hue = Double.random(in: 0...1) - let hueOffsets = [0.0, 0.08, 0.16, 0.5, 0.66, 0.83] - return hueOffsets.map { offset in - Color( - hue: (hue + offset).truncatingRemainder(dividingBy: 1), - saturation: Double.random(in: 0.55...0.95), - brightness: Double.random(in: 0.85...1)) - } - } } private final class ClickThroughOverlayPanel: NSPanel { diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 5985a08abe..e9d1db56c0 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -384,6 +384,15 @@ extension SettingsStore { } } + var codexLocalSessionCostLedgerEnabled: Bool { + get { self.defaultsState.codexLocalSessionCostLedgerEnabled } + set { + self.defaultsState.codexLocalSessionCostLedgerEnabled = newValue + self.userDefaults.set(newValue, forKey: "codexLocalSessionCostLedgerEnabled") + self.noteBackgroundWorkSettingsChanged() + } + } + var costUsageHistoryDays: Int { get { self.defaultsState.costUsageHistoryDays } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 6f2ac24e30..605a62f13d 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -37,6 +37,7 @@ extension SettingsStore { _ = self.menuBarMetricPreferencesRaw _ = self.copilotIconSecondaryWindowIDRaw _ = self.costUsageEnabled + _ = self.codexLocalSessionCostLedgerEnabled _ = self.costUsageHistoryDays _ = self.costComparisonPeriodsEnabled _ = self.costSummaryDisplayStyle diff --git a/Sources/CodexBar/SettingsStore+MenuPreferences.swift b/Sources/CodexBar/SettingsStore+MenuPreferences.swift index 3f8579c7e9..96866fd371 100644 --- a/Sources/CodexBar/SettingsStore+MenuPreferences.swift +++ b/Sources/CodexBar/SettingsStore+MenuPreferences.swift @@ -292,8 +292,9 @@ extension SettingsStore { } func isCostUsageEffectivelyEnabled(for provider: UsageProvider) -> Bool { - self.costUsageEnabled - && ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost + let isEnabled = self.costUsageEnabled || + (provider == .codex && self.codexLocalSessionCostLedgerEnabled) + return isEnabled && ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost } var resetTimeDisplayStyle: ResetTimeDisplayStyle { diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 6f4ec81780..b53bffdd1c 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -444,6 +444,8 @@ extension SettingsStore { let copilotBudgetExtrasEnabled = userDefaults.object(forKey: "copilotBudgetExtrasEnabled") as? Bool ?? false let copilotIconSecondaryWindowIDRaw = Self.loadCopilotIconSecondaryWindowIDRaw(userDefaults: userDefaults) let costUsageEnabled = userDefaults.object(forKey: "tokenCostUsageEnabled") as? Bool ?? false + let codexLocalSessionCostLedgerEnabled = userDefaults.object( + forKey: "codexLocalSessionCostLedgerEnabled") as? Bool ?? false let rawCostUsageHistoryDays = userDefaults.object(forKey: "tokenCostUsageHistoryDays") as? Int ?? 30 let costUsageHistoryDays = max(1, min(365, rawCostUsageHistoryDays)) let costComparisonPeriodsEnabled = userDefaults.object( @@ -535,6 +537,7 @@ extension SettingsStore { copilotBudgetExtrasEnabled: copilotBudgetExtrasEnabled, copilotIconSecondaryWindowIDRaw: copilotIconSecondaryWindowIDRaw, costUsageEnabled: costUsageEnabled, + codexLocalSessionCostLedgerEnabled: codexLocalSessionCostLedgerEnabled, costUsageHistoryDays: costUsageHistoryDays, costComparisonPeriodsEnabled: costComparisonPeriodsEnabled, costSummaryDisplayStyleRaw: costSummaryDisplayStyleRaw, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 77d9583842..daa9552220 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -38,6 +38,7 @@ struct SettingsDefaultsState { var copilotBudgetExtrasEnabled: Bool var copilotIconSecondaryWindowIDRaw: String var costUsageEnabled: Bool + var codexLocalSessionCostLedgerEnabled: Bool var costUsageHistoryDays: Int var costComparisonPeriodsEnabled: Bool var costSummaryDisplayStyleRaw: String diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 52ffa1628c..3e4b13e6df 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -473,6 +473,7 @@ extension StatusItemController { historyDays: tokenSnapshot.historyDays, windowLabel: tokenSnapshot.historyLabel, projects: provider == .codex ? tokenSnapshot.projects : [], + sessions: provider == .codex ? tokenSnapshot.sessions : [], width: width) let hosting = MenuHostingView(rootView: chartView) hosting.applyMeasuredHeight( diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index 3d82578043..7ae8f28c18 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -127,6 +127,7 @@ extension StatusItemController { usageBarsShowUsed: self.settings.usageBarsShowUsed, resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: target), + codexLocalSessionCostLedgerEnabled: self.settings.codexLocalSessionCostLedgerEnabled, tokenCostInlineDashboardEnabled: self.settings.costSummaryShowsInlineDashboard(for: target), tokenCostMenuSectionEnabled: !UsageStore.tokenCostRequiresProviderSnapshot(target) && self.settings.costSummaryShowsSubmenu(for: target), diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index 07abec81da..6f8adbcdef 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -110,6 +110,7 @@ extension StatusItemController { from: dashboard?.usageBreakdown ?? []) var parts = [ "costEnabled=\(self.settings.costUsageEnabled ? "1" : "0")", + "codexLocalCost=\(self.settings.codexLocalSessionCostLedgerEnabled ? "1" : "0")", "costStyle=\(self.settings.costSummaryDisplayStyle.rawValue)", "openAIAttached=\(self.store.openAIDashboardAttachmentAuthorized ? "1" : "0")", "openAILogin=\(self.store.openAIDashboardRequiresLogin ? "1" : "0")", diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 91a3299827..90b16f1e01 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -32,6 +32,7 @@ extension UsageStore { let fetcher = self.costUsageFetcher let timeoutSeconds = self.tokenFetchTimeout + let allowPricingRefresh = provider != .codex || !self.settings.codexLocalSessionCostLedgerEnabled let environment = provider == .bedrock ? ProviderRegistry.makeEnvironment( base: self.environmentBase, @@ -49,6 +50,7 @@ extension UsageStore { allowVertexClaudeFallback: !self.isEnabled(.claude), codexHomePath: codexHomePath, historyDays: historyDays, + allowPricingRefresh: allowPricingRefresh, bypassScannerDebounce: true) } group.addTask { @@ -174,7 +176,7 @@ extension UsageStore { @discardableResult func hydrateCachedTokenSnapshots(now: Date = Date()) -> Task? { - guard self.settings.costUsageEnabled else { return nil } + guard self.settings.isCostUsageEffectivelyEnabled(for: .codex) else { return nil } guard self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata).contains(.codex) else { return nil } @@ -207,7 +209,7 @@ extension UsageStore { guard self.providerPublicationRevisionIsCurrent(publicationRevision, for: .codex), self.settings.providerConfigRevision(for: .codex) == providerConfigRevision, self.settings.costUsageSettingsRevision == costUsageSettingsRevision, - self.settings.costUsageEnabled, + self.settings.isCostUsageEffectivelyEnabled(for: .codex), self.isEnabled(.codex), self.tokenCostScope(for: .codex).signature == scope.signature, self.settings.costUsageHistoryDays == historyDays, @@ -240,12 +242,35 @@ extension UsageStore { guard provider == .codex else { return (nil, provider.rawValue) } - let homePath = self.settings.activeManagedCodexRemoteHomePath? - .trimmingCharacters(in: .whitespacesAndNewlines) - guard let homePath, !homePath.isEmpty else { + if self.settings.codexLocalSessionCostLedgerEnabled { return (nil, "codex:ambient") } - return (homePath, "codex:managed:\(homePath)") + let activeSource = self.settings.codexActiveSource + switch activeSource { + case .liveSystem: + return (nil, "codex:ambient") + case let .managedAccount(id): + let homePath = self.settings.managedCodexRemoteHomePath(forActiveSource: activeSource)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let homePath, !homePath.isEmpty { + return (homePath, "codex:managed:\(homePath)") + } + let unavailablePath = Self.costUsageCacheDirectory() + .appendingPathComponent("unavailable-managed", isDirectory: true) + .appendingPathComponent(id.uuidString, isDirectory: true) + .path + return (unavailablePath, "codex:managed:unavailable:\(id.uuidString)") + case .profileHome: + let homePath = self.settings.profileCodexHomePath(forActiveSource: activeSource)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let homePath, !homePath.isEmpty { + return (homePath, "codex:profile:\(homePath)") + } + let unavailablePath = Self.costUsageCacheDirectory() + .appendingPathComponent("unavailable-profile", isDirectory: true) + .path + return (unavailablePath, "codex:profile-unavailable") + } } func tokenSnapshotScopeSignature(for provider: UsageProvider) -> String { diff --git a/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift b/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift index dc49160cd1..00ef0abb24 100644 --- a/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift +++ b/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift @@ -21,7 +21,9 @@ extension UsageStore { func scheduleTokenRefresh() { guard self.tokenRefreshSequenceTask == nil, !self.hasForcedRefreshEnrichmentInFlight else { return } - if self.startPendingTokenRefreshRetryIfPossible() { return } + if self.startPendingTokenRefreshRetryIfPossible() { + return + } self.startTokenRefreshSequence(force: false, scope: .all) } @@ -119,7 +121,7 @@ extension UsageStore { private func startPendingTokenRefreshRetryIfPossible() -> Bool { guard !self.tokenRefreshRetryProviders.isEmpty, self.tokenRefreshSequenceTask == nil, - self.settings.costUsageEnabled + self.settings.costUsageEnabled || self.settings.codexLocalSessionCostLedgerEnabled else { return false } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 89d5e08d3b..ee7bdc55f6 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -968,6 +968,7 @@ extension UsageStore { .wayfinder: "Wayfinder debug log not yet implemented", .sub2api: "sub2api debug log not yet implemented", .zenmux: "ZenMux debug log not yet implemented", + .aiand: "ai& debug log not yet implemented", ] let buildText = { switch provider { @@ -1046,7 +1047,7 @@ extension UsageStore { .copilot, .devin, .vertexai, .kilo, .kiro, .kimi, .moonshot, .jetbrains, .perplexity, .mimo, .doubao, .sakana, .abacus, .mistral, .codebuff, .crof, .windsurf, .venice, .manus, .commandcode, .qoder, .stepfun, .bedrock, .grok, .groq, .t3chat, .llmproxy, .litellm, .zed, - .deepgram, .poe, .chutes, .clawrouter, .longcat, .wayfinder, .sub2api, .zenmux: + .deepgram, .poe, .chutes, .clawrouter, .longcat, .wayfinder, .sub2api, .zenmux, .aiand: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } } @@ -1332,7 +1333,7 @@ extension UsageStore { return } - guard self.settings.costUsageEnabled else { + guard self.settings.isCostUsageEffectivelyEnabled(for: provider) else { self.clearTokenSnapshot(for: provider) self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() @@ -1384,11 +1385,8 @@ extension UsageStore { .debug("cost usage start provider=\(provider.rawValue) force=\(force)") do { - // Codex cost usage scans local session logs from this machine. That data is - // intentionally presented as provider-level local telemetry rather than managed-account - // remote state, so managed Codex account selection does not retarget that fetch. - // If the UI later needs account-scoped token history, it should label and source that - // separately instead of silently changing the meaning of this section. + // Codex cost usage scans the explicit token-cost scope: selected managed account by + // default, or this Mac's ambient Codex home when the local ledger is enabled. let snapshot = try await self.loadTokenUsageSnapshot( provider: provider, force: force, diff --git a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift index e241a69426..58d2a70c29 100644 --- a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift +++ b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift @@ -227,6 +227,8 @@ extension CodexBarCLI { ChutesSettingsReader.apiKey(environment: environment) != nil case .zenmux: ZenMuxSettingsReader.managementAPIKey(environment: environment) != nil + case .aiand: + AiAndSettingsReader.apiKey(environment: environment) != nil case .crof: CrofSettingsReader.apiKey(environment: environment) != nil case .deepgram: diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index 93bbb232d8..86d12dd2f0 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -185,7 +185,7 @@ public enum ProviderConfigEnvironment { GroqSettingsReader.apiKeyEnvironmentKey case .llmproxy: LLMProxySettingsReader.apiKeyEnvironmentKey - case .chutes, .poe, .litellm, .clawrouter, .factory, .sub2api, .zenmux: + case .chutes, .poe, .litellm, .clawrouter, .factory, .sub2api, .zenmux, .aiand: self.additionalAPIKeyEnvironmentKey(for: provider) default: nil @@ -208,6 +208,8 @@ public enum ProviderConfigEnvironment { FactorySettingsReader.apiTokenKey case .zenmux: ZenMuxSettingsReader.managementAPIKeyEnvironmentKey + case .aiand: + AiAndSettingsReader.apiKeyEnvironmentKey default: nil } diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 1967165c79..162f5ac825 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -65,6 +65,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: Bool = false, codexHomePath: String? = nil, historyDays: Int = 30, + allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, includePiSessions: Bool = true) async throws -> CostUsageTokenSnapshot { @@ -76,6 +77,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: allowVertexClaudeFallback, codexHomePath: codexHomePath, historyDays: historyDays, + allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: refreshPricingInBackground, includePiSessions: includePiSessions, bypassScannerDebounce: false, @@ -90,6 +92,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: Bool = false, codexHomePath: String? = nil, historyDays: Int = 30, + allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, includePiSessions: Bool = true, bypassScannerDebounce: Bool) async throws -> CostUsageTokenSnapshot @@ -102,6 +105,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: allowVertexClaudeFallback, codexHomePath: codexHomePath, historyDays: historyDays, + allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: refreshPricingInBackground, includePiSessions: includePiSessions, bypassScannerDebounce: bypassScannerDebounce, @@ -117,6 +121,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: Bool = false, codexHomePath: String? = nil, historyDays: Int = 30, + allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, automaticCodexScanByteLimit _: Int64?) async throws -> CostUsageTokenSnapshot { @@ -128,6 +133,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: allowVertexClaudeFallback, codexHomePath: codexHomePath, historyDays: historyDays, + allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: refreshPricingInBackground) } @@ -135,6 +141,22 @@ public struct CostUsageFetcher: Sendable { self.scannerOptions } + private static func resolvedScannerOptions( + _ override: CostUsageScanner.Options?, + provider: UsageProvider, + codexHomePath: String?) -> CostUsageScanner.Options + { + var options = override ?? CostUsageScanner.Options() + if provider == .codex, + let codexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines), + !codexHomePath.isEmpty + { + options.codexSessionsRoot = URL(fileURLWithPath: codexHomePath, isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + } + return options + } + static func loadTokenSnapshot( provider: UsageProvider, environment: [String: String] = ProcessInfo.processInfo.environment, @@ -143,6 +165,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: Bool = false, codexHomePath: String? = nil, historyDays: Int = 30, + allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, includePiSessions: Bool = true, bypassScannerDebounce: Bool = false, @@ -173,30 +196,21 @@ public struct CostUsageFetcher: Sendable { useCurrentLocalDayForSession: false) } - var options = overrideScannerOptions ?? CostUsageScanner.Options() - if provider == .codex, - let codexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines), - !codexHomePath.isEmpty - { - options.codexSessionsRoot = URL(fileURLWithPath: codexHomePath, isDirectory: true) - .appendingPathComponent("sessions", isDirectory: true) - } - if retryUnknownPricing, provider == .codex || provider == .claude { - let pricingCacheRoot = options.cacheRoot - if refreshPricingInBackground { - Task.detached(priority: .utility) { - await ModelsDevPricingPipeline.refreshIfNeeded( - now: now, - cacheRoot: pricingCacheRoot, - client: modelsDevClient) - } - } else { - await ModelsDevPricingPipeline.refreshIfNeeded( - now: now, - cacheRoot: pricingCacheRoot, - client: modelsDevClient) - } - } + var options = Self.resolvedScannerOptions( + overrideScannerOptions, + provider: provider, + codexHomePath: codexHomePath) + let scopedCodexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines) + let shouldMergePiUsage = provider != .codex || scopedCodexHomePath?.isEmpty != false + await Self.refreshPricingIfAllowed( + options: PricingRefreshOptions( + provider: provider, + isAllowed: allowPricingRefresh, + retryUnknown: retryUnknownPricing, + inBackground: refreshPricingInBackground), + now: now, + cacheRoot: options.cacheRoot, + client: modelsDevClient) if provider == .vertexai { options.claudeLogProviderFilter = allowVertexClaudeFallback ? .all : .vertexAIOnly @@ -249,15 +263,25 @@ public struct CostUsageFetcher: Sendable { } var projects: [CostUsageProjectBreakdown] = [] + var sessions: [CostUsageSessionBreakdown] = [] var piDaily: CostUsageDailyReport? if provider == .codex { - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: scanOptions.cacheRoot) + let roots = CostUsageScanner.codexSessionsRoots(options: scanOptions) + let cache = CostUsageScanner.codexCache( + CostUsageCacheIO.load(provider: .codex, cacheRoot: scanOptions.cacheRoot), + scopedTo: roots) + let range = CostUsageScanner.CostUsageDayRange(since: since, until: until) projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( cache: cache, - range: CostUsageScanner.CostUsageDayRange(since: since, until: until), + range: range, modelsDevCacheRoot: scanOptions.cacheRoot) + sessions = CostUsageScanner.buildCodexSessionBreakdownsFromCache( + cache: cache, + range: range, + modelsDevCacheRoot: scanOptions.cacheRoot, + sessionRoots: roots) } - if includePiSessions, provider == .codex || provider == .claude { + if includePiSessions, provider == .claude || (provider == .codex && shouldMergePiUsage) { let piReport = try PiSessionCostScanner.loadDailyReportCancellable( provider: provider, since: since, @@ -274,11 +298,15 @@ public struct CostUsageFetcher: Sendable { if provider == .codex { projects = Self.mergedProjectBreakdowns( projects + [piDaily.flatMap(Self.unknownProjectBreakdown(from:))].compactMap(\.self)) + if piDaily?.data.isEmpty == false { + sessions = [] + } } - return (daily: daily, projects: projects) + return (daily: daily, projects: projects, sessions: sessions) } - if retryUnknownPricing, + if allowPricingRefresh, + retryUnknownPricing, let request = Self.unknownPricingRefreshRequest( provider: provider, daily: scanResult.daily, @@ -295,6 +323,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: allowVertexClaudeFallback, codexHomePath: codexHomePath, historyDays: historyDays, + allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: false, includePiSessions: includePiSessions, scannerOptions: options, @@ -307,7 +336,35 @@ public struct CostUsageFetcher: Sendable { from: scanResult.daily, now: now, historyDays: clampedHistoryDays, - projects: scanResult.projects) + projects: scanResult.projects, + sessions: scanResult.sessions) + } + + private struct PricingRefreshOptions: Sendable { + let provider: UsageProvider + let isAllowed: Bool + let retryUnknown: Bool + let inBackground: Bool + } + + private static func refreshPricingIfAllowed( + options: PricingRefreshOptions, + now: Date, + cacheRoot: URL?, + client: ModelsDevClient) async + { + guard options.isAllowed, + options.retryUnknown, + options.provider == .codex || options.provider == .claude + else { return } + + if options.inBackground { + Task.detached(priority: .utility) { + await ModelsDevPricingPipeline.refreshIfNeeded(now: now, cacheRoot: cacheRoot, client: client) + } + } else { + await ModelsDevPricingPipeline.refreshIfNeeded(now: now, cacheRoot: cacheRoot, client: client) + } } private struct UnknownPricingRefreshRequest: Sendable { @@ -404,9 +461,13 @@ public struct CostUsageFetcher: Sendable { let since = Calendar.current.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now let range = CostUsageScanner.CostUsageDayRange(since: since, until: until) let options = overrideScannerOptions ?? CostUsageScanner.Options() - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot) + let roots = CostUsageScanner.codexSessionsRoots(options: options) + let cache = CostUsageScanner.codexCache( + CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot), + scopedTo: roots) var reports: [CostUsageDailyReport] = [] var projects: [CostUsageProjectBreakdown] = [] + var sessions: [CostUsageSessionBreakdown] = [] // Raw inputs for the derived result fields below: the native cache's own scan // time, every constituent scan time, and whether a second source joined the merge. var nativeScanAt: Date? @@ -428,6 +489,11 @@ public struct CostUsageFetcher: Sendable { nativeScanAt = scanAt scanTimes.append(scanAt) } + sessions = CostUsageScanner.buildCodexSessionBreakdownsFromCache( + cache: cache, + range: range, + modelsDevCacheRoot: options.cacheRoot, + sessionRoots: roots) if cache.codexProjectMetadataVersion == CostUsageScanner.codexProjectMetadataVersion { projects.append(contentsOf: CostUsageScanner.buildCodexProjectBreakdownsFromCache( cache: cache, @@ -452,6 +518,9 @@ public struct CostUsageFetcher: Sendable { if let piProject = Self.unknownProjectBreakdown(from: piResult.report) { projects.append(piProject) } + if !piResult.report.data.isEmpty { + sessions = [] + } } guard !reports.isEmpty else { return nil } @@ -465,6 +534,7 @@ public struct CostUsageFetcher: Sendable { now: now, historyDays: clampedHistoryDays, projects: Self.mergedProjectBreakdowns(projects), + sessions: sessions, updatedAt: scanTimes.min()), lastRefreshAt: piMerged ? nil : nativeScanAt) } @@ -490,6 +560,7 @@ public struct CostUsageFetcher: Sendable { historyDays: Int = 30, useCurrentLocalDayForSession: Bool = true, projects: [CostUsageProjectBreakdown] = [], + sessions: [CostUsageSessionBreakdown] = [], updatedAt: Date? = nil) -> CostUsageTokenSnapshot { let sessionEntry = useCurrentLocalDayForSession @@ -526,6 +597,7 @@ public struct CostUsageFetcher: Sendable { historyDays: historyDays, daily: daily.data, projects: projects, + sessions: sessions, updatedAt: updatedAt ?? now) } diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 61ba9c3333..09da6f1a65 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -22,6 +22,46 @@ public struct CostUsageWindowSummary: Sendable, Equatable { } } +/// An estimated local Codex conversation total derived from one session log. +/// This is intentionally distinct from account-level billing or quota data. +public struct CostUsageSessionBreakdown: Sendable, Equatable, Identifiable { + public let sessionID: String + public let lastActivity: Date + public let inputTokens: Int? + public let cachedInputTokens: Int? + public let outputTokens: Int? + public let totalTokens: Int? + public let requestCount: Int? + public let costUSD: Double? + public let modelBreakdowns: [CostUsageDailyReport.ModelBreakdown] + + public var id: String { + self.sessionID + } + + public init( + sessionID: String, + lastActivity: Date, + inputTokens: Int?, + cachedInputTokens: Int?, + outputTokens: Int?, + totalTokens: Int?, + requestCount: Int?, + costUSD: Double?, + modelBreakdowns: [CostUsageDailyReport.ModelBreakdown]) + { + self.sessionID = sessionID + self.lastActivity = lastActivity + self.inputTokens = inputTokens + self.cachedInputTokens = cachedInputTokens + self.outputTokens = outputTokens + self.totalTokens = totalTokens + self.requestCount = requestCount + self.costUSD = costUSD + self.modelBreakdowns = modelBreakdowns + } +} + public struct CostUsageTokenSnapshot: Sendable, Equatable { public let sessionTokens: Int? public let sessionCostUSD: Double? @@ -35,6 +75,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { public let historyLabel: String? public let daily: [CostUsageDailyReport.Entry] public let projects: [CostUsageProjectBreakdown] + public let sessions: [CostUsageSessionBreakdown] public let updatedAt: Date public init( @@ -50,6 +91,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { historyLabel: String? = nil, daily: [CostUsageDailyReport.Entry], projects: [CostUsageProjectBreakdown] = [], + sessions: [CostUsageSessionBreakdown] = [], updatedAt: Date) { self.sessionTokens = sessionTokens @@ -65,6 +107,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { self.historyLabel = historyLabel self.daily = daily self.projects = projects + self.sessions = sessions self.updatedAt = updatedAt } @@ -109,13 +152,19 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { return (entry, date) } .max { lhs, rhs in - if lhs.date != rhs.date { return lhs.date < rhs.date } + if lhs.date != rhs.date { + return lhs.date < rhs.date + } let lCost = lhs.entry.costUSD ?? -1 let rCost = rhs.entry.costUSD ?? -1 - if lCost != rCost { return lCost < rCost } + if lCost != rCost { + return lCost < rCost + } let lTokens = lhs.entry.totalTokens ?? -1 let rTokens = rhs.entry.totalTokens ?? -1 - if lTokens != rTokens { return lTokens < rTokens } + if lTokens != rTokens { + return lTokens < rTokens + } return lhs.entry.date < rhs.entry.date }?.entry } @@ -128,7 +177,9 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { let dayKey = CostUsageLocalDay.key(from: date, calendar: calendar) return entries.first { entry in let rawDate = entry.date.trimmingCharacters(in: .whitespacesAndNewlines) - if rawDate == dayKey { return true } + if rawDate == dayKey { + return true + } guard let parsed = CostUsageDateParser.parse(rawDate) else { return false } return CostUsageLocalDay.key(from: parsed, calendar: calendar) == dayKey } @@ -352,8 +403,12 @@ public struct CostUsageDailyReport: Sendable, Decodable { (try? container.decodeIfPresent([String].self, forKey: key)).flatMap(\.self) } - if let modelsUsed = decodeStringList(.modelsUsed) { return modelsUsed } - if let models = decodeStringList(.models) { return models } + if let modelsUsed = decodeStringList(.modelsUsed) { + return modelsUsed + } + if let models = decodeStringList(.models) { + return models + } guard container.contains(.models) else { return nil } @@ -918,16 +973,22 @@ enum CostUsageDateParser { key: self.isoWithFractionalSecondsKey, options: [.withInternetDateTime, .withFractionalSeconds]) .date(from: trimmed) - { return d } + { + return d + } if let d = self.isoFormatter(key: self.isoInternetDateTimeKey, options: [.withInternetDateTime]) .date(from: trimmed) - { return d } + { + return d + } if let d = self.dateFormatter(key: self.dayFormatterKey, format: "yyyy-MM-dd").date(from: trimmed) { return d } if let d = self.dateFormatter(key: self.monthDayYearFormatterKey, format: "MMM d, yyyy") .date(from: trimmed) - { return d } + { + return d + } return nil } diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 807ef3013d..34c88ec382 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 = "8be945d1c2519e9b" + static let value = "8a1e1d7e87bcbe2d" } diff --git a/Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift index 2afe1dd9a8..ebc1c6125e 100644 --- a/Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift @@ -31,7 +31,12 @@ public enum AbacusProviderDescriptor { branding: ProviderBranding( iconStyle: .abacus, iconResourceName: "ProviderIcon-abacus", - color: ProviderColor(red: 56 / 255, green: 189 / 255, blue: 248 / 255)), + color: ProviderColor(red: 56 / 255, green: 189 / 255, blue: 248 / 255), + confettiPalette: [ + ProviderColor(hex: 0x35BEE2), + ProviderColor(hex: 0xC64AF9), + ProviderColor(hex: 0xFFFFFF), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Abacus AI cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/AiAnd/AiAndProviderDescriptor.swift b/Sources/CodexBarCore/Providers/AiAnd/AiAndProviderDescriptor.swift new file mode 100644 index 0000000000..a5c9e92afa --- /dev/null +++ b/Sources/CodexBarCore/Providers/AiAnd/AiAndProviderDescriptor.swift @@ -0,0 +1,66 @@ +import Foundation + +public enum AiAndProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .aiand, + metadata: ProviderMetadata( + id: .aiand, + displayName: "ai&", + sessionLabel: "Spend", + weeklyLabel: "Spend", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show ai& usage", + cliName: "aiand", + defaultEnabled: false, + dashboardURL: "https://console.aiand.com", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .aiand, + iconResourceName: "ProviderIcon-aiand", + color: ProviderColor(red: 226 / 255, green: 92 / 255, blue: 43 / 255), + confettiPalette: [ + ProviderColor(hex: 0xE25C2B), + ProviderColor(hex: 0xF2A17E), + ProviderColor(hex: 0x33231C), + ]), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "ai& spend is reported by the analytics summary API." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [AiAndAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "aiand", + aliases: ["ai&", "ai-and"], + versionDetector: nil)) + } +} + +struct AiAndAPIFetchStrategy: ProviderFetchStrategy { + let id = "aiand.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + AiAndSettingsReader.apiKey(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let credential = AiAndSettingsReader.apiKey(environment: context.env) else { + throw AiAndUsageError.notConfigured + } + let usage = try await AiAndUsageFetcher.fetchUsage(credential) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Sources/CodexBarCore/Providers/AiAnd/AiAndSettingsReader.swift b/Sources/CodexBarCore/Providers/AiAnd/AiAndSettingsReader.swift new file mode 100644 index 0000000000..8f0cc3cfd3 --- /dev/null +++ b/Sources/CodexBarCore/Providers/AiAnd/AiAndSettingsReader.swift @@ -0,0 +1,24 @@ +import Foundation + +public enum AiAndSettingsReader { + public static let apiKeyEnvironmentKey = "AIAND_API_KEY" + + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.apiKeyEnvironmentKey]) + } + + 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/AiAnd/AiAndUsageFetcher.swift b/Sources/CodexBarCore/Providers/AiAnd/AiAndUsageFetcher.swift new file mode 100644 index 0000000000..43e1b329e1 --- /dev/null +++ b/Sources/CodexBarCore/Providers/AiAnd/AiAndUsageFetcher.swift @@ -0,0 +1,117 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum AiAndUsageError: LocalizedError, Sendable, Equatable { + case notConfigured + case invalidAPIKey + case insufficientCredits + case rateLimited + case apiError(Int) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .notConfigured: + "Missing ai& API key. Add one in Settings or set AIAND_API_KEY." + case .invalidAPIKey: + "ai& rejected the API key. Create a new key at console.aiand.com and update Settings." + case .insufficientCredits: + "ai& reports the organization is out of credits. Top up at console.aiand.com." + case .rateLimited: + "ai& rate limit exceeded. Usage will refresh on the next cycle." + case let .apiError(statusCode): + "ai& analytics API returned HTTP \(statusCode)." + case let .parseFailed(message): + "Could not parse ai& usage: \(message)" + } + } +} + +public struct AiAndUsageSnapshot: Sendable, Equatable { + public let last30DaysCostUSD: Double + public let updatedAt: Date + + public init(last30DaysCostUSD: Double, updatedAt: Date) { + self.last30DaysCostUSD = last30DaysCostUSD + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + // ai& is prepaid with no quota windows; spend is the only documented usage + // signal, so no RateWindows are synthesized. limit 0 means "no cap". + UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: self.last30DaysCostUSD, + limit: 0, + currencyCode: "USD", + period: "Last 30 days", + updatedAt: self.updatedAt), + updatedAt: self.updatedAt, + identity: nil, + dataConfidence: .exact) + } +} + +public enum AiAndUsageFetcher { + static let summaryURL = URL(string: "https://api.aiand.com/analytics/summary?range=30days")! + private static let requestTimeoutSeconds: TimeInterval = 15 + + public static func fetchUsage( + _ rawCredential: String, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + now: Date = Date()) async throws -> AiAndUsageSnapshot + { + guard let credential = AiAndSettingsReader.cleaned(rawCredential) else { + throw AiAndUsageError.notConfigured + } + var request = URLRequest(url: self.summaryURL) + request.httpMethod = "GET" + request.timeoutInterval = self.requestTimeoutSeconds + request.setValue("Bearer \(credential)", 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) + } + return try self.parseSummary(response.data, now: now) + } + + private static func parseSummary(_ data: Data, now: Date) throws -> AiAndUsageSnapshot { + let summary: SummaryPayload + do { + summary = try JSONDecoder().decode(SummaryPayload.self, from: data) + } catch { + throw AiAndUsageError.parseFailed(error.localizedDescription) + } + guard summary.costUSD.isFinite else { + throw AiAndUsageError.parseFailed("cost_usd is not a finite number") + } + return AiAndUsageSnapshot(last30DaysCostUSD: summary.costUSD, updatedAt: now) + } + + private static func error(statusCode: Int) -> AiAndUsageError { + switch statusCode { + case 401: + .invalidAPIKey + case 402: + .insufficientCredits + case 429: + .rateLimited + default: + .apiError(statusCode) + } + } +} + +private struct SummaryPayload: Decodable { + let costUSD: Double + + enum CodingKeys: String, CodingKey { + case costUSD = "cost_usd" + } +} diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanProviderDescriptor.swift index 39586646b3..c32d43f3f7 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanProviderDescriptor.swift @@ -45,7 +45,12 @@ public enum AlibabaCodingPlanProviderDescriptor { branding: ProviderBranding( iconStyle: .alibaba, iconResourceName: "ProviderIcon-alibaba", - color: ProviderColor(red: 1.0, green: 106 / 255, blue: 0)), + color: ProviderColor(red: 1.0, green: 106 / 255, blue: 0), + confettiPalette: [ + ProviderColor(hex: 0xFF6A00), + ProviderColor(hex: 0x111111), + ProviderColor(hex: 0xFFFFFF), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Alibaba Coding Plan cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift index 6b5012a91c..ddf7970adc 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift @@ -45,7 +45,12 @@ public enum AlibabaTokenPlanProviderDescriptor { branding: ProviderBranding( iconStyle: .alibaba, iconResourceName: "ProviderIcon-alibaba", - color: ProviderColor(red: 1.0, green: 106 / 255, blue: 0)), + color: ProviderColor(red: 1.0, green: 106 / 255, blue: 0), + confettiPalette: [ + ProviderColor(hex: 0xFF6A00), + ProviderColor(hex: 0x0064C8), + ProviderColor(hex: 0xFFFFFF), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Alibaba Token Plan cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Amp/AmpProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Amp/AmpProviderDescriptor.swift index 91ecef4ac4..8adaf8a94a 100644 --- a/Sources/CodexBarCore/Providers/Amp/AmpProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Amp/AmpProviderDescriptor.swift @@ -26,7 +26,12 @@ public enum AmpProviderDescriptor { branding: ProviderBranding( iconStyle: .amp, iconResourceName: "ProviderIcon-amp", - color: ProviderColor(red: 220 / 255, green: 38 / 255, blue: 38 / 255)), + color: ProviderColor(red: 220 / 255, green: 38 / 255, blue: 38 / 255), + confettiPalette: [ + ProviderColor(hex: 0x091C1E), + ProviderColor(hex: 0xDFDFC1), + ProviderColor(hex: 0xD97706), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Amp cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift index 10b89cd5ee..db210b6069 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift @@ -27,7 +27,12 @@ public enum AntigravityProviderDescriptor { branding: ProviderBranding( iconStyle: .antigravity, iconResourceName: "ProviderIcon-antigravity", - color: ProviderColor(red: 96 / 255, green: 186 / 255, blue: 126 / 255)), + color: ProviderColor(red: 96 / 255, green: 186 / 255, blue: 126 / 255), + confettiPalette: [ + ProviderColor(hex: 0x4285F4), + ProviderColor(hex: 0x34A853), + ProviderColor(hex: 0xFBBC04), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Antigravity cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Augment/AugmentProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Augment/AugmentProviderDescriptor.swift index 529bd39cc5..8f23d48a85 100644 --- a/Sources/CodexBarCore/Providers/Augment/AugmentProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Augment/AugmentProviderDescriptor.swift @@ -50,7 +50,12 @@ public enum AugmentProviderDescriptor { branding: ProviderBranding( iconStyle: .augment, iconResourceName: "ProviderIcon-augment", - color: ProviderColor(red: 99 / 255, green: 102 / 255, blue: 241 / 255)), + color: ProviderColor(red: 99 / 255, green: 102 / 255, blue: 241 / 255), + confettiPalette: [ + ProviderColor(hex: 0xF97316), + ProviderColor(hex: 0x111111), + ProviderColor(hex: 0xFFF7ED), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Augment cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/AzureOpenAI/AzureOpenAIProviderDescriptor.swift b/Sources/CodexBarCore/Providers/AzureOpenAI/AzureOpenAIProviderDescriptor.swift index 52859faeca..9f375d12af 100644 --- a/Sources/CodexBarCore/Providers/AzureOpenAI/AzureOpenAIProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/AzureOpenAI/AzureOpenAIProviderDescriptor.swift @@ -27,7 +27,12 @@ public enum AzureOpenAIProviderDescriptor { branding: ProviderBranding( iconStyle: .openai, iconResourceName: "ProviderIcon-codex", - color: ProviderColor(red: 0, green: 120 / 255, blue: 212 / 255)), + color: ProviderColor(red: 0, green: 120 / 255, blue: 212 / 255), + confettiPalette: [ + ProviderColor(hex: 0x0078D4), + ProviderColor(hex: 0x50E6FF), + ProviderColor(hex: 0xFFFFFF), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Azure OpenAI usage history is not exposed by the deployment validation probe." }), diff --git a/Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift index d0e3310edb..701a729968 100644 --- a/Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift @@ -26,7 +26,12 @@ public enum BedrockProviderDescriptor { branding: ProviderBranding( iconStyle: .bedrock, iconResourceName: "ProviderIcon-bedrock", - color: ProviderColor(red: 1, green: 0.6, blue: 0)), + color: ProviderColor(red: 1, green: 0.6, blue: 0), + confettiPalette: [ + ProviderColor(hex: 0x01A88D), + ProviderColor(hex: 0x232F3E), + ProviderColor(hex: 0xFF9900), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: true, noDataMessage: { "No AWS Bedrock cost data available. Check your AWS access keys " diff --git a/Sources/CodexBarCore/Providers/Chutes/ChutesProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Chutes/ChutesProviderDescriptor.swift index 55ff94d0d6..5bb80a9f24 100644 --- a/Sources/CodexBarCore/Providers/Chutes/ChutesProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Chutes/ChutesProviderDescriptor.swift @@ -26,7 +26,12 @@ public enum ChutesProviderDescriptor { branding: ProviderBranding( iconStyle: .chutes, iconResourceName: "ProviderIcon-chutes", - color: ProviderColor(red: 49 / 255, green: 132 / 255, blue: 255 / 255)), + color: ProviderColor(red: 49 / 255, green: 132 / 255, blue: 255 / 255), + confettiPalette: [ + ProviderColor(hex: 0x121212), + ProviderColor(hex: 0xFFFFFF), + ProviderColor(hex: 0x63D297), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Chutes cost history is not available from CodexBar." }), diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 05cf1a46ca..1ae78675fe 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -28,7 +28,12 @@ public enum ClaudeProviderDescriptor { branding: ProviderBranding( iconStyle: .claude, iconResourceName: "ProviderIcon-claude", - color: ProviderColor(red: 204 / 255, green: 124 / 255, blue: 94 / 255)), + color: ProviderColor(red: 204 / 255, green: 124 / 255, blue: 94 / 255), + confettiPalette: [ + ProviderColor(hex: 0xD97757), + ProviderColor(hex: 0xF0EEE6), + ProviderColor(hex: 0x141413), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: true, noDataMessage: self.noDataMessage), diff --git a/Sources/CodexBarCore/Providers/ClawRouter/ClawRouterProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ClawRouter/ClawRouterProviderDescriptor.swift index 2b52905250..4019f25ddf 100644 --- a/Sources/CodexBarCore/Providers/ClawRouter/ClawRouterProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ClawRouter/ClawRouterProviderDescriptor.swift @@ -23,7 +23,12 @@ public enum ClawRouterProviderDescriptor { branding: ProviderBranding( iconStyle: .clawrouter, iconResourceName: "ProviderIcon-clawrouter", - color: ProviderColor(red: 89 / 255, green: 110 / 255, blue: 246 / 255)), + color: ProviderColor(red: 89 / 255, green: 110 / 255, blue: 246 / 255), + confettiPalette: [ + ProviderColor(hex: 0x332CB3), + ProviderColor(hex: 0x456FDD), + ProviderColor(hex: 0xFFFFFF), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "ClawRouter spend is reported by its usage API." }), diff --git a/Sources/CodexBarCore/Providers/ClinePass/ClinePassProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ClinePass/ClinePassProviderDescriptor.swift index 38b00391e3..033279e0cb 100644 --- a/Sources/CodexBarCore/Providers/ClinePass/ClinePassProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ClinePass/ClinePassProviderDescriptor.swift @@ -27,7 +27,12 @@ public enum ClinePassProviderDescriptor { branding: ProviderBranding( iconStyle: .clinepass, iconResourceName: "ProviderIcon-clinepass", - color: ProviderColor(red: 0.38, green: 0.64, blue: 0.98)), + color: ProviderColor(red: 0.38, green: 0.64, blue: 0.98), + confettiPalette: [ + ProviderColor(hex: 0x61A3FA), + ProviderColor(hex: 0x111111), + ProviderColor(hex: 0xFFFFFF), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "ClinePass cost history is not available via the usage-limits API." }), diff --git a/Sources/CodexBarCore/Providers/Codebuff/CodebuffProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codebuff/CodebuffProviderDescriptor.swift index 6ea5634e90..5f75610f9b 100644 --- a/Sources/CodexBarCore/Providers/Codebuff/CodebuffProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codebuff/CodebuffProviderDescriptor.swift @@ -27,7 +27,12 @@ public enum CodebuffProviderDescriptor { branding: ProviderBranding( iconStyle: .codebuff, iconResourceName: "ProviderIcon-codebuff", - color: ProviderColor(red: 68 / 255, green: 255 / 255, blue: 0 / 255)), + color: ProviderColor(red: 68 / 255, green: 255 / 255, blue: 0 / 255), + confettiPalette: [ + ProviderColor(hex: 0x9EFC62), + ProviderColor(hex: 0xFFFFFF), + ProviderColor(hex: 0x000000), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Codebuff cost summary is not yet supported." }), diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift index 3dabff36de..e6fa71b4e7 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift @@ -28,7 +28,12 @@ public enum CodexProviderDescriptor { branding: ProviderBranding( iconStyle: .codex, iconResourceName: "ProviderIcon-codex", - color: ProviderColor(red: 73 / 255, green: 163 / 255, blue: 176 / 255)), + color: ProviderColor(red: 73 / 255, green: 163 / 255, blue: 176 / 255), + confettiPalette: [ + ProviderColor(hex: 0x736BD4), + ProviderColor(hex: 0x97A9F7), + ProviderColor(hex: 0xCFD4F7), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: true, noDataMessage: self.noDataMessage), diff --git a/Sources/CodexBarCore/Providers/CommandCode/CommandCodeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/CommandCode/CommandCodeProviderDescriptor.swift index 24cee03e35..f8fd869ae5 100644 --- a/Sources/CodexBarCore/Providers/CommandCode/CommandCodeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/CommandCode/CommandCodeProviderDescriptor.swift @@ -28,7 +28,12 @@ public enum CommandCodeProviderDescriptor { branding: ProviderBranding( iconStyle: .commandcode, iconResourceName: "ProviderIcon-commandcode", - color: ProviderColor(red: 0 / 255, green: 0 / 255, blue: 0 / 255)), + color: ProviderColor(red: 0 / 255, green: 0 / 255, blue: 0 / 255), + confettiPalette: [ + ProviderColor(hex: 0x000000), + ProviderColor(hex: 0xFFFFFF), + ProviderColor(hex: 0x7B5BFF), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Command Code cost summary is not yet supported." }), diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift index 5a3b1f39c4..f42ec5a115 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift @@ -26,7 +26,12 @@ public enum CopilotProviderDescriptor { branding: ProviderBranding( iconStyle: .copilot, iconResourceName: "ProviderIcon-copilot", - color: ProviderColor(red: 168 / 255, green: 85 / 255, blue: 247 / 255)), + color: ProviderColor(red: 168 / 255, green: 85 / 255, blue: 247 / 255), + confettiPalette: [ + ProviderColor(hex: 0x8534F3), + ProviderColor(hex: 0xF08A3A), + ProviderColor(hex: 0xC898FD), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Copilot cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Crof/CrofProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Crof/CrofProviderDescriptor.swift index 860733de98..825044b116 100644 --- a/Sources/CodexBarCore/Providers/Crof/CrofProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Crof/CrofProviderDescriptor.swift @@ -27,7 +27,12 @@ public enum CrofProviderDescriptor { branding: ProviderBranding( iconStyle: .crof, iconResourceName: "ProviderIcon-crof", - color: ProviderColor(red: 0.18, green: 0.67, blue: 0.58)), + color: ProviderColor(red: 0.18, green: 0.67, blue: 0.58), + confettiPalette: [ + ProviderColor(hex: 0x0A0A0A), + ProviderColor(hex: 0x8B7CFF), + ProviderColor(hex: 0xA99FFF), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Crof cost summary is not available via API." }), diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift index 983cae2dc9..0a11544e24 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift @@ -28,7 +28,11 @@ public enum CursorProviderDescriptor { branding: ProviderBranding( iconStyle: .cursor, iconResourceName: "ProviderIcon-cursor", - color: ProviderColor(red: 0 / 255, green: 191 / 255, blue: 165 / 255)), + color: ProviderColor(red: 0 / 255, green: 191 / 255, blue: 165 / 255), + confettiPalette: [ + ProviderColor(hex: 0x1B1913), + ProviderColor(hex: 0xEDECEC), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Cursor cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift index d50b7cc90b..4b5b3e0281 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift @@ -64,7 +64,12 @@ public enum DeepSeekProviderDescriptor { branding: ProviderBranding( iconStyle: .deepseek, iconResourceName: "ProviderIcon-deepseek", - color: ProviderColor(red: 0.32, green: 0.49, blue: 0.94)), + color: ProviderColor(red: 0.32, green: 0.49, blue: 0.94), + confettiPalette: [ + ProviderColor(hex: 0x4D6BFE), + ProviderColor(hex: 0x3982FF), + ProviderColor(hex: 0x020E36), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "DeepSeek per-day cost history is not available via API." }), diff --git a/Sources/CodexBarCore/Providers/Deepgram/DeepgramProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Deepgram/DeepgramProviderDescriptor.swift index 5e77cb49ce..e99941971d 100644 --- a/Sources/CodexBarCore/Providers/Deepgram/DeepgramProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Deepgram/DeepgramProviderDescriptor.swift @@ -30,7 +30,12 @@ public enum DeepgramProviderDescriptor { color: ProviderColor( red: 100 / 255, green: 103 / 255, - blue: 242 / 255)), + blue: 242 / 255), + confettiPalette: [ + ProviderColor(hex: 0x13EF95), + ProviderColor(hex: 0x149AFB), + ProviderColor(hex: 0x1A1A1F), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { diff --git a/Sources/CodexBarCore/Providers/Devin/DevinProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Devin/DevinProviderDescriptor.swift index 8cdcd55064..80ca592199 100644 --- a/Sources/CodexBarCore/Providers/Devin/DevinProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Devin/DevinProviderDescriptor.swift @@ -27,7 +27,12 @@ public enum DevinProviderDescriptor { branding: ProviderBranding( iconStyle: .devin, iconResourceName: "ProviderIcon-devin", - color: ProviderColor(red: 70 / 255, green: 180 / 255, blue: 130 / 255)), + color: ProviderColor(red: 70 / 255, green: 180 / 255, blue: 130 / 255), + confettiPalette: [ + ProviderColor(hex: 0x000000), + ProviderColor(hex: 0x626870), + ProviderColor(hex: 0xFFFFFF), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Devin cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift index 386c039be6..7c16d36a56 100644 --- a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift @@ -35,7 +35,12 @@ public enum DoubaoProviderDescriptor { branding: ProviderBranding( iconStyle: .doubao, iconResourceName: "ProviderIcon-doubao", - color: ProviderColor(red: 51 / 255, green: 112 / 255, blue: 255 / 255)), + color: ProviderColor(red: 51 / 255, green: 112 / 255, blue: 255 / 255), + confettiPalette: [ + ProviderColor(hex: 0x0057FF), + ProviderColor(hex: 0xEFC5BA), + ProviderColor(hex: 0x493530), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Doubao cost summary is not available." }), diff --git a/Sources/CodexBarCore/Providers/ElevenLabs/ElevenLabsProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ElevenLabs/ElevenLabsProviderDescriptor.swift index 1981614adc..bddc58c71c 100644 --- a/Sources/CodexBarCore/Providers/ElevenLabs/ElevenLabsProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ElevenLabs/ElevenLabsProviderDescriptor.swift @@ -28,7 +28,12 @@ public enum ElevenLabsProviderDescriptor { branding: ProviderBranding( iconStyle: .elevenlabs, iconResourceName: "ProviderIcon-elevenlabs", - color: ProviderColor(red: 0.92, green: 0.92, blue: 0.90)), + color: ProviderColor(red: 0.92, green: 0.92, blue: 0.90), + confettiPalette: [ + ProviderColor(hex: 0x000000), + ProviderColor(hex: 0x808080), + ProviderColor(hex: 0xFDFCFC), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "ElevenLabs cost history is not available via API yet." }), diff --git a/Sources/CodexBarCore/Providers/Factory/FactoryProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Factory/FactoryProviderDescriptor.swift index f3f389288a..88bbccd65c 100644 --- a/Sources/CodexBarCore/Providers/Factory/FactoryProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Factory/FactoryProviderDescriptor.swift @@ -27,7 +27,12 @@ public enum FactoryProviderDescriptor { branding: ProviderBranding( iconStyle: .factory, iconResourceName: "ProviderIcon-factory", - color: ProviderColor(red: 255 / 255, green: 107 / 255, blue: 53 / 255)), + color: ProviderColor(red: 255 / 255, green: 107 / 255, blue: 53 / 255), + confettiPalette: [ + ProviderColor(hex: 0xEE6018), + ProviderColor(hex: 0xA0CA92), + ProviderColor(hex: 0x1D1A18), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Droid cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift index 322ad1f14a..4cb101eb3f 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift @@ -28,7 +28,12 @@ public enum GeminiProviderDescriptor { branding: ProviderBranding( iconStyle: .gemini, iconResourceName: "ProviderIcon-gemini", - color: ProviderColor(red: 171 / 255, green: 135 / 255, blue: 234 / 255)), + color: ProviderColor(red: 171 / 255, green: 135 / 255, blue: 234 / 255), + confettiPalette: [ + ProviderColor(hex: 0x4285F4), + ProviderColor(hex: 0xA142F4), + ProviderColor(hex: 0xD96570), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Gemini cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift index 02379d308c..709f3a28e8 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift @@ -28,7 +28,12 @@ public enum GrokProviderDescriptor { branding: ProviderBranding( iconStyle: .grok, iconResourceName: "ProviderIcon-grok", - color: ProviderColor(red: 16 / 255, green: 163 / 255, blue: 127 / 255)), + color: ProviderColor(red: 16 / 255, green: 163 / 255, blue: 127 / 255), + confettiPalette: [ + ProviderColor(hex: 0x000000), + ProviderColor(hex: 0x868686), + ProviderColor(hex: 0xFDFDFD), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Grok cost summary is not supported yet." }), diff --git a/Sources/CodexBarCore/Providers/Groq/GroqProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Groq/GroqProviderDescriptor.swift index 6e6171f42d..fbc6dace91 100644 --- a/Sources/CodexBarCore/Providers/Groq/GroqProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Groq/GroqProviderDescriptor.swift @@ -27,7 +27,12 @@ public enum GroqProviderDescriptor { branding: ProviderBranding( iconStyle: .groq, iconResourceName: "ProviderIcon-groq", - color: ProviderColor(red: 245 / 255, green: 104 / 255, blue: 68 / 255)), + color: ProviderColor(red: 245 / 255, green: 104 / 255, blue: 68 / 255), + confettiPalette: [ + ProviderColor(hex: 0xF43E01), + ProviderColor(hex: 0xFFFFFF), + ProviderColor(hex: 0x97FCA7), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Sign in at console.groq.com to show Groq spend and token usage." }), diff --git a/Sources/CodexBarCore/Providers/JetBrains/JetBrainsProviderDescriptor.swift b/Sources/CodexBarCore/Providers/JetBrains/JetBrainsProviderDescriptor.swift index 3b31b1164b..ef7a39c12c 100644 --- a/Sources/CodexBarCore/Providers/JetBrains/JetBrainsProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/JetBrains/JetBrainsProviderDescriptor.swift @@ -25,7 +25,12 @@ public enum JetBrainsProviderDescriptor { branding: ProviderBranding( iconStyle: .jetbrains, iconResourceName: "ProviderIcon-jetbrains", - color: ProviderColor(red: 255 / 255, green: 51 / 255, blue: 153 / 255)), + color: ProviderColor(red: 255 / 255, green: 51 / 255, blue: 153 / 255), + confettiPalette: [ + ProviderColor(hex: 0x6B57FF), + ProviderColor(hex: 0x21D789), + ProviderColor(hex: 0x000000), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "JetBrains AI cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift index 0fd75324ff..ab8392a26b 100644 --- a/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift @@ -26,7 +26,12 @@ public enum KiloProviderDescriptor { branding: ProviderBranding( iconStyle: .kilo, iconResourceName: "ProviderIcon-kilo", - color: ProviderColor(red: 242 / 255, green: 112 / 255, blue: 39 / 255)), + color: ProviderColor(red: 242 / 255, green: 112 / 255, blue: 39 / 255), + confettiPalette: [ + ProviderColor(hex: 0xFA483A), + ProviderColor(hex: 0xAC1D0E), + ProviderColor(hex: 0x121212), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Kilo cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift index 5daaa3923c..14d93764e8 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift @@ -26,7 +26,12 @@ public enum KimiProviderDescriptor { branding: ProviderBranding( iconStyle: .kimi, iconResourceName: "ProviderIcon-kimi", - color: ProviderColor(red: 254 / 255, green: 96 / 255, blue: 60 / 255)), + color: ProviderColor(red: 254 / 255, green: 96 / 255, blue: 60 / 255), + confettiPalette: [ + ProviderColor(hex: 0x000000), + ProviderColor(hex: 0x4E6EF2), + ProviderColor(hex: 0xFFFFFF), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Kimi cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Kiro/KiroProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Kiro/KiroProviderDescriptor.swift index 804b346c08..6e0304a4f3 100644 --- a/Sources/CodexBarCore/Providers/Kiro/KiroProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Kiro/KiroProviderDescriptor.swift @@ -26,7 +26,12 @@ public enum KiroProviderDescriptor { branding: ProviderBranding( iconStyle: .kiro, iconResourceName: "ProviderIcon-kiro", - color: ProviderColor(red: 255 / 255, green: 153 / 255, blue: 0 / 255)), + color: ProviderColor(red: 255 / 255, green: 153 / 255, blue: 0 / 255), + confettiPalette: [ + ProviderColor(hex: 0x8F4AFF), + ProviderColor(hex: 0xCAA9FF), + ProviderColor(hex: 0x2B2B2B), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Kiro cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/LLMProxy/LLMProxyProviderDescriptor.swift b/Sources/CodexBarCore/Providers/LLMProxy/LLMProxyProviderDescriptor.swift index 29091b78f3..4f77c5e5d2 100644 --- a/Sources/CodexBarCore/Providers/LLMProxy/LLMProxyProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/LLMProxy/LLMProxyProviderDescriptor.swift @@ -26,7 +26,12 @@ public enum LLMProxyProviderDescriptor { branding: ProviderBranding( iconStyle: .llmproxy, iconResourceName: "ProviderIcon-llmproxy", - color: ProviderColor(red: 36 / 255, green: 180 / 255, blue: 126 / 255)), + color: ProviderColor(red: 36 / 255, green: 180 / 255, blue: 126 / 255), + confettiPalette: [ + ProviderColor(hex: 0x00FFFF), + ProviderColor(hex: 0xFFFFFF), + ProviderColor(hex: 0x000000), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "LLM Proxy cost history is reported in the quota-stats summary." }), diff --git a/Sources/CodexBarCore/Providers/LiteLLM/LiteLLMProviderDescriptor.swift b/Sources/CodexBarCore/Providers/LiteLLM/LiteLLMProviderDescriptor.swift index 1360749c16..dcf4744ec2 100644 --- a/Sources/CodexBarCore/Providers/LiteLLM/LiteLLMProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/LiteLLM/LiteLLMProviderDescriptor.swift @@ -25,7 +25,12 @@ public enum LiteLLMProviderDescriptor { branding: ProviderBranding( iconStyle: .litellm, iconResourceName: "ProviderIcon-litellm", - color: ProviderColor(red: 76 / 255, green: 137 / 255, blue: 240 / 255)), + color: ProviderColor(red: 76 / 255, green: 137 / 255, blue: 240 / 255), + confettiPalette: [ + ProviderColor(hex: 0x191938), + ProviderColor(hex: 0x8258F2), + ProviderColor(hex: 0xC5B9F6), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "LiteLLM spend is reported by the provider API." }), diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift index d7afe3bb62..83daf5d220 100644 --- a/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift @@ -26,7 +26,12 @@ public enum LongCatProviderDescriptor { branding: ProviderBranding( iconStyle: .longcat, iconResourceName: "ProviderIcon-longcat", - color: ProviderColor(red: 255 / 255, green: 209 / 255, blue: 0 / 255)), + color: ProviderColor(red: 255 / 255, green: 209 / 255, blue: 0 / 255), + confettiPalette: [ + ProviderColor(hex: 0xFFD100), + ProviderColor(hex: 0x111111), + ProviderColor(hex: 0xFFFFFF), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "LongCat cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Manus/ManusProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Manus/ManusProviderDescriptor.swift index d3ee45796a..514c5057a3 100644 --- a/Sources/CodexBarCore/Providers/Manus/ManusProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Manus/ManusProviderDescriptor.swift @@ -26,7 +26,12 @@ public enum ManusProviderDescriptor { branding: ProviderBranding( iconStyle: .manus, iconResourceName: "ProviderIcon-manus", - color: ProviderColor(red: 52 / 255, green: 50 / 255, blue: 45 / 255)), + color: ProviderColor(red: 52 / 255, green: 50 / 255, blue: 45 / 255), + confettiPalette: [ + ProviderColor(hex: 0x34322D), + ProviderColor(hex: 0xF2F0E9), + ProviderColor(hex: 0x0099FF), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Manus cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift index 9ceab352a7..416c81de12 100644 --- a/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift @@ -26,7 +26,11 @@ public enum MiMoProviderDescriptor { branding: ProviderBranding( iconStyle: .mimo, iconResourceName: "ProviderIcon-mimo", - color: ProviderColor(red: 1.0, green: 105 / 255, blue: 0)), + color: ProviderColor(red: 1.0, green: 105 / 255, blue: 0), + confettiPalette: [ + ProviderColor(hex: 0x3D3834), + ProviderColor(hex: 0x736B68), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Xiaomi MiMo cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift index fab28f9f9e..4d02782100 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift @@ -26,7 +26,12 @@ public enum MiniMaxProviderDescriptor { branding: ProviderBranding( iconStyle: .minimax, iconResourceName: "ProviderIcon-minimax", - color: ProviderColor(red: 254 / 255, green: 96 / 255, blue: 60 / 255)), + color: ProviderColor(red: 254 / 255, green: 96 / 255, blue: 60 / 255), + confettiPalette: [ + ProviderColor(hex: 0x181E25), + ProviderColor(hex: 0x86909C), + ProviderColor(hex: 0xF7F8FA), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "MiniMax cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift index b31dfc193b..c97c4797aa 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift @@ -27,7 +27,12 @@ public enum MistralProviderDescriptor { branding: ProviderBranding( iconStyle: .mistral, iconResourceName: "ProviderIcon-mistral", - color: ProviderColor(red: 255 / 255, green: 80 / 255, blue: 15 / 255)), + color: ProviderColor(red: 255 / 255, green: 80 / 255, blue: 15 / 255), + confettiPalette: [ + ProviderColor(hex: 0xFA500F), + ProviderColor(hex: 0xFFAF01), + ProviderColor(hex: 0xFFE000), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: true, noDataMessage: { "Mistral cost history needs a billing web session." }), diff --git a/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift index a5552e2a8d..fc18f20391 100644 --- a/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift @@ -26,7 +26,12 @@ public enum MoonshotProviderDescriptor { branding: ProviderBranding( iconStyle: .kimi, iconResourceName: "ProviderIcon-kimi", - color: ProviderColor(red: 32 / 255, green: 93 / 255, blue: 235 / 255)), + color: ProviderColor(red: 32 / 255, green: 93 / 255, blue: 235 / 255), + confettiPalette: [ + ProviderColor(hex: 0x121212), + ProviderColor(hex: 0x305140), + ProviderColor(hex: 0x9F9F9F), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Moonshot / Kimi API cost summary is not available." }), diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift index 16097ac553..df315adfdb 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift @@ -26,7 +26,11 @@ public enum OllamaProviderDescriptor { branding: ProviderBranding( iconStyle: .ollama, iconResourceName: "ProviderIcon-ollama", - color: ProviderColor(red: 136 / 255, green: 136 / 255, blue: 136 / 255)), + color: ProviderColor(red: 136 / 255, green: 136 / 255, blue: 136 / 255), + confettiPalette: [ + ProviderColor(hex: 0x000000), + ProviderColor(hex: 0xFFFFFF), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Ollama cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIProviderDescriptor.swift index d2a5e6f151..ebe68f562a 100644 --- a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIProviderDescriptor.swift @@ -25,7 +25,12 @@ public enum OpenAIAPIProviderDescriptor { branding: ProviderBranding( iconStyle: .openai, iconResourceName: "ProviderIcon-codex", - color: ProviderColor(red: 0.06, green: 0.51, blue: 0.43)), + color: ProviderColor(red: 0.06, green: 0.51, blue: 0.43), + confettiPalette: [ + ProviderColor(hex: 0x000000), + ProviderColor(hex: 0x808080), + ProviderColor(hex: 0xFFFFFF), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: true, noDataMessage: { "OpenAI usage needs an Admin API key for organization usage." }), diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift index ae4e72a68c..54883af5d4 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift @@ -26,7 +26,12 @@ public enum OpenCodeProviderDescriptor { branding: ProviderBranding( iconStyle: .opencode, iconResourceName: "ProviderIcon-opencode", - color: ProviderColor(red: 59 / 255, green: 130 / 255, blue: 246 / 255)), + color: ProviderColor(red: 59 / 255, green: 130 / 255, blue: 246 / 255), + confettiPalette: [ + ProviderColor(hex: 0x211E1E), + ProviderColor(hex: 0xCFCECD), + ProviderColor(hex: 0xFAB283), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "OpenCode cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift index b3306b67be..303d1ee2b3 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift @@ -26,7 +26,12 @@ public enum OpenCodeGoProviderDescriptor { branding: ProviderBranding( iconStyle: .opencodego, iconResourceName: "ProviderIcon-opencodego", - color: ProviderColor(red: 59 / 255, green: 130 / 255, blue: 246 / 255)), + color: ProviderColor(red: 59 / 255, green: 130 / 255, blue: 246 / 255), + confettiPalette: [ + ProviderColor(hex: 0x211E1E), + ProviderColor(hex: 0xA3BE8C), + ProviderColor(hex: 0xCFCECD), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "OpenCode Go cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift index 7cc05ef485..271241e342 100644 --- a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift @@ -26,7 +26,12 @@ public enum OpenRouterProviderDescriptor { branding: ProviderBranding( iconStyle: .openrouter, iconResourceName: "ProviderIcon-openrouter", - color: ProviderColor(red: 100 / 255, green: 103 / 255, blue: 242 / 255)), + color: ProviderColor(red: 100 / 255, green: 103 / 255, blue: 242 / 255), + confettiPalette: [ + ProviderColor(hex: 0x96A5B9), + ProviderColor(hex: 0x161616), + ProviderColor(hex: 0xFFFFFF), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "OpenRouter cost summary is not yet supported." }), diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift index 2700549054..a6364ed72a 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift @@ -27,7 +27,12 @@ public enum PerplexityProviderDescriptor { branding: ProviderBranding( iconStyle: .perplexity, iconResourceName: "ProviderIcon-perplexity", - color: ProviderColor(red: 32 / 255, green: 178 / 255, blue: 170 / 255)), + color: ProviderColor(red: 32 / 255, green: 178 / 255, blue: 170 / 255), + confettiPalette: [ + ProviderColor(hex: 0x016A71), + ProviderColor(hex: 0x313131), + ProviderColor(hex: 0xFDFBFA), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Perplexity cost tracking is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Poe/PoeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Poe/PoeProviderDescriptor.swift index d3b4feda14..6ee9836004 100644 --- a/Sources/CodexBarCore/Providers/Poe/PoeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Poe/PoeProviderDescriptor.swift @@ -27,7 +27,12 @@ public enum PoeProviderDescriptor { branding: ProviderBranding( iconStyle: .poe, iconResourceName: "ProviderIcon-poe", - color: ProviderColor(red: 93 / 255, green: 92 / 255, blue: 222 / 255)), + color: ProviderColor(red: 93 / 255, green: 92 / 255, blue: 222 / 255), + confettiPalette: [ + ProviderColor(hex: 0x5D5CDE), + ProviderColor(hex: 0x2A2AA2), + ProviderColor(hex: 0xE051ED), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Poe usage history is unavailable." }), diff --git a/Sources/CodexBarCore/Providers/ProviderBranding.swift b/Sources/CodexBarCore/Providers/ProviderBranding.swift index 2502f0d0c0..9d76dafedf 100644 --- a/Sources/CodexBarCore/Providers/ProviderBranding.swift +++ b/Sources/CodexBarCore/Providers/ProviderBranding.swift @@ -10,16 +10,42 @@ public struct ProviderColor: Sendable, Equatable { self.green = green self.blue = blue } + + public init(hex: UInt32) { + precondition(hex <= 0xFFFFFF, "Provider colors must use a six-digit RGB hex value.") + self.red = Double((hex >> 16) & 0xFF) / 255 + self.green = Double((hex >> 8) & 0xFF) / 255 + self.blue = Double(hex & 0xFF) / 255 + } } public struct ProviderBranding: Sendable { public let iconStyle: IconStyle public let iconResourceName: String public let color: ProviderColor + public let confettiPalette: [ProviderColor] + /// Source-compatible fallback for external CodexBarCore clients. Registered descriptors must provide + /// a curated palette; registry tests reject this duplicated-color fallback. + @available(*, deprecated, message: "Provide a curated 2–3-color confettiPalette.") public init(iconStyle: IconStyle, iconResourceName: String, color: ProviderColor) { + self.init( + iconStyle: iconStyle, + iconResourceName: iconResourceName, + color: color, + confettiPalette: [color, color]) + } + + public init( + iconStyle: IconStyle, + iconResourceName: String, + color: ProviderColor, + confettiPalette: [ProviderColor]) + { + precondition((2...3).contains(confettiPalette.count), "Provider confetti palettes require 2–3 colors.") self.iconStyle = iconStyle self.iconResourceName = iconResourceName self.color = color + self.confettiPalette = confettiPalette } } diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 071f3851d7..0aef74dadd 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -113,6 +113,7 @@ public enum ProviderDescriptorRegistry { .sub2api: Sub2APIProviderDescriptor.descriptor, .wayfinder: WayfinderProviderDescriptor.descriptor, .zenmux: ZenMuxProviderDescriptor.descriptor, + .aiand: AiAndProviderDescriptor.descriptor, ] private static let bootstrap: Void = { for provider in UsageProvider.allCases { diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index c2450968cf..f00c9a6ed0 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -63,6 +63,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case sub2api case wayfinder case zenmux + case aiand } // swiftformat:enable sortDeclarations @@ -126,6 +127,7 @@ public enum IconStyle: String, Sendable, CaseIterable { case sub2api case wayfinder case zenmux + case aiand case combined } diff --git a/Sources/CodexBarCore/Providers/Qoder/QoderProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Qoder/QoderProviderDescriptor.swift index 1272cd4e86..8829eab84c 100644 --- a/Sources/CodexBarCore/Providers/Qoder/QoderProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Qoder/QoderProviderDescriptor.swift @@ -27,7 +27,12 @@ public enum QoderProviderDescriptor { branding: ProviderBranding( iconStyle: .qoder, iconResourceName: "ProviderIcon-qoder", - color: ProviderColor(red: 16 / 255, green: 185 / 255, blue: 129 / 255)), + color: ProviderColor(red: 16 / 255, green: 185 / 255, blue: 129 / 255), + confettiPalette: [ + ProviderColor(hex: 0x2ADB5C), + ProviderColor(hex: 0x111113), + ProviderColor(hex: 0xFFFFFF), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Qoder cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Sakana/SakanaProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Sakana/SakanaProviderDescriptor.swift index aa711d57f7..3212b2ea04 100644 --- a/Sources/CodexBarCore/Providers/Sakana/SakanaProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Sakana/SakanaProviderDescriptor.swift @@ -26,7 +26,12 @@ public enum SakanaProviderDescriptor { branding: ProviderBranding( iconStyle: .sakana, iconResourceName: "ProviderIcon-sakana", - color: ProviderColor(red: 0.16, green: 0.46, blue: 0.86)), + color: ProviderColor(red: 0.16, green: 0.46, blue: 0.86), + confettiPalette: [ + ProviderColor(hex: 0xE10600), + ProviderColor(hex: 0x0D0D0D), + ProviderColor(hex: 0xFFFFFF), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Sakana AI cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift index 1cdac836fa..69c389c3a7 100644 --- a/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift @@ -27,7 +27,12 @@ public enum StepFunProviderDescriptor { branding: ProviderBranding( iconStyle: .stepfun, iconResourceName: "ProviderIcon-stepfun", - color: ProviderColor(red: 0.13, green: 0.59, blue: 0.95)), + color: ProviderColor(red: 0.13, green: 0.59, blue: 0.95), + confettiPalette: [ + ProviderColor(hex: 0x000000), + ProviderColor(hex: 0xFFFFFF), + ProviderColor(hex: 0x858585), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "StepFun per-day cost history is not available via API." }), diff --git a/Sources/CodexBarCore/Providers/Sub2API/Sub2APIProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Sub2API/Sub2APIProviderDescriptor.swift index 122899e39d..14d0f970a6 100644 --- a/Sources/CodexBarCore/Providers/Sub2API/Sub2APIProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Sub2API/Sub2APIProviderDescriptor.swift @@ -24,7 +24,12 @@ public enum Sub2APIProviderDescriptor { branding: ProviderBranding( iconStyle: .sub2api, iconResourceName: "ProviderIcon-sub2api", - color: ProviderColor(red: 45 / 255, green: 198 / 255, blue: 216 / 255)), + color: ProviderColor(red: 45 / 255, green: 198 / 255, blue: 216 / 255), + confettiPalette: [ + ProviderColor(hex: 0x1F62FF), + ProviderColor(hex: 0x60EDF6), + ProviderColor(hex: 0x74F9B0), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "sub2api spend is reported by its usage API." }), diff --git a/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift index 84ad7806a2..f5881ec3a1 100644 --- a/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift @@ -25,7 +25,12 @@ public enum SyntheticProviderDescriptor { branding: ProviderBranding( iconStyle: .synthetic, iconResourceName: "ProviderIcon-synthetic", - color: ProviderColor(red: 20 / 255, green: 20 / 255, blue: 20 / 255)), + color: ProviderColor(red: 20 / 255, green: 20 / 255, blue: 20 / 255), + confettiPalette: [ + ProviderColor(hex: 0x6366F1), + ProviderColor(hex: 0x3E3E3E), + ProviderColor(hex: 0xF7F6F3), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Synthetic cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/T3Chat/T3ChatProviderDescriptor.swift b/Sources/CodexBarCore/Providers/T3Chat/T3ChatProviderDescriptor.swift index d06649fc4b..2ec8762aa6 100644 --- a/Sources/CodexBarCore/Providers/T3Chat/T3ChatProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/T3Chat/T3ChatProviderDescriptor.swift @@ -27,7 +27,12 @@ public enum T3ChatProviderDescriptor { branding: ProviderBranding( iconStyle: .t3chat, iconResourceName: "ProviderIcon-t3chat", - color: ProviderColor(red: 245 / 255, green: 102 / 255, blue: 71 / 255)), + color: ProviderColor(red: 245 / 255, green: 102 / 255, blue: 71 / 255), + confettiPalette: [ + ProviderColor(hex: 0x970B72), + ProviderColor(hex: 0xE6229C), + ProviderColor(hex: 0xFEA0F6), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "T3 Chat cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Venice/VeniceProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Venice/VeniceProviderDescriptor.swift index d080293003..08383155f2 100644 --- a/Sources/CodexBarCore/Providers/Venice/VeniceProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Venice/VeniceProviderDescriptor.swift @@ -27,7 +27,12 @@ public enum VeniceProviderDescriptor { branding: ProviderBranding( iconStyle: .venice, iconResourceName: "ProviderIcon-venice", - color: ProviderColor(red: 0.2, green: 0.6, blue: 1.0)), + color: ProviderColor(red: 0.2, green: 0.6, blue: 1.0), + confettiPalette: [ + ProviderColor(hex: 0x0E2942), + ProviderColor(hex: 0xF7F5ED), + ProviderColor(hex: 0x3C8FDD), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Venice per-day cost history is not available via API." }), diff --git a/Sources/CodexBarCore/Providers/VertexAI/VertexAIProviderDescriptor.swift b/Sources/CodexBarCore/Providers/VertexAI/VertexAIProviderDescriptor.swift index f076951afd..9b4e66efaf 100644 --- a/Sources/CodexBarCore/Providers/VertexAI/VertexAIProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/VertexAI/VertexAIProviderDescriptor.swift @@ -26,7 +26,12 @@ public enum VertexAIProviderDescriptor { branding: ProviderBranding( iconStyle: .vertexai, iconResourceName: "ProviderIcon-vertexai", - color: ProviderColor(red: 66 / 255, green: 133 / 255, blue: 244 / 255)), + color: ProviderColor(red: 66 / 255, green: 133 / 255, blue: 244 / 255), + confettiPalette: [ + ProviderColor(hex: 0x4285F4), + ProviderColor(hex: 0xEA4335), + ProviderColor(hex: 0xFBBC04), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: true, noDataMessage: { "No Vertex AI cost data found in Claude logs. Ensure entries include Vertex metadata." diff --git a/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift index d089134224..924b981a11 100644 --- a/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift @@ -26,7 +26,12 @@ public enum WarpProviderDescriptor { branding: ProviderBranding( iconStyle: .warp, iconResourceName: "ProviderIcon-warp", - color: ProviderColor(red: 147 / 255, green: 139 / 255, blue: 180 / 255)), + color: ProviderColor(red: 147 / 255, green: 139 / 255, blue: 180 / 255), + confettiPalette: [ + ProviderColor(hex: 0xC7AEFF), + ProviderColor(hex: 0x1C1A26), + ProviderColor(hex: 0xFFFFFF), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Warp cost summary is not available." }), diff --git a/Sources/CodexBarCore/Providers/Wayfinder/WayfinderProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Wayfinder/WayfinderProviderDescriptor.swift index c57e1758e7..f7322abdc9 100644 --- a/Sources/CodexBarCore/Providers/Wayfinder/WayfinderProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Wayfinder/WayfinderProviderDescriptor.swift @@ -23,7 +23,12 @@ public enum WayfinderProviderDescriptor { branding: ProviderBranding( iconStyle: .wayfinder, iconResourceName: "ProviderIcon-wayfinder", - color: ProviderColor(red: 16 / 255, green: 163 / 255, blue: 127 / 255)), + color: ProviderColor(red: 16 / 255, green: 163 / 255, blue: 127 / 255), + confettiPalette: [ + ProviderColor(hex: 0x10A37F), + ProviderColor(hex: 0xBD6A13), + ProviderColor(hex: 0x0D0D0D), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Wayfinder savings are reported by its local gateway." }), diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift index facf988ca1..050426377d 100644 --- a/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift @@ -25,7 +25,12 @@ public enum WindsurfProviderDescriptor { branding: ProviderBranding( iconStyle: .windsurf, iconResourceName: "ProviderIcon-windsurf", - color: ProviderColor(red: 52 / 255, green: 232 / 255, blue: 187 / 255)), + color: ProviderColor(red: 52 / 255, green: 232 / 255, blue: 187 / 255), + confettiPalette: [ + ProviderColor(hex: 0x000000), + ProviderColor(hex: 0x09B6A2), + ProviderColor(hex: 0x34E8BB), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Windsurf cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift index 2352663677..13995d248e 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift @@ -25,7 +25,12 @@ public enum ZaiProviderDescriptor { branding: ProviderBranding( iconStyle: .zai, iconResourceName: "ProviderIcon-zai", - color: ProviderColor(red: 232 / 255, green: 90 / 255, blue: 106 / 255)), + color: ProviderColor(red: 232 / 255, green: 90 / 255, blue: 106 / 255), + confettiPalette: [ + ProviderColor(hex: 0x126EF6), + ProviderColor(hex: 0x2D2D2D), + ProviderColor(hex: 0xDFE2E7), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "z.ai cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Zed/ZedProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Zed/ZedProviderDescriptor.swift index e6f56cb66e..30a2403787 100644 --- a/Sources/CodexBarCore/Providers/Zed/ZedProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Zed/ZedProviderDescriptor.swift @@ -25,7 +25,12 @@ public enum ZedProviderDescriptor { branding: ProviderBranding( iconStyle: .zed, iconResourceName: "ProviderIcon-zed", - color: ProviderColor(red: 8 / 255, green: 78 / 255, blue: 255 / 255)), + color: ProviderColor(red: 8 / 255, green: 78 / 255, blue: 255 / 255), + confettiPalette: [ + ProviderColor(hex: 0x084CCF), + ProviderColor(hex: 0x000000), + ProviderColor(hex: 0xFFFFFF), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Zed cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/ZenMux/ZenMuxProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ZenMux/ZenMuxProviderDescriptor.swift index d08bb4457a..3781fc7585 100644 --- a/Sources/CodexBarCore/Providers/ZenMux/ZenMuxProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ZenMux/ZenMuxProviderDescriptor.swift @@ -23,7 +23,12 @@ public enum ZenMuxProviderDescriptor { branding: ProviderBranding( iconStyle: .zenmux, iconResourceName: "ProviderIcon-zenmux", - color: ProviderColor(red: 108 / 255, green: 92 / 255, blue: 231 / 255)), + color: ProviderColor(red: 108 / 255, green: 92 / 255, blue: 231 / 255), + confettiPalette: [ + ProviderColor(hex: 0x6C5CE7), + ProviderColor(hex: 0xA29BFE), + ProviderColor(hex: 0xFFFFFF), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "ZenMux cost history is not exposed by the Management API." }), diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift index b6b1efee0f..5e79e86c07 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift @@ -1,6 +1,83 @@ import Foundation extension CostUsageScanner { + static func codexCache(_ cache: CostUsageCache, scopedTo roots: [URL]) -> CostUsageCache { + var scoped = cache + scoped.files = cache.files.filter { filePath, _ in + Self.isWithinCodexRoots(fileURL: URL(fileURLWithPath: filePath), roots: roots) + } + scoped.days = [:] + for usage in scoped.files.values { + Self.applyFileDays(cache: &scoped, fileDays: usage.days, sign: 1) + } + return scoped + } + + static func buildCodexSessionBreakdownsFromCache( + cache: CostUsageCache, + range: CostUsageDayRange, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil, + sessionRoots: [URL]? = nil, + priorityTurns: [String: CodexPriorityTurnMetadata] = [:], + modelsDevCatalogLoader: (URL?) -> ModelsDevCatalog? = { + CostUsagePricing.modelsDevCatalog(cacheRoot: $0) + }) -> [CostUsageSessionBreakdown] + { + let resolvedModelsDevCatalog = modelsDevCatalog + ?? modelsDevCatalogLoader(modelsDevCacheRoot) + ?? ModelsDevCatalog(providers: [:]) + var latestFileBySessionID: [String: (path: String, usage: CostUsageFileUsage)] = [:] + + for (filePath, usage) in cache.files { + if let sessionRoots, + !Self.isWithinCodexRoots(fileURL: URL(fileURLWithPath: filePath), roots: sessionRoots) + { + continue + } + guard usage.touchesCodexScanWindow(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) else { + continue + } + let sessionID = usage.sessionId ?? URL(fileURLWithPath: filePath).deletingPathExtension().lastPathComponent + guard !sessionID.isEmpty else { continue } + if let existing = latestFileBySessionID[sessionID], existing.usage.mtimeUnixMs >= usage.mtimeUnixMs { + continue + } + latestFileBySessionID[sessionID] = (filePath, usage) + } + + return latestFileBySessionID.compactMap { sessionID, file in + var fileCache = CostUsageCache() + fileCache.files[file.path] = file.usage + fileCache.days = file.usage.days + let report = Self.buildCodexReportFromCache( + cache: fileCache, + range: range, + modelsDevCatalog: resolvedModelsDevCatalog, + priorityTurns: priorityTurns) + guard !report.data.isEmpty else { return nil } + + let summary = report.summary + let requestCounts = report.data.compactMap(\.requestCount) + return CostUsageSessionBreakdown( + sessionID: sessionID, + lastActivity: Date(timeIntervalSince1970: TimeInterval(file.usage.mtimeUnixMs) / 1000), + inputTokens: summary?.totalInputTokens, + cachedInputTokens: summary?.cacheReadTokens, + outputTokens: summary?.totalOutputTokens, + totalTokens: summary?.totalTokens, + requestCount: requestCounts.isEmpty ? nil : requestCounts.reduce(0, +), + costUSD: summary?.totalCostUSD, + modelBreakdowns: Self.codexProjectModelBreakdowns(from: report.data) ?? []) + } + .sorted { lhs, rhs in + if lhs.lastActivity != rhs.lastActivity { + return lhs.lastActivity > rhs.lastActivity + } + return lhs.sessionID > rhs.sessionID + } + } + static func buildCodexProjectBreakdownsFromCache( cache: CostUsageCache, range: CostUsageDayRange, @@ -55,10 +132,14 @@ extension CostUsageScanner { .sorted { lhs, rhs in let lhsCost = lhs.totalCostUSD ?? -1 let rhsCost = rhs.totalCostUSD ?? -1 - if lhsCost != rhsCost { return lhsCost > rhsCost } + if lhsCost != rhsCost { + return lhsCost > rhsCost + } let lhsTokens = lhs.totalTokens ?? -1 let rhsTokens = rhs.totalTokens ?? -1 - if lhsTokens != rhsTokens { return lhsTokens > rhsTokens } + if lhsTokens != rhsTokens { + return lhsTokens > rhsTokens + } return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending } } @@ -96,10 +177,14 @@ extension CostUsageScanner { .sorted { lhs, rhs in let lhsCost = lhs.totalCostUSD ?? -1 let rhsCost = rhs.totalCostUSD ?? -1 - if lhsCost != rhsCost { return lhsCost > rhsCost } + if lhsCost != rhsCost { + return lhsCost > rhsCost + } let lhsTokens = lhs.totalTokens ?? -1 let rhsTokens = rhs.totalTokens ?? -1 - if lhsTokens != rhsTokens { return lhsTokens > rhsTokens } + if lhsTokens != rhsTokens { + return lhsTokens > rhsTokens + } return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 7981668fa7..106ead4654 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -830,7 +830,7 @@ enum CostUsageScanner { .ollama, .t3chat, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .sakana, .abacus, .mistral, .deepseek, .codebuff, .crof, .windsurf, .zed, .venice, .commandcode, .qoder, .stepfun, .bedrock, .grok, .groq, .llmproxy, .litellm, .deepgram, .poe, .chutes, .clawrouter, - .longcat, .sub2api, .wayfinder, .zenmux: + .longcat, .sub2api, .wayfinder, .zenmux, .aiand: return emptyReport } } @@ -885,7 +885,7 @@ enum CostUsageScanner { .appendingPathComponent("sessions", isDirectory: true) } - private static func codexSessionsRoots(options: Options) -> [URL] { + static func codexSessionsRoots(options: Options) -> [URL] { let root = self.defaultCodexSessionsRoot(options: options) if let archived = self.codexArchivedSessionsRoot(sessionsRoot: root) { return [root, archived] @@ -1183,7 +1183,7 @@ enum CostUsageScanner { return out } - private static func isWithinCodexRoots(fileURL: URL, roots: [URL]) -> Bool { + static func isWithinCodexRoots(fileURL: URL, roots: [URL]) -> Bool { let filePath = fileURL.standardizedFileURL.path return roots.contains { root in let rootPath = root.standardizedFileURL.path diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 006c1bb3f3..94234b4dd8 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -126,6 +126,7 @@ enum ProviderChoice: String, AppEnum { case .longcat: return nil // LongCat not yet supported in widgets case .zed: return nil // Zed 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 } } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index b7b8a9a234..07946c1b88 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -361,6 +361,7 @@ private struct ProviderSwitchChip: View { case .longcat: "LongCat" case .zed: "Zed" case .zenmux: "ZenMux" + case .aiand: "ai&" } } } @@ -1103,6 +1104,8 @@ enum WidgetColors { Color(red: 64 / 255, green: 156 / 255, blue: 255 / 255) case .zenmux: Color(red: 108 / 255, green: 92 / 255, blue: 231 / 255) + case .aiand: + Color(red: 226 / 255, green: 92 / 255, blue: 43 / 255) } } } diff --git a/Tests/CodexBarTests/AiAndProviderTests.swift b/Tests/CodexBarTests/AiAndProviderTests.swift new file mode 100644 index 0000000000..656f6845cb --- /dev/null +++ b/Tests/CodexBarTests/AiAndProviderTests.swift @@ -0,0 +1,233 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct AiAndProviderTests { + @Test + func `summary maps to a 30-day USD spend snapshot without rate windows`() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(request.httpMethod == "GET") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-test-fixture") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(url.absoluteString == "https://api.aiand.com/analytics/summary?range=30days") + #expect(url.scheme == "https") + #expect(url.host == "api.aiand.com") + #expect(url.query == "range=30days") + #expect(url.user == nil) + #expect(url.password == nil) + #expect(url.fragment == nil) + return Self.response(url: url, body: Self.summaryFixture) + } + + let usage = try await AiAndUsageFetcher.fetchUsage( + "sk-test-fixture", + transport: transport, + now: now) + let snapshot = usage.toUsageSnapshot() + + #expect(usage.last30DaysCostUSD == 42.18) + #expect(snapshot.primary == nil) + #expect(snapshot.secondary == nil) + #expect(snapshot.tertiary == nil) + #expect(snapshot.extraRateWindows == nil) + #expect(snapshot.providerCost?.used == 42.18) + #expect(snapshot.providerCost?.limit == 0) + #expect(snapshot.providerCost?.currencyCode == "USD") + #expect(snapshot.providerCost?.period == "Last 30 days") + #expect(snapshot.identity == nil) + #expect(snapshot.dataConfidence == .exact) + #expect(snapshot.updatedAt == now) + } + + @Test + func `credential is only sent as a bearer header`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + return Self.response(url: url, body: Self.summaryFixture) + } + + _ = try await AiAndUsageFetcher.fetchUsage("sk-test-fixture", transport: transport) + + let request = try #require(await transport.requests().first) + let url = try #require(request.url) + #expect(!url.absoluteString.contains("sk-test-fixture")) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-test-fixture") + } + + @Test + func `invalid api key maps to an actionable error`() async { + let transport = Self.errorTransport(statusCode: 401, code: "invalid_api_key") + + await #expect { + _ = try await AiAndUsageFetcher.fetchUsage("sk-wrong", transport: transport) + } throws: { error in + error as? AiAndUsageError == .invalidAPIKey + } + #expect(AiAndUsageError.invalidAPIKey.errorDescription?.contains("console.aiand.com") == true) + } + + @Test + func `insufficient credits maps to an actionable error`() async { + let transport = Self.errorTransport(statusCode: 402, code: "insufficient_credits") + + await #expect { + _ = try await AiAndUsageFetcher.fetchUsage("sk-test-fixture", transport: transport) + } throws: { error in + error as? AiAndUsageError == .insufficientCredits + } + #expect(AiAndUsageError.insufficientCredits.errorDescription?.contains("credits") == true) + } + + @Test + func `rate limit is surfaced politely`() async { + let transport = Self.errorTransport(statusCode: 429, code: "rate_limit_exceeded") + + await #expect { + _ = try await AiAndUsageFetcher.fetchUsage("sk-test-fixture", transport: transport) + } throws: { error in + error as? AiAndUsageError == .rateLimited + } + } + + @Test + func `unexpected status is reported with its code`() async { + let transport = Self.errorTransport(statusCode: 500, code: "internal_error") + + await #expect { + _ = try await AiAndUsageFetcher.fetchUsage("sk-test-fixture", transport: transport) + } throws: { error in + error as? AiAndUsageError == .apiError(500) + } + } + + @Test + func `missing or whitespace credential fails clearly`() async { + await #expect { + _ = try await AiAndUsageFetcher.fetchUsage(" ") + } throws: { error in + error as? AiAndUsageError == .notConfigured + } + } + + @Test + func `malformed summary payload fails parsing`() async { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + return Self.response(url: url, body: #"{"range":"30days"}"#) + } + + await #expect { + _ = try await AiAndUsageFetcher.fetchUsage("sk-test-fixture", transport: transport) + } throws: { error in + guard case .parseFailed = error as? AiAndUsageError else { return false } + return true + } + } + + @Test + func `settings reader trims whitespace and quotes`() { + #expect(AiAndSettingsReader.apiKey(environment: [ + AiAndSettingsReader.apiKeyEnvironmentKey: " 'sk-test-fixture' ", + ]) == "sk-test-fixture") + #expect(AiAndSettingsReader.apiKey(environment: [:]) == nil) + #expect(AiAndSettingsReader.apiKey(environment: [ + AiAndSettingsReader.apiKeyEnvironmentKey: " ", + ]) == nil) + } + + @Test @MainActor + func `descriptor and app registry include aiand`() throws { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .aiand) + #expect(descriptor.metadata.displayName == "ai&") + #expect(descriptor.metadata.cliName == "aiand") + #expect(descriptor.metadata.defaultEnabled == false) + #expect(!descriptor.metadata.supportsCredits) + #expect(!descriptor.tokenCost.supportsTokenCost) + #expect(descriptor.fetchPlan.sourceModes == [.auto, .api]) + #expect(descriptor.cli.aliases == ["ai&", "ai-and"]) + + let implementation = try #require(ProviderImplementationRegistry.implementation(for: .aiand)) + #expect(implementation is AiAndProviderImplementation) + } + + @Test @MainActor + func `menu card renders spend through the generic API-spend path`() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + return Self.response(url: url, body: Self.summaryFixture) + } + let usage = try await AiAndUsageFetcher.fetchUsage( + "sk-test-fixture", + transport: transport, + now: now) + let model = UsageMenuCardView.Model.make(.init( + provider: .aiand, + metadata: AiAndProviderDescriptor.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 == "API spend") + #expect(model.providerCost?.spendLine == "Last 30 days: $42.18") + #expect(model.providerCost?.percentUsed == nil) + #expect(model.providerCost?.percentLine == nil) + } + + /// Sanitized from the documented /analytics/summary example (docs.aiand.com/analytics/summary/). + private static let summaryFixture = #""" + { + "range": "30days", + "from": "2026-06-17T00:00:00Z", + "to": "2026-07-17T00:00:00Z", + "requests": 87432, + "input_tokens": 12345678, + "output_tokens": 234567, + "cost_usd": 42.18, + "errors": 312, + "p50_latency_ms": 410, + "p95_latency_ms": 1820 + } + """# + + private static func errorTransport(statusCode: Int, code: String) -> ProviderHTTPTransportStub { + ProviderHTTPTransportStub { request in + let url = try #require(request.url) + let body = #""" + {"error":{"message":"fixture error","type":"fixture","param":null,"code":"\#(code)"}} + """# + return Self.response(url: url, body: body, statusCode: statusCode) + } + } + + private 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/Tests/CodexBarTests/CodexLocalSessionCostSettingsTests.swift b/Tests/CodexBarTests/CodexLocalSessionCostSettingsTests.swift new file mode 100644 index 0000000000..3b86be3845 --- /dev/null +++ b/Tests/CodexBarTests/CodexLocalSessionCostSettingsTests.swift @@ -0,0 +1,139 @@ +import CodexBarCore +import Foundation +import SwiftUI +import Testing +@testable import CodexBar + +@MainActor +struct CodexLocalSessionCostSettingsTests { + @Test + func `codex exposes usage and cookie pickers`() throws { + let fixture = try self.makeSettingsFixture(suite: "CodexLocalSessionCostSettingsTests-codex") + let context = fixture.settingsContext(provider: .codex) + + let pickers = CodexProviderImplementation().settingsPickers(context: context) + let toggles = CodexProviderImplementation().settingsToggles(context: context) + #expect(pickers.contains(where: { $0.id == "codex-usage-source" })) + let usagePicker = try #require(pickers.first(where: { $0.id == "codex-usage-source" })) + #expect(usagePicker.title == "Quota usage source") + #expect(usagePicker.subtitle.contains("Local session cost estimates work independently")) + let cookiePicker = try #require(pickers.first(where: { $0.id == "codex-cookie-source" })) + #expect(cookiePicker.placement == .connection) + let localLedgerToggle = try #require(toggles.first(where: { $0.id == "codex-local-session-cost-ledger" })) + #expect(localLedgerToggle.title == "Local session cost estimates") + #expect(localLedgerToggle.subtitle.contains("organization API keys")) + #expect(!localLedgerToggle.binding.wrappedValue) + localLedgerToggle.binding.wrappedValue = true + #expect(fixture.settings.codexLocalSessionCostLedgerEnabled) + #expect(!fixture.settings.costUsageEnabled) + #expect(fixture.settings.isCostUsageEffectivelyEnabled(for: .codex)) + #expect(!fixture.settings.isCostUsageEffectivelyEnabled(for: .claude)) + #expect(toggles.contains(where: { $0.id == "codex-historical-tracking" })) + let sparkToggle = try #require(toggles.first(where: { $0.id == "codex-spark-usage-visible" })) + #expect(sparkToggle.title == "Show Codex Spark usage") + #expect(sparkToggle.subtitle.contains("menu and provider preview")) + #expect(sparkToggle.binding.wrappedValue) + #expect(sparkToggle.isEnabled?() == true) + + sparkToggle.binding.wrappedValue = false + #expect(fixture.settings.codexSparkUsageVisible == false) + + fixture.settings.showOptionalCreditsAndExtraUsage = false + #expect(sparkToggle.isEnabled?() == false) + } + + @Test + func `codex local ledger ignores the managed account home`() throws { + let fixture = try self.makeSettingsFixture(suite: "CodexLocalSessionCostSettingsTests-local-ledger") + fixture.settings._test_activeManagedCodexRemoteHomePath = "/tmp/managed-codex-home" + fixture.settings.codexActiveSource = .managedAccount(id: UUID()) + defer { fixture.settings._test_activeManagedCodexRemoteHomePath = nil } + + let managedScope = fixture.store.tokenCostScope(for: .codex) + fixture.settings.codexLocalSessionCostLedgerEnabled = true + let localScope = fixture.store.tokenCostScope(for: .codex) + + #expect(managedScope.codexHomePath == "/tmp/managed-codex-home") + #expect(managedScope.signature == "codex:managed:/tmp/managed-codex-home") + #expect(localScope.codexHomePath == nil) + #expect(localScope.signature == "codex:ambient") + } + + @Test + func `unresolved managed cost scope never falls back to ambient sessions`() throws { + let fixture = try self.makeSettingsFixture(suite: "CodexLocalSessionCostSettingsTests-managed-unresolved") + let accountID = UUID() + fixture.settings.codexActiveSource = .managedAccount(id: accountID) + + let scope = fixture.store.tokenCostScope(for: .codex) + + #expect(scope.codexHomePath != nil) + #expect(scope.signature != "codex:ambient") + #expect(scope.signature.hasPrefix("codex:managed:")) + } + + private func makeSettingsFixture(suite: String) throws -> Fixture { + let defaults = try #require(UserDefaults(suiteName: suite)) + 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) + return Fixture(settings: settings, store: store) + } + + private struct Fixture { + let settings: SettingsStore + let store: UsageStore + private let state = ProviderSettingsContextState() + + @MainActor + func settingsContext(provider: UsageProvider) -> ProviderSettingsContext { + let settings = self.settings + let store = self.store + let state = self.state + return ProviderSettingsContext( + provider: provider, + 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: { id in state.statusByID[id] }, + setStatusText: { id, text in + if let text { + state.statusByID[id] = text + } else { + state.statusByID.removeValue(forKey: id) + } + }, + lastAppActiveRunAt: { id in state.lastRunAtByID[id] }, + setLastAppActiveRunAt: { id, date in + if let date { + state.lastRunAtByID[id] = date + } else { + state.lastRunAtByID.removeValue(forKey: id) + } + }, + requestConfirmation: { _ in }, + runLoginFlow: {}) + } + } + + private final class ProviderSettingsContextState { + var statusByID: [String: String] = [:] + var lastRunAtByID: [String: Date] = [:] + } +} diff --git a/Tests/CodexBarTests/CodexSubagentAccountingIntegrationTests.swift b/Tests/CodexBarTests/CodexSubagentAccountingIntegrationTests.swift index 74343bc9c6..fc7f57eb46 100644 --- a/Tests/CodexBarTests/CodexSubagentAccountingIntegrationTests.swift +++ b/Tests/CodexBarTests/CodexSubagentAccountingIntegrationTests.swift @@ -201,6 +201,11 @@ struct CodexSubagentAccountingIntegrationTests { #expect(childUsages.allSatisfy { $0.forkBaselineDependencyKey == CostUsageScanner.codexForkDependencyNotRequiredKey }) + let sessions = CostUsageScanner.buildCodexSessionBreakdownsFromCache( + cache: cache, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + #expect(sessions.count == 3) + #expect(sessions.allSatisfy { $0.totalTokens == 55 }) } @Test diff --git a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift index 281bb25b54..2ae20458b0 100644 --- a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift +++ b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift @@ -744,7 +744,8 @@ struct CostHistoryChartMenuViewTests { historyDays: Int = 30, historyLabel: String? = nil, daily: [CostUsageDailyReport.Entry]? = nil, - projects: [CostUsageProjectBreakdown]? = nil) -> CostUsageTokenSnapshot + projects: [CostUsageProjectBreakdown]? = nil, + sessions: [CostUsageSessionBreakdown] = []) -> CostUsageTokenSnapshot { CostUsageTokenSnapshot( sessionTokens: 123, @@ -765,6 +766,7 @@ struct CostHistoryChartMenuViewTests { modelBreakdowns: nil), ], projects: projects ?? self.makeProjects(count: projectCount, sourcesPerProject: 1), + sessions: sessions, updatedAt: Date()) } @@ -776,6 +778,7 @@ struct CostHistoryChartMenuViewTests { historyLabel: String? = nil, daily: [CostUsageDailyReport.Entry]? = nil, projects: [CostUsageProjectBreakdown]? = nil, + sessions: [CostUsageSessionBreakdown] = [], provider: UsageProvider = .codex) -> CostHistoryChartMenuView.RenderFingerprint { CostHistoryChartMenuView.renderFingerprint(from: self.makeSnapshot( @@ -785,7 +788,8 @@ struct CostHistoryChartMenuViewTests { historyDays: historyDays, historyLabel: historyLabel, daily: daily, - projects: projects), provider: provider) + projects: projects, + sessions: sessions), provider: provider) } private static func makeProjects(count: Int, sourcesPerProject: Int) -> [CostUsageProjectBreakdown] { @@ -859,3 +863,37 @@ struct CostHistoryChartMenuViewTests { sources: sources) } } + +extension CostHistoryChartMenuViewTests { + @Test + func `session labels distinguish concurrent uuid v7 identifiers`() { + let first = CostHistoryChartMenuView.shortSessionID("019f6d91-970b-7e13-b08e-000000000001") + let second = CostHistoryChartMenuView.shortSessionID("019f6d91-970b-7e13-b08e-000000000002") + + #expect(first == "019f...00000001") + #expect(second == "019f...00000002") + #expect(first != second) + } + + @Test + @MainActor + func `render fingerprint tracks displayed session token components`() { + func session(input: Int?, cached: Int?, output: Int?) -> CostUsageSessionBreakdown { + CostUsageSessionBreakdown( + sessionID: "session-1", + lastActivity: Date(timeIntervalSince1970: 100), + inputTokens: input, + cachedInputTokens: cached, + outputTokens: output, + totalTokens: 110, + requestCount: 1, + costUSD: 0.01, + modelBreakdowns: []) + } + + let base = Self.fingerprint(sessions: [session(input: 100, cached: 20, output: 10)]) + #expect(base != Self.fingerprint(sessions: [session(input: 90, cached: 20, output: 10)])) + #expect(base != Self.fingerprint(sessions: [session(input: 100, cached: 10, output: 10)])) + #expect(base != Self.fingerprint(sessions: [session(input: 100, cached: 20, output: 20)])) + } +} diff --git a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift index 668121e0ba..bcf9d25777 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift @@ -353,6 +353,7 @@ struct CostUsageFetcherCacheSnapshotTests { #expect(cached?.sessionTokens == 207) #expect(cached?.last30DaysTokens == 207) + #expect(cached?.sessions.isEmpty == true) } @Test diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index f357ec9e42..5db8108693 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import CodexBarCore +@Suite(.serialized) struct CostUsageFetcherTests { @Test func `fetcher scopes codex history to selected codex home`() async throws { @@ -17,23 +18,44 @@ struct CostUsageFetcherTests { filename: "ambient.jsonl", tokens: 100) try Self.writeCodexSessionFile(homeRoot: otherHome, env: env, day: day, filename: "managed.jsonl", tokens: 10) + _ = try env.writePiSessionFile( + relativePath: "2026-04-08T10-00-00-000Z_ambient.jsonl", + contents: env.jsonl([[ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 50, "output": 5, "totalTokens": 55], + ], + ]])) let options = CostUsageScanner.Options(cacheRoot: env.cacheRoot) + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) let ambient = try await CostUsageFetcher.loadTokenSnapshot( provider: .codex, now: day, codexHomePath: env.codexHomeRoot.path, - scannerOptions: options) + scannerOptions: options, + piScannerOptions: piOptions) let managed = try await CostUsageFetcher.loadTokenSnapshot( provider: .codex, now: day, codexHomePath: otherHome.path, - scannerOptions: options) + scannerOptions: options, + piScannerOptions: piOptions) #expect(ambient.sessionTokens == 100) #expect(managed.sessionTokens == 10) } +} +extension CostUsageFetcherTests { @Test func `fetcher refreshes codex cache when legacy roots metadata is missing`() async throws { let env = try CostUsageTestEnvironment() @@ -582,6 +604,7 @@ struct CostUsageFetcherTests { #expect(breakdown.modelName == "gpt-5.4") #expect(abs((breakdown.costUSD ?? 0) - (nativeCost + piCost)) < 0.000001) #expect(breakdown.totalTokens == 170) + #expect(snapshot.sessions.isEmpty) } @Test @@ -875,3 +898,108 @@ struct CostUsageFetcherTests { ]).write(to: url, atomically: true, encoding: .utf8) } } + +extension CostUsageFetcherTests { + @Test + func `fetcher returns individual codex conversations for the selected history window`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let firstURL = try env.writeCodexSessionFile( + day: day, + filename: "first.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": env.isoString(for: day), + "payload": ["session_id": "first-session"], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "model": "openai/gpt-5.4", + "last_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 20, + "output_tokens": 10, + ], + ], + ], + ], + ])) + let secondURL = try env.writeCodexSessionFile( + day: day, + filename: "second.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": env.isoString(for: day), + "payload": ["session_id": "second-session"], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "model": "openai/gpt-5.4", + "last_token_usage": [ + "input_tokens": 40, + "cached_input_tokens": 5, + "output_tokens": 5, + ], + ], + ], + ], + ])) + try FileManager.default.setAttributes( + [.modificationDate: day.addingTimeInterval(10)], + ofItemAtPath: firstURL.path) + try FileManager.default.setAttributes( + [.modificationDate: day.addingTimeInterval(20)], + ofItemAtPath: secondURL.path) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + allowPricingRefresh: false, + scannerOptions: options, + piScannerOptions: piOptions) + + #expect(snapshot.sessions.map(\.sessionID) == ["second-session", "first-session"]) + let first = try #require(snapshot.sessions.first(where: { $0.sessionID == "first-session" })) + #expect(first.inputTokens == 100) + #expect(first.cachedInputTokens == 20) + #expect(first.outputTokens == 10) + #expect(first.totalTokens == 110) + #expect(first.requestCount == nil) + #expect(first.modelBreakdowns.map(\.modelName) == ["gpt-5.4"]) + #expect(first.costUSD != nil) + + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + let unrelatedRoot = env.root.appendingPathComponent("unrelated/sessions", isDirectory: true) + let filtered = CostUsageScanner.buildCodexSessionBreakdownsFromCache( + cache: cache, + range: range, + modelsDevCacheRoot: env.cacheRoot, + sessionRoots: [unrelatedRoot]) + #expect(filtered.isEmpty) + let scopedCache = CostUsageScanner.codexCache(cache, scopedTo: [unrelatedRoot]) + #expect(scopedCache.files.isEmpty) + #expect(scopedCache.days.isEmpty) + } +} diff --git a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift index 94bb97fbac..4ea858f6c4 100644 --- a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift @@ -166,6 +166,27 @@ struct CostUsageFetcherUnknownModelPricingTests { #expect(breakdown.costUSD == nil) #expect(requestCount == 0) } + + @Test + func `local only fetch skips every pricing network refresh`() async throws { + let fixture = try UnknownModelPricingFixture() + defer { fixture.environment.cleanup() } + let counter = UnknownModelPricingRequestCounter() + + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: fixture.day, + allowPricingRefresh: false, + refreshPricingInBackground: false, + scannerOptions: fixture.options, + modelsDevClient: ModelsDevClient( + transport: CostUsageFetcherCountingModelsDevTransport(counter: counter))) + + let breakdown = try #require(snapshot.daily.first?.modelBreakdowns?.first) + #expect(breakdown.modelName == "gpt-new") + #expect(breakdown.costUSD == nil) + #expect(await counter.requestCount == 0) + } } private struct UnknownModelPricingFixture { diff --git a/Tests/CodexBarTests/MenuCardModelCodexDegradedQuotaTests.swift b/Tests/CodexBarTests/MenuCardModelCodexDegradedQuotaTests.swift index 24f500435a..0b43092371 100644 --- a/Tests/CodexBarTests/MenuCardModelCodexDegradedQuotaTests.swift +++ b/Tests/CodexBarTests/MenuCardModelCodexDegradedQuotaTests.swift @@ -5,7 +5,7 @@ import Testing struct MenuCardModelCodexDegradedQuotaTests { @Test - func `codex local token usage keeps remote quota unavailable error visible`() throws { + func `codex local token usage hides remote quota unavailable error`() throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let metadata = try #require(ProviderDefaults.metadata[.codex]) let tokenSnapshot = CostUsageTokenSnapshot( @@ -41,13 +41,14 @@ struct MenuCardModelCodexDegradedQuotaTests { usageBarsShowUsed: false, resetTimeDisplayStyle: .countdown, tokenCostUsageEnabled: true, + codexLocalSessionCostLedgerEnabled: true, showOptionalCreditsAndExtraUsage: true, hidePersonalInfo: false, now: now)) #expect(model.placeholder == nil) - #expect(model.subtitleStyle == .error) - #expect(model.subtitleText == "Codex usage is temporarily unavailable. Try refreshing.") + #expect(model.subtitleStyle == .info) + #expect(model.subtitleText == "Not fetched yet") #expect(model.usesStackedDetailLayout) #expect(model.tokenUsage?.sessionLine.contains("$1.08") == true) #expect(model.tokenUsage?.sessionLine.contains("tokens") == true) @@ -55,6 +56,19 @@ struct MenuCardModelCodexDegradedQuotaTests { #expect(model.tokenUsage?.monthLine.contains("tokens") == true) } + @Test + func `codex managed token usage keeps remote quota unavailable error visible`() throws { + let error = "Codex usage is temporarily unavailable. Try refreshing." + let model = try self.makeModel( + tokenCostUsageEnabled: true, + codexLocalSessionCostLedgerEnabled: false, + lastError: error) + + #expect(model.subtitleStyle == .error) + #expect(model.subtitleText == error) + #expect(model.tokenUsage != nil) + } + @Test func `codex remote quota unavailable error stays visible when token usage is hidden`() throws { let now = Date(timeIntervalSince1970: 1_800_000_000) @@ -122,6 +136,7 @@ struct MenuCardModelCodexDegradedQuotaTests { usageBarsShowUsed: false, resetTimeDisplayStyle: .countdown, tokenCostUsageEnabled: true, + codexLocalSessionCostLedgerEnabled: true, showOptionalCreditsAndExtraUsage: true, hidePersonalInfo: false, now: now)) @@ -145,7 +160,7 @@ struct MenuCardModelCodexDegradedQuotaTests { } @Test - func `codex local token usage preserves mapped transport error`() throws { + func `codex local token usage hides mapped remote transport error`() throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let metadata = try #require(ProviderDefaults.metadata[.codex]) let tokenSnapshot = CostUsageTokenSnapshot( @@ -173,13 +188,14 @@ struct MenuCardModelCodexDegradedQuotaTests { usageBarsShowUsed: false, resetTimeDisplayStyle: .countdown, tokenCostUsageEnabled: true, + codexLocalSessionCostLedgerEnabled: true, showOptionalCreditsAndExtraUsage: true, hidePersonalInfo: false, now: now)) #expect(model.placeholder == nil) - #expect(model.subtitleStyle == .error) - #expect(model.subtitleText == "Codex usage is temporarily unavailable. Try refreshing.") + #expect(model.subtitleStyle == .info) + #expect(model.subtitleText == "Not fetched yet") #expect(model.tokenUsage?.sessionLine.contains("$1.08") == true) } @@ -212,6 +228,7 @@ struct MenuCardModelCodexDegradedQuotaTests { private func makeModel( tokenCostUsageEnabled: Bool, + codexLocalSessionCostLedgerEnabled: Bool = true, lastError: String?) throws -> UsageMenuCardView.Model { let now = Date(timeIntervalSince1970: 1_800_000_000) @@ -240,6 +257,7 @@ struct MenuCardModelCodexDegradedQuotaTests { usageBarsShowUsed: false, resetTimeDisplayStyle: .countdown, tokenCostUsageEnabled: tokenCostUsageEnabled, + codexLocalSessionCostLedgerEnabled: codexLocalSessionCostLedgerEnabled, showOptionalCreditsAndExtraUsage: true, hidePersonalInfo: false, now: now)) diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index bba9a3b2c9..e25548ebbd 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -42,6 +42,7 @@ struct ProviderIconResourcesTests { "sub2api", "wayfinder", "zenmux", + "aiand", ] for slug in slugs { let url = resources.appending(path: "ProviderIcon-\(slug).svg") diff --git a/Tests/CodexBarTests/ProviderRegistryTests.swift b/Tests/CodexBarTests/ProviderRegistryTests.swift index ec839ef6b4..e92d5a5941 100644 --- a/Tests/CodexBarTests/ProviderRegistryTests.swift +++ b/Tests/CodexBarTests/ProviderRegistryTests.swift @@ -45,4 +45,36 @@ struct ProviderRegistryTests { #expect(zaiIndex < minimaxIndex) } + + @Test + func `provider confetti palettes are complete and branded`() { + for descriptor in ProviderDescriptorRegistry.all { + let palette = descriptor.branding.confettiPalette + #expect( + (2...3).contains(palette.count), + "Invalid confetti palette for \(descriptor.id.rawValue).") + let hasDistinctColors = palette.first.map { first in + palette.dropFirst().contains { $0 != first } + } ?? false + #expect( + hasDistinctColors, + "Confetti palette for \(descriptor.id.rawValue) must contain distinct colors.") + } + + #expect(ClaudeProviderDescriptor.descriptor.branding.confettiPalette == [ + ProviderColor(hex: 0xD97757), + ProviderColor(hex: 0xF0EEE6), + ProviderColor(hex: 0x141413), + ]) + #expect(CodexProviderDescriptor.descriptor.branding.confettiPalette == [ + ProviderColor(hex: 0x736BD4), + ProviderColor(hex: 0x97A9F7), + ProviderColor(hex: 0xCFD4F7), + ]) + #expect(OpenAIAPIProviderDescriptor.descriptor.branding.confettiPalette == [ + ProviderColor(hex: 0x000000), + ProviderColor(hex: 0x808080), + ProviderColor(hex: 0xFFFFFF), + ]) + } } diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 034fe1b0cf..5a1cb1fae5 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -50,30 +50,6 @@ struct ProviderSettingsDescriptorTests { #expect(fixture.settings.providerConfig(for: .openai)?.sanitizedWorkspaceID == "proj_abc") } - @Test - func `codex exposes usage and cookie pickers`() throws { - let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-codex") - let context = fixture.settingsContext(provider: .codex) - - let pickers = CodexProviderImplementation().settingsPickers(context: context) - let toggles = CodexProviderImplementation().settingsToggles(context: context) - #expect(pickers.contains(where: { $0.id == "codex-usage-source" })) - let cookiePicker = try #require(pickers.first(where: { $0.id == "codex-cookie-source" })) - #expect(cookiePicker.placement == .connection) - #expect(toggles.contains(where: { $0.id == "codex-historical-tracking" })) - let sparkToggle = try #require(toggles.first(where: { $0.id == "codex-spark-usage-visible" })) - #expect(sparkToggle.title == "Show Codex Spark usage") - #expect(sparkToggle.subtitle.contains("menu and provider preview")) - #expect(sparkToggle.binding.wrappedValue) - #expect(sparkToggle.isEnabled?() == true) - - sparkToggle.binding.wrappedValue = false - #expect(fixture.settings.codexSparkUsageVisible == false) - - fixture.settings.showOptionalCreditsAndExtraUsage = false - #expect(sparkToggle.isEnabled?() == false) - } - @Test func `antigravity usage source picker clarifies local ide and agy`() throws { let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-antigravity-source") diff --git a/docs/aiand.md b/docs/aiand.md new file mode 100644 index 0000000000..cabc808804 --- /dev/null +++ b/docs/aiand.md @@ -0,0 +1,69 @@ +--- +summary: "ai& provider: API key setup and 30-day spend from the analytics summary API." +read_when: + - Configuring ai& usage + - Debugging ai& analytics requests +--- + +# ai& Provider + +CodexBar reads organization spend from ai&'s documented analytics API. ai& (aiand.com) is an OpenAI/Anthropic-compatible +inference gateway that can back Claude Code, Codex CLI, and opencode (all three have dedicated integration guides in +the ai& docs). + +## Authentication + +Create an API key in the [ai& console](https://console.aiand.com) (Settings → API Keys → Create). Keys use the `sk-` +prefix and are shown once at creation time. Add the key in CodexBar Settings → Providers → ai&. + +You can also set the environment variable: + +```bash +export AIAND_API_KEY="..." +``` + +Or configure it through the CLI: + +```bash +printf '%s' "$AIAND_API_KEY" | codexbar config set-api-key --provider aiand --stdin +``` + +## Data Source + +CodexBar requests: + +- `GET https://api.aiand.com/analytics/summary?range=30days` + +The request uses `Authorization: Bearer `. CodexBar does not read ai& browser cookies, console sessions, +request logs, or inference prompts. + +## Display + +The menu shows the last 30 days of organization spend in US dollars as an API-spend row. ai& bills prepaid credits with +no quota windows, so no session or weekly meters are shown. The remaining credit balance is only available in the ai& +console; the public API does not expose it. + +Notes: + +- ai& caches analytics responses server-side for 120 seconds, so the number can lag up to two minutes. +- API keys are organization-scoped: every key in the same organization reports the same org-wide spend. + +## CLI Usage + +```bash +codexbar usage --provider aiand +``` + +`ai&` and `ai-and` also work as provider aliases. + +## Troubleshooting + +- A `401` means ai& rejected the API key; create a new key in the console (keys are shown only once). +- A `402` means the organization is out of prepaid credits; top up at console.aiand.com. +- A `429` means the per-minute rate limit was hit; CodexBar retries on the next refresh cycle. + +## Sources + +- [Analytics Summary](https://docs.aiand.com/analytics/summary/) +- [Authentication](https://docs.aiand.com/authentication/) +- [Credits & Top-Up](https://docs.aiand.com/billing/credits/) diff --git a/docs/codex.md b/docs/codex.md index 2c4ef537e1..e1a4067923 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -143,6 +143,12 @@ Example: - CLI PTY diagnostics can still parse `Credits:` from saved/manual `/status` output. ## Cost usage (local log scan) +- Menu source selection: + - By default, a selected managed account keeps its own `CODEX_HOME` session history. + - **Local session cost estimates** is a Codex-only opt-in that instead scans this Mac's ambient `$CODEX_HOME` + (or `~/.codex`) independently of quota, OAuth, web-dashboard, and administrator access. + - The local-only mode never makes a network request or uploads session content. It uses an existing local models.dev + cache when available, then the bundled `CostUsagePricing` rates. - Source files: - Native Codex logs: - `~/.codex/sessions/YYYY/MM/DD/*.jsonl` @@ -156,9 +162,11 @@ Example: - pi sessions count assistant-message usage rows and attribute `openai-codex` assistant usage to Codex. - pi assistant usage is bucketed by assistant-turn timestamp, so mixed-model pi sessions can contribute to multiple days/models correctly. + - Native conversation rows reuse the corrected cached per-file totals and existing pricing tables. They are hidden + when pi usage joins the aggregate because the native-only rows would not reconcile with the merged total. - Cache: - - Native + merged provider cache: `~/Library/Caches/CodexBar/cost-usage/codex-v2.json` - - pi session cache: `~/Library/Caches/CodexBar/cost-usage/pi-sessions-v1.json` + - Native + merged provider cache: `~/Library/Caches/CodexBar/cost-usage/codex-v10.json` + - pi session cache: `~/Library/Caches/CodexBar/cost-usage/pi-sessions-v6.json` - Window: configurable 1-365 day rolling history, with a 60s minimum refresh interval. ### Usage & Spend account rows diff --git a/docs/configuration.md b/docs/configuration.md index e57ff7ebf8..8ab533e110 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -178,6 +178,7 @@ printf '%s' "$LLM_PROXY_API_KEY" | codexbar config set-api-key --provider llmpro printf '%s' "$LITELLM_API_KEY" | codexbar config set-api-key --provider litellm --stdin printf '%s' "$CLAWROUTER_API_KEY" | codexbar config set-api-key --provider clawrouter --stdin printf '%s' "$SUB2API_API_KEY" | codexbar config set-api-key --provider sub2api --stdin +printf '%s' "$AIAND_API_KEY" | codexbar config set-api-key --provider aiand --stdin ``` OpenAI API project scoping uses `workspaceID` in config. This maps to `OPENAI_PROJECT_ID` for Admin API usage and is @@ -264,7 +265,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`, `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`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `clawrouter`, `longcat`, `sub2api`, `wayfinder`, `zenmux`. +`codex`, `openai`, `azureopenai`, `claude`, `clinepass`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `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`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `clawrouter`, `longcat`, `sub2api`, `wayfinder`, `zenmux`, `aiand`. ## Ordering The order of `providers` controls display/order in the app and CLI. Reorder the array to change ordering. diff --git a/docs/index.html b/docs/index.html index ad53971a7a..5329777a4e 100644 --- a/docs/index.html +++ b/docs/index.html @@ -6,7 +6,7 @@ CodexBar — every AI coding limit, in your menu bar @@ -37,7 +37,7 @@ @@ -293,7 +293,7 @@

- 60 providers,{mobileBreak}one menu bar + 61 providers,{mobileBreak}one menu bar

Popular providers become status items with their own usage windows, reset countdowns, charts, and provider menus. diff --git a/docs/llms.txt b/docs/llms.txt index 9d0f911716..0eef651a3f 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -1,9 +1,9 @@ # CodexBar -A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 60 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 61 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. Canonical documentation: -- CodexBar — every AI coding limit, in your menu bar: https://codexbar.app/ - A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 60 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. +- CodexBar — every AI coding limit, in your menu bar: https://codexbar.app/ - A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 61 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. Source: https://github.com/steipete/CodexBar diff --git a/docs/provider.md b/docs/provider.md index 4a2be9894a..6fd968d36f 100644 --- a/docs/provider.md +++ b/docs/provider.md @@ -47,7 +47,7 @@ Provider behavior is descriptor-driven. Two explicit, exhaustive registries form Introduce a single descriptor per provider: - `id` (stable `UsageProvider`) - display/labels/URLs (menu title, dashboard URL, status URL) -- UI branding (icon name, primary color) +- UI branding (icon name, primary color, 2–3-color confetti palette) - capabilities (supportsCredits, supportsTokenCost, supportsStatusPolling, supportsLogin) - fetch plan (allowed `--source` modes + ordered strategy pipeline) - CLI metadata (cliName, aliases, version provider) @@ -122,7 +122,11 @@ public enum ExampleProviderDescriptor { branding: ProviderBranding( iconStyle: .codex, iconResourceName: "ProviderIcon-example", - color: ProviderColor(red: 0.2, green: 0.6, blue: 0.8)), + color: ProviderColor(red: 0.2, green: 0.6, blue: 0.8), + confettiPalette: [ + ProviderColor(hex: 0x3399CC), + ProviderColor(hex: 0x66C2FF), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Example cost summary is not supported." }), @@ -187,9 +191,10 @@ Checklist: - Add `Sources/CodexBar/Providers//ProviderImplementation.swift`: - `ProviderImplementation` only for settings/login UI hooks. - Add an exhaustive case to `ProviderImplementationRegistry.makeImplementation(for:)`. -- Add icons + color in descriptor: +- Add icons + colors in descriptor: - `iconName` must match `ProviderIcon-` asset. - Color used in menu cards + switcher. + - Provide a curated `confettiPalette` with 2–3 colors for reset celebrations. - If CLI-specific behavior is needed: - add `cliName`, `cliAliases`, `sourceModes`, `versionProvider` in descriptor. - strategies decide which `--source` modes apply. diff --git a/docs/providers.md b/docs/providers.md index 7dc8b83b06..30a39b1223 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -8,7 +8,7 @@ read_when: # Providers -CodexBar currently registers 60 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or +CodexBar currently registers 61 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) @@ -89,6 +89,7 @@ scan fails, while provider/account configuration changes replace obsolete result | Deepgram | API key → project discovery and usage breakdown API (`api`). | | Chutes | API key from config/env → subscription usage and quota API (`api`). | | ZenMux | Management API key from config/env → five-hour and seven-day quota windows plus PAYG balance (`api`). | +| ai& | API key from config/env → 30-day organization spend from the analytics summary API (`api`). | | Zed | Zed editor Keychain session → `cloud.zed.dev/client/users/me` for plan and quota data (`local`). | ## Codex @@ -513,4 +514,11 @@ scan fails, while provider/account configuration changes replace obsolete result - Status: none yet. - Details: `docs/stepfun.md`. +## ai& +- API key from config or `AIAND_API_KEY` (org-scoped `sk-` key from console.aiand.com). +- Reads the last 30 days of organization spend from `GET https://api.aiand.com/analytics/summary?range=30days`, the documented billing-grade aggregate. +- Prepaid credits with no quota windows; no session or weekly meters are synthesized. The credit balance is console-only and not shown. +- Analytics responses are cached server-side for 120 seconds, so spend can lag up to two minutes. +- Details: `docs/aiand.md`. + See also: `docs/provider.md` for architecture notes. diff --git a/docs/site-locales.mjs b/docs/site-locales.mjs index 20fedd9d0a..2da396f75e 100644 --- a/docs/site-locales.mjs +++ b/docs/site-locales.mjs @@ -98,8 +98,8 @@ export const localeCatalog = [ export const localeMessages = { "en": { "meta.title": "CodexBar — every AI coding limit in your menu bar", - "meta.description": "A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 60 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more.", - "meta.ogDescription": "Track usage windows, credits, and resets across 60 AI coding providers from your macOS menu bar.", + "meta.description": "A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 61 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more.", + "meta.ogDescription": "Track usage windows, credits, and resets across 61 AI coding providers from your macOS menu bar.", "nav.primary": "Primary", "nav.language": "Language", "nav.docs": "docs", @@ -112,7 +112,7 @@ export const localeMessages = { "hero.description": "CodexBar tracks usage windows, credit balances, and reset countdowns across the providers you actually pay for — one status item each, or merge them into one.", "hero.download": "Download for macOS", "hero.fineprint": "Free & open source · macOS 14+ · Universal via GitHub Releases and Homebrew", - "providers.title": "60 providers,{mobileBreak}one menu bar", + "providers.title": "61 providers,{mobileBreak}one menu bar", "providers.description": "Popular providers become status items with their own usage windows, reset countdowns, charts, and provider menus.", "providers.yourProvider": "Your provider", "providers.authoringGuide": "Authoring guide", @@ -238,8 +238,8 @@ export const localeMessages = { }, "zh-CN": { "meta.title": "CodexBar — 菜单栏中的每个 AI 编码限制", - "meta.description": "一个微小的 macOS 菜单栏应用程序,可跟踪 60 个提供商(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 编码提供商使用窗口、积分、成本和重置。", - "meta.ogDescription": "从您的 macOS 菜单栏跟踪 60 个 AI 编码提供商的使用窗口、积分和重置。", + "meta.description": "一个微小的 macOS 菜单栏应用程序,可跟踪 61 个提供商(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 编码提供商使用窗口、积分、成本和重置。", + "meta.ogDescription": "从您的 macOS 菜单栏跟踪 61 个 AI 编码提供商的使用窗口、积分和重置。", "nav.primary": "基本的", "nav.language": "语言", "nav.docs": "文档", @@ -252,7 +252,7 @@ export const localeMessages = { "hero.description": "CodexBar 跟踪您实际付费的提供商的使用窗口、信用余额和重置倒计时 - 每个状态项一项,或将它们合并为一项。", "hero.download": "下载macOS", "hero.fineprint": "免费开源 · macOS 14+ · GitHub Releases 和 Homebrew 均提供通用版本", - "providers.title": "60 个提供商,{mobileBreak}一个菜单栏", + "providers.title": "61 个提供商,{mobileBreak}一个菜单栏", "providers.description": "受欢迎的提供商成为状态项目,具有自己的使用窗口、重置倒计时、图表和提供商菜单。", "providers.yourProvider": "您的提供商", "providers.authoringGuide": "创作指南", @@ -378,8 +378,8 @@ export const localeMessages = { }, "zh-TW": { "meta.title": "CodexBar — 功能表列中的每個 AI 編碼限制", - "meta.description": "一個微小的 macOS 功能表列應用程序,可追蹤 60 個提供者(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 編碼提供者使用視窗、積分、成本和重設。", - "meta.ogDescription": "從您的 macOS 功能表列追蹤 60 個 AI 編碼提供者的使用視窗、積分和重設。", + "meta.description": "一個微小的 macOS 功能表列應用程序,可追蹤 61 個提供者(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 編碼提供者使用視窗、積分、成本和重設。", + "meta.ogDescription": "從您的 macOS 功能表列追蹤 61 個 AI 編碼提供者的使用視窗、積分和重設。", "nav.primary": "基本的", "nav.language": "語言", "nav.docs": "文件", @@ -392,7 +392,7 @@ export const localeMessages = { "hero.description": "CodexBar 追蹤您實際付費的提供者的使用視窗、信用餘額和重設倒數計時 - 每個狀態項一項,或將它們合併為一項。", "hero.download": "下載macOS", "hero.fineprint": "免費開源 · macOS 14+ · GitHub Releases 和 Homebrew 均提供通用版本", - "providers.title": "60 個提供者,{mobileBreak}一個選單列", + "providers.title": "61 個提供者,{mobileBreak}一個選單列", "providers.description": "受歡迎的提供者成為狀態項目,具有自己的使用視窗、重置倒數計時、圖表和提供者選單。", "providers.yourProvider": "您的提供者", "providers.authoringGuide": "創作指南", @@ -518,8 +518,8 @@ export const localeMessages = { }, "ja-JP": { "meta.title": "CodexBar — メニュー バーのすべての AI コーディング制限", - "meta.description": "小さな macOS メニュー バー アプリ。Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM など、60 のプロバイダーにわたる AI コーディング プロバイダーの使用期間、クレジット、コスト、リセットを追跡します。", - "meta.ogDescription": "macOS メニュー バーから、60 の AI コーディング プロバイダーにわたる使用期間、クレジット、リセットを追跡します。", + "meta.description": "小さな macOS メニュー バー アプリ。Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM など、61 のプロバイダーにわたる AI コーディング プロバイダーの使用期間、クレジット、コスト、リセットを追跡します。", + "meta.ogDescription": "macOS メニュー バーから、61 の AI コーディング プロバイダーにわたる使用期間、クレジット、リセットを追跡します。", "nav.primary": "主要な", "nav.language": "言語", "nav.docs": "ドキュメント", @@ -532,7 +532,7 @@ export const localeMessages = { "hero.description": "CodexBar は、実際に料金を支払っているプロバイダー全体の使用期間、クレジット残高、リセット カウントダウンを追跡します。ステータス項目を 1 つずつ、または 1 つに統合します。", "hero.download": "macOS のダウンロード", "hero.fineprint": "無料・オープンソース · macOS 14+ · GitHub Releases と Homebrew でユニバーサル版を提供", - "providers.title": "60 プロバイダー、{mobileBreak}1つのメニューバー", + "providers.title": "61 プロバイダー、{mobileBreak}1つのメニューバー", "providers.description": "人気のあるプロバイダーは、独自の使用期間、リセット カウントダウン、グラフ、プロバイダー メニューを備えたステータス アイテムになります。", "providers.yourProvider": "あなたのプロバイダー", "providers.authoringGuide": "オーサリングガイド", @@ -658,8 +658,8 @@ export const localeMessages = { }, "es": { "meta.title": "CodexBar: cada límite de codificación de IA en tu barra de menú", - "meta.description": "Una pequeña aplicación de barra de menú macOS que rastrea las ventanas de uso, los créditos, los costos y los restablecimientos de los proveedores de codificación de IA en 60 proveedores: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM y más.", - "meta.ogDescription": "Realice un seguimiento de las ventanas de uso, los créditos y los restablecimientos en 60 proveedores de codificación de IA desde su barra de menú macOS.", + "meta.description": "Una pequeña aplicación de barra de menú macOS que rastrea las ventanas de uso, los créditos, los costos y los restablecimientos de los proveedores de codificación de IA en 61 proveedores: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM y más.", + "meta.ogDescription": "Realice un seguimiento de las ventanas de uso, los créditos y los restablecimientos en 61 proveedores de codificación de IA desde su barra de menú macOS.", "nav.primary": "Primario", "nav.language": "Idioma", "nav.docs": "Documentos", @@ -672,7 +672,7 @@ export const localeMessages = { "hero.description": "CodexBar realiza un seguimiento de los períodos de uso, los saldos de crédito y restablece las cuentas regresivas de los proveedores por los que realmente paga: un elemento de estado para cada uno o los fusiona en uno solo.", "hero.download": "Descargar para macOS", "hero.fineprint": "Gratis y de código abierto · macOS 14+ · Universal mediante GitHub Releases y Homebrew", - "providers.title": "60 proveedores,{mobileBreak}una barra de menús", + "providers.title": "61 proveedores,{mobileBreak}una barra de menús", "providers.description": "Los proveedores populares se convierten en elementos de estado con sus propias ventanas de uso, restablecen cuentas regresivas, gráficos y menús de proveedores.", "providers.yourProvider": "Tu proveedor", "providers.authoringGuide": "guía de autoría", @@ -798,8 +798,8 @@ export const localeMessages = { }, "pt-BR": { "meta.title": "CodexBar — todos os limites de codificação de IA na sua barra de menu", - "meta.description": "Um pequeno aplicativo de barra de menu macOS que rastreia janelas de uso, créditos, custos e redefinições do provedor de codificação de IA em 60 provedores — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e muito mais.", - "meta.ogDescription": "Rastreie janelas de uso, créditos e redefinições em 60 provedores de codificação de IA na barra de menu macOS.", + "meta.description": "Um pequeno aplicativo de barra de menu macOS que rastreia janelas de uso, créditos, custos e redefinições do provedor de codificação de IA em 61 provedores — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e muito mais.", + "meta.ogDescription": "Rastreie janelas de uso, créditos e redefinições em 61 provedores de codificação de IA na barra de menu macOS.", "nav.primary": "Primário", "nav.language": "Linguagem", "nav.docs": "Documentos", @@ -812,7 +812,7 @@ export const localeMessages = { "hero.description": "CodexBar rastreia janelas de uso, saldos de crédito e reinicia contagens regressivas nos provedores pelos quais você realmente paga - um item de status cada, ou mescla-os em um.", "hero.download": "Baixar para macOS", "hero.fineprint": "Gratuito e de código aberto · macOS 14+ · Universal via GitHub Releases e Homebrew", - "providers.title": "60 provedores,{mobileBreak}uma barra de menu", + "providers.title": "61 provedores,{mobileBreak}uma barra de menu", "providers.description": "Provedores populares tornam-se itens de status com suas próprias janelas de uso, reiniciam contagens regressivas, gráficos e menus de provedores.", "providers.yourProvider": "Seu provedor", "providers.authoringGuide": "Guia de autoria", @@ -938,8 +938,8 @@ export const localeMessages = { }, "ko": { "meta.title": "CodexBar — 메뉴 표시줄의 모든 AI 코딩 제한", - "meta.description": "Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM 등 60개 제공자 전체에서 AI 코딩 제공자 사용 창, 크레딧, 비용 및 재설정을 추적하는 작은 macOS 메뉴 표시줄 앱입니다.", - "meta.ogDescription": "macOS 메뉴 표시줄에서 60개 AI 코딩 제공업체의 사용 기간, 크레딧 및 재설정을 추적하세요.", + "meta.description": "Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM 등 61개 제공자 전체에서 AI 코딩 제공자 사용 창, 크레딧, 비용 및 재설정을 추적하는 작은 macOS 메뉴 표시줄 앱입니다.", + "meta.ogDescription": "macOS 메뉴 표시줄에서 61개 AI 코딩 제공업체의 사용 기간, 크레딧 및 재설정을 추적하세요.", "nav.primary": "주요한", "nav.language": "언어", "nav.docs": "문서", @@ -952,7 +952,7 @@ export const localeMessages = { "hero.description": "CodexBar는 귀하가 실제로 비용을 지불한 제공업체 전반에 걸쳐 사용 기간, 크레딧 잔액 및 재설정 카운트다운을 추적합니다. 즉, 각각 하나의 상태 항목 또는 하나로 병합됩니다.", "hero.download": "macOS 동안 다운로드", "hero.fineprint": "무료 오픈 소스 · macOS 14+ · GitHub Releases와 Homebrew에서 유니버설 제공", - "providers.title": "60개 제공자,{mobileBreak}하나의 메뉴 막대", + "providers.title": "61개 제공자,{mobileBreak}하나의 메뉴 막대", "providers.description": "인기 있는 제공업체는 자체 사용 창, 재설정 카운트다운, 차트 및 제공업체 메뉴를 갖춘 상태 항목이 됩니다.", "providers.yourProvider": "귀하의 제공자", "providers.authoringGuide": "저작 가이드", @@ -1078,8 +1078,8 @@ export const localeMessages = { }, "de": { "meta.title": "CodexBar – alle Limits Ihrer KI-Coding-Tools in der Menüleiste", - "meta.description": "Eine kleine macOS-Menüleisten-App, die Nutzungslimits, Guthaben, Kosten und Resets von 60 KI-Coding-Anbietern im Blick behält – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM und mehr.", - "meta.ogDescription": "Nutzungslimits, Guthaben und Resets von 60 KI-Coding-Anbietern direkt in Ihrer macOS-Menüleiste.", + "meta.description": "Eine kleine macOS-Menüleisten-App, die Nutzungslimits, Guthaben, Kosten und Resets von 61 KI-Coding-Anbietern im Blick behält – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM und mehr.", + "meta.ogDescription": "Nutzungslimits, Guthaben und Resets von 61 KI-Coding-Anbietern direkt in Ihrer macOS-Menüleiste.", "nav.primary": "Primär", "nav.language": "Sprache", "nav.docs": "Dokumente", @@ -1092,7 +1092,7 @@ export const localeMessages = { "hero.description": "CodexBar verfolgt Nutzungsfenster, Guthaben und Reset-Countdowns bei den Anbietern, für die Sie tatsächlich bezahlen – jeweils ein Statuselement oder sie werden zu einem zusammengeführt.", "hero.download": "Herunterladen für macOS", "hero.fineprint": "Kostenlos und Open Source · macOS 14+ · Universal über GitHub Releases und Homebrew", - "providers.title": "60 Provider,{mobileBreak}eine Menüleiste", + "providers.title": "61 Provider,{mobileBreak}eine Menüleiste", "providers.description": "Beliebte Anbieter werden zu Statuselementen mit eigenen Nutzungsfenstern, Reset-Countdowns, Diagrammen und Anbietermenüs.", "providers.yourProvider": "Ihr Anbieter", "providers.authoringGuide": "Autorenleitfaden", @@ -1218,8 +1218,8 @@ export const localeMessages = { }, "fr": { "meta.title": "CodexBar — chaque limite de codage IA dans votre barre de menus", - "meta.description": "Une petite application de barre de menus macOS qui suit les fenêtres d'utilisation, les crédits, les coûts et les réinitialisations des fournisseurs de codage d'IA sur 60 fournisseurs : Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, et plus encore.", - "meta.ogDescription": "Suivez les fenêtres d'utilisation, les crédits et les réinitialisations auprès de 60 fournisseurs de codage d'IA à partir de votre barre de menus macOS.", + "meta.description": "Une petite application de barre de menus macOS qui suit les fenêtres d'utilisation, les crédits, les coûts et les réinitialisations des fournisseurs de codage d'IA sur 61 fournisseurs : Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, et plus encore.", + "meta.ogDescription": "Suivez les fenêtres d'utilisation, les crédits et les réinitialisations auprès de 61 fournisseurs de codage d'IA à partir de votre barre de menus macOS.", "nav.primary": "Primaire", "nav.language": "Langue", "nav.docs": "Documents", @@ -1232,7 +1232,7 @@ export const localeMessages = { "hero.description": "CodexBar suit les fenêtres d'utilisation, les soldes créditeurs et réinitialise les comptes à rebours pour les fournisseurs pour lesquels vous payez réellement - un élément de statut chacun, ou les fusionne en un seul.", "hero.download": "Télécharger pour macOS", "hero.fineprint": "Gratuit et open source · macOS 14+ · Universel via GitHub Releases et Homebrew", - "providers.title": "60 fournisseurs,{mobileBreak}une barre des menus", + "providers.title": "61 fournisseurs,{mobileBreak}une barre des menus", "providers.description": "Les fournisseurs populaires deviennent des éléments de statut avec leurs propres fenêtres d'utilisation, réinitialisent les comptes à rebours, les graphiques et les menus des fournisseurs.", "providers.yourProvider": "Votre fournisseur", "providers.authoringGuide": "Guide de création", @@ -1358,8 +1358,8 @@ export const localeMessages = { }, "ar": { "meta.title": "CodexBar — كل حد لترميز الذكاء الاصطناعي في شريط القائمة", - "meta.description": "تطبيق شريط قوائم macOS صغير الحجم يتتبع نوافذ استخدام موفر ترميز الذكاء الاصطناعي، والائتمانات، والتكاليف، وعمليات إعادة التعيين عبر 60 موفرًا - Codex، وOpenAI، وClaude، وCursor، وGemini، وCopilot، وLiteLLM، والمزيد.", - "meta.ogDescription": "تتبع نوافذ الاستخدام والأرصدة وعمليات إعادة التعيين عبر 60 موفرًا لترميز الذكاء الاصطناعي من شريط القائمة macOS.", + "meta.description": "تطبيق شريط قوائم macOS صغير الحجم يتتبع نوافذ استخدام موفر ترميز الذكاء الاصطناعي، والائتمانات، والتكاليف، وعمليات إعادة التعيين عبر 61 موفرًا - Codex، وOpenAI، وClaude، وCursor، وGemini، وCopilot، وLiteLLM، والمزيد.", + "meta.ogDescription": "تتبع نوافذ الاستخدام والأرصدة وعمليات إعادة التعيين عبر 61 موفرًا لترميز الذكاء الاصطناعي من شريط القائمة macOS.", "nav.primary": "أساسي", "nav.language": "لغة", "nav.docs": "المستندات", @@ -1372,7 +1372,7 @@ export const localeMessages = { "hero.description": "يتتبع CodexBar فترات الاستخدام والأرصدة الائتمانية وإعادة تعيين العد التنازلي عبر مقدمي الخدمة الذين تدفع مقابلهم فعليًا — عنصر حالة واحد لكل منهم، أو دمجهم في عنصر واحد.", "hero.download": "التنزيل لمدة macOS", "hero.fineprint": "مجاني ومفتوح المصدر · macOS 14+ · إصدار شامل عبر GitHub Releases وHomebrew", - "providers.title": "60 مزودًا،{mobileBreak}شريط قوائم واحد", + "providers.title": "61 مزودًا،{mobileBreak}شريط قوائم واحد", "providers.description": "يصبح الموفرون المشهورون عناصر حالة مع نوافذ الاستخدام الخاصة بهم، وعمليات إعادة تعيين العد التنازلي، والمخططات، وقوائم الموفر.", "providers.yourProvider": "المزود الخاص بك", "providers.authoringGuide": "دليل التأليف", @@ -1498,8 +1498,8 @@ export const localeMessages = { }, "it": { "meta.title": "CodexBar: ogni limite di codifica AI nella barra dei menu", - "meta.description": "Una piccola app della barra dei menu macOS che tiene traccia delle finestre di utilizzo, dei crediti, dei costi e dei ripristini del fornitore di codifica AI tra 60 fornitori: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e altri.", - "meta.ogDescription": "Tieni traccia delle finestre di utilizzo, dei crediti e dei ripristini tra 60 fornitori di codifica AI dalla barra dei menu macOS.", + "meta.description": "Una piccola app della barra dei menu macOS che tiene traccia delle finestre di utilizzo, dei crediti, dei costi e dei ripristini del fornitore di codifica AI tra 61 fornitori: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e altri.", + "meta.ogDescription": "Tieni traccia delle finestre di utilizzo, dei crediti e dei ripristini tra 61 fornitori di codifica AI dalla barra dei menu macOS.", "nav.primary": "Primario", "nav.language": "Lingua", "nav.docs": "Documenti", @@ -1512,7 +1512,7 @@ export const localeMessages = { "hero.description": "CodexBar tiene traccia delle finestre di utilizzo, dei saldi del credito e reimposta i conti alla rovescia tra i fornitori per cui paghi effettivamente: un elemento di stato ciascuno o uniscili in uno solo.", "hero.download": "Scarica per macOS", "hero.fineprint": "Gratuito e open source · macOS 14+ · Universale tramite GitHub Releases e Homebrew", - "providers.title": "60 provider,{mobileBreak}una barra dei menu", + "providers.title": "61 provider,{mobileBreak}una barra dei menu", "providers.description": "I fornitori più popolari diventano elementi di stato con le proprie finestre di utilizzo, reimpostano i conti alla rovescia, i grafici e i menu dei fornitori.", "providers.yourProvider": "Il tuo fornitore", "providers.authoringGuide": "Guida all'autore", @@ -1638,8 +1638,8 @@ export const localeMessages = { }, "vi": { "meta.title": "CodexBar — mọi giới hạn mã hóa AI trong thanh menu của bạn", - "meta.description": "Một ứng dụng thanh menu macOS nhỏ theo dõi khoảng thời gian sử dụng, tín dụng, chi phí và đặt lại của nhà cung cấp mã hóa AI trên 60 nhà cung cấp — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, v.v.", - "meta.ogDescription": "Theo dõi khoảng thời gian sử dụng, tín dụng và đặt lại trên 60 nhà cung cấp mã hóa AI từ thanh menu macOS của bạn.", + "meta.description": "Một ứng dụng thanh menu macOS nhỏ theo dõi khoảng thời gian sử dụng, tín dụng, chi phí và đặt lại của nhà cung cấp mã hóa AI trên 61 nhà cung cấp — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, v.v.", + "meta.ogDescription": "Theo dõi khoảng thời gian sử dụng, tín dụng và đặt lại trên 61 nhà cung cấp mã hóa AI từ thanh menu macOS của bạn.", "nav.primary": "Sơ đẳng", "nav.language": "Ngôn ngữ", "nav.docs": "Tài liệu", @@ -1652,7 +1652,7 @@ export const localeMessages = { "hero.description": "CodexBar theo dõi khoảng thời gian sử dụng, số dư tín dụng và đếm ngược đặt lại trên các nhà cung cấp mà bạn thực sự thanh toán — mỗi mục một trạng thái hoặc hợp nhất chúng thành một.", "hero.download": "Tải xuống cho macOS", "hero.fineprint": "Miễn phí và nguồn mở · macOS 14+ · Bản universal qua GitHub Releases và Homebrew", - "providers.title": "60 nhà cung cấp,{mobileBreak}một thanh menu", + "providers.title": "61 nhà cung cấp,{mobileBreak}một thanh menu", "providers.description": "Các nhà cung cấp phổ biến trở thành các mục trạng thái với cửa sổ sử dụng của riêng họ, đặt lại bộ đếm ngược, biểu đồ và menu nhà cung cấp.", "providers.yourProvider": "Nhà cung cấp của bạn", "providers.authoringGuide": "Hướng dẫn soạn thảo", @@ -1778,8 +1778,8 @@ export const localeMessages = { }, "nl": { "meta.title": "CodexBar — elke AI-coderingslimiet in uw menubalk", - "meta.description": "Een kleine macOS menubalk-app die gebruiksperioden, tegoeden, kosten en resets van AI-coderingsproviders bijhoudt bij 60 providers: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM en meer.", - "meta.ogDescription": "Houd gebruiksvensters, tegoeden en resets bij van 60 leveranciers van AI-codering via uw macOS-menubalk.", + "meta.description": "Een kleine macOS menubalk-app die gebruiksperioden, tegoeden, kosten en resets van AI-coderingsproviders bijhoudt bij 61 providers: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM en meer.", + "meta.ogDescription": "Houd gebruiksvensters, tegoeden en resets bij van 61 leveranciers van AI-codering via uw macOS-menubalk.", "nav.primary": "Primair", "nav.language": "Taal", "nav.docs": "Documenten", @@ -1792,7 +1792,7 @@ export const localeMessages = { "hero.description": "CodexBar houdt gebruiksperioden, tegoeden en reset-countdowns bij voor de providers waarvoor u daadwerkelijk betaalt: elk één statusitem, of u kunt ze samenvoegen tot één statusitem.", "hero.download": "Downloaden voor macOS", "hero.fineprint": "Gratis en open source · macOS 14+ · Universeel via GitHub Releases en Homebrew", - "providers.title": "60 providers,{mobileBreak}één menubalk", + "providers.title": "61 providers,{mobileBreak}één menubalk", "providers.description": "Populaire providers worden statusitems met hun eigen gebruiksvensters, resetcountdowns, grafieken en providermenu's.", "providers.yourProvider": "Uw aanbieder", "providers.authoringGuide": "Handleiding voor het schrijven", @@ -1918,8 +1918,8 @@ export const localeMessages = { }, "tr": { "meta.title": "CodexBar — menü çubuğunuzdaki tüm AI kodlama limitleri", - "meta.description": "AI kodlama sağlayıcısı kullanım pencerelerini, kredilerini, maliyetlerini izleyen ve Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM ve daha fazlası olmak üzere 60 sağlayıcı genelinde sıfırlamaları izleyen küçük bir macOS menü çubuğu uygulaması.", - "meta.ogDescription": "macOS menü çubuğunu kullanarak 60 AI kodlama sağlayıcısındaki kullanım pencerelerini, kredileri ve sıfırlamaları izleyin.", + "meta.description": "AI kodlama sağlayıcısı kullanım pencerelerini, kredilerini, maliyetlerini izleyen ve Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM ve daha fazlası olmak üzere 61 sağlayıcı genelinde sıfırlamaları izleyen küçük bir macOS menü çubuğu uygulaması.", + "meta.ogDescription": "macOS menü çubuğunu kullanarak 61 AI kodlama sağlayıcısındaki kullanım pencerelerini, kredileri ve sıfırlamaları izleyin.", "nav.primary": "Öncelik", "nav.language": "Dil", "nav.docs": "Dokümanlar", @@ -1932,7 +1932,7 @@ export const localeMessages = { "hero.description": "CodexBar, gerçekte ödeme yaptığınız sağlayıcılar genelinde kullanım pencerelerini, kredi bakiyelerini ve geri sayımları sıfırlar (her biri bir durum öğesi olacak şekilde) izler veya bunları tek bir öğede birleştirir.", "hero.download": "macOS için indirin", "hero.fineprint": "Ücretsiz ve açık kaynak · macOS 14+ · GitHub Releases ve Homebrew ile evrensel sürüm", - "providers.title": "60 sağlayıcı,{mobileBreak}bir menü çubuğu", + "providers.title": "61 sağlayıcı,{mobileBreak}bir menü çubuğu", "providers.description": "Popüler sağlayıcılar, kendi kullanım pencereleri, sıfırlama geri sayımları, çizelgeleri ve sağlayıcı menüleriyle durum öğeleri haline gelir.", "providers.yourProvider": "Sağlayıcınız", "providers.authoringGuide": "Yazma kılavuzu", @@ -2058,8 +2058,8 @@ export const localeMessages = { }, "uk": { "meta.title": "CodexBar — кожне обмеження кодування AI у вашій панелі меню", - "meta.description": "Маленький додаток macOS на панелі меню, який відстежує вікна використання постачальників кодування штучного інтелекту, кредити, витрати та скидання 60 постачальників — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM тощо.", - "meta.ogDescription": "Відстежуйте вікна використання, кредити та скидання 60 постачальників кодування ШІ за допомогою панелі меню macOS.", + "meta.description": "Маленький додаток macOS на панелі меню, який відстежує вікна використання постачальників кодування штучного інтелекту, кредити, витрати та скидання 61 постачальників — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM тощо.", + "meta.ogDescription": "Відстежуйте вікна використання, кредити та скидання 61 постачальників кодування ШІ за допомогою панелі меню macOS.", "nav.primary": "Первинний", "nav.language": "Мова", "nav.docs": "документи", @@ -2072,7 +2072,7 @@ export const localeMessages = { "hero.description": "CodexBar відстежує вікна використання, кредитні баланси та скидає зворотний відлік для постачальників, за яких ви фактично платите, — по одному статусу для кожного або об’єднує їх в один.", "hero.download": "Завантажити для macOS", "hero.fineprint": "Безкоштовний і відкритий код · macOS 14+ · Універсальна версія через GitHub Releases і Homebrew", - "providers.title": "60 провайдерів,{mobileBreak}одна панель меню", + "providers.title": "61 провайдерів,{mobileBreak}одна панель меню", "providers.description": "Популярні постачальники стають елементами статусу з власними вікнами використання, скиданням зворотного відліку, діаграмами та меню постачальників.", "providers.yourProvider": "Ваш провайдер", "providers.authoringGuide": "Авторський посібник", @@ -2198,8 +2198,8 @@ export const localeMessages = { }, "ru": { "meta.title": "CodexBar — все лимиты AI-кодинга в вашей строке меню", - "meta.description": "Небольшое приложение для строки меню macOS, которое отслеживает окна использования, кредиты, расходы и сбросы лимитов у 60 AI-провайдеров для кодинга — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM и других.", - "meta.ogDescription": "Отслеживайте окна использования, кредиты и сбросы лимитов у 60 AI-провайдеров для кодинга прямо из строки меню macOS.", + "meta.description": "Небольшое приложение для строки меню macOS, которое отслеживает окна использования, кредиты, расходы и сбросы лимитов у 61 AI-провайдеров для кодинга — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM и других.", + "meta.ogDescription": "Отслеживайте окна использования, кредиты и сбросы лимитов у 61 AI-провайдеров для кодинга прямо из строки меню macOS.", "nav.primary": "Основная навигация", "nav.language": "Язык", "nav.docs": "Документация", @@ -2212,7 +2212,7 @@ export const localeMessages = { "hero.description": "CodexBar отслеживает окна использования, балансы кредитов и обратный отсчет до сброса у провайдеров, за которых вы действительно платите, — по одному элементу статуса на каждого или все вместе в одном.", "hero.download": "Скачать для macOS", "hero.fineprint": "Бесплатно и с открытым исходным кодом · macOS 14+ · универсальная сборка через GitHub Releases и Homebrew", - "providers.title": "60 провайдеров,{mobileBreak}одна строка меню", + "providers.title": "61 провайдеров,{mobileBreak}одна строка меню", "providers.description": "Популярные провайдеры становятся элементами статуса со своими окнами использования, обратным отсчетом до сброса, графиками и меню провайдера.", "providers.yourProvider": "Ваш провайдер", "providers.authoringGuide": "Руководство по добавлению", @@ -2338,8 +2338,8 @@ export const localeMessages = { }, "id": { "meta.title": "CodexBar — setiap batas pengkodean AI di bilah menu Anda", - "meta.description": "Aplikasi bilah menu macOS kecil yang melacak periode penggunaan, kredit, biaya, dan penyetelan ulang penyedia pengkodean AI di 60 penyedia — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, dan banyak lagi.", - "meta.ogDescription": "Lacak jangka waktu penggunaan, kredit, dan penyetelan ulang di 60 penyedia pengkodean AI dari bilah menu macOS Anda.", + "meta.description": "Aplikasi bilah menu macOS kecil yang melacak periode penggunaan, kredit, biaya, dan penyetelan ulang penyedia pengkodean AI di 61 penyedia — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, dan banyak lagi.", + "meta.ogDescription": "Lacak jangka waktu penggunaan, kredit, dan penyetelan ulang di 61 penyedia pengkodean AI dari bilah menu macOS Anda.", "nav.primary": "Utama", "nav.language": "Bahasa", "nav.docs": "dokumen", @@ -2352,7 +2352,7 @@ export const localeMessages = { "hero.description": "CodexBar melacak jangka waktu penggunaan, saldo kredit, dan hitungan mundur penyetelan ulang di seluruh penyedia yang sebenarnya Anda bayar — masing-masing satu item status, atau gabungkan menjadi satu.", "hero.download": "Unduh untuk macOS", "hero.fineprint": "Gratis dan sumber terbuka · macOS 14+ · Universal melalui GitHub Releases dan Homebrew", - "providers.title": "60 penyedia,{mobileBreak}satu bilah menu", + "providers.title": "61 penyedia,{mobileBreak}satu bilah menu", "providers.description": "Penyedia populer menjadi item status dengan jendela penggunaannya sendiri, hitung mundur pengaturan ulang, bagan, dan menu penyedia.", "providers.yourProvider": "Penyedia Anda", "providers.authoringGuide": "Panduan penulisan", @@ -2478,8 +2478,8 @@ export const localeMessages = { }, "pl": { "meta.title": "CodexBar — każdy limit kodowania AI na pasku menu", - "meta.description": "Mała aplikacja z paskiem menu macOS, która śledzi okna użycia dostawcy kodowania AI, kredyty, koszty i resety u 60 dostawców — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i nie tylko.", - "meta.ogDescription": "Śledź okresy użytkowania, kredyty i resety u 60 dostawców kodowania AI za pomocą paska menu macOS.", + "meta.description": "Mała aplikacja z paskiem menu macOS, która śledzi okna użycia dostawcy kodowania AI, kredyty, koszty i resety u 61 dostawców — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i nie tylko.", + "meta.ogDescription": "Śledź okresy użytkowania, kredyty i resety u 61 dostawców kodowania AI za pomocą paska menu macOS.", "nav.primary": "Podstawowy", "nav.language": "Język", "nav.docs": "Dokumenty", @@ -2492,7 +2492,7 @@ export const localeMessages = { "hero.description": "CodexBar śledzi okna użytkowania, salda kredytów i resetuje odliczanie u dostawców, za których faktycznie płacisz — po jednym statusie dla każdego lub połącz je w jeden.", "hero.download": "Pobierz dla macOS", "hero.fineprint": "Darmowe i otwarte oprogramowanie · macOS 14+ · Wersja uniwersalna przez GitHub Releases i Homebrew", - "providers.title": "60 dostawców,{mobileBreak}jeden pasek menu", + "providers.title": "61 dostawców,{mobileBreak}jeden pasek menu", "providers.description": "Popularni dostawcy stają się elementami statusu z własnymi oknami użytkowania, resetowaniem odliczania, wykresami i menu dostawców.", "providers.yourProvider": "Twój dostawca", "providers.authoringGuide": "Przewodnik autorski", @@ -2618,8 +2618,8 @@ export const localeMessages = { }, "fa": { "meta.title": "CodexBar - هر محدودیت کدنویسی هوش مصنوعی در نوار منو شما", - "meta.description": "یک برنامه نوار منو کوچک macOS که پنجره‌های استفاده از ارائه‌دهنده کدنویسی هوش مصنوعی، اعتبارات، هزینه‌ها، و بازنشانی را در بین 60 ارائه‌دهنده - Codex، OpenAI، Claude، Cursor، Gemini، Copilot، LiteLLM و موارد دیگر بازنشانی می‌کند.", - "meta.ogDescription": "پنجره‌های استفاده، اعتبارات و بازنشانی‌ها را در بین 60 ارائه‌دهنده کدنویسی هوش مصنوعی از نوار منوی macOS خود ردیابی کنید.", + "meta.description": "یک برنامه نوار منو کوچک macOS که پنجره‌های استفاده از ارائه‌دهنده کدنویسی هوش مصنوعی، اعتبارات، هزینه‌ها، و بازنشانی را در بین 61 ارائه‌دهنده - Codex، OpenAI، Claude، Cursor، Gemini، Copilot، LiteLLM و موارد دیگر بازنشانی می‌کند.", + "meta.ogDescription": "پنجره‌های استفاده، اعتبارات و بازنشانی‌ها را در بین 61 ارائه‌دهنده کدنویسی هوش مصنوعی از نوار منوی macOS خود ردیابی کنید.", "nav.primary": "اولیه", "nav.language": "زبان", "nav.docs": "اسناد", @@ -2632,7 +2632,7 @@ export const localeMessages = { "hero.description": "CodexBar پنجره‌های استفاده، مانده اعتبار و بازنشانی شمارش معکوس را در سراسر ارائه‌دهندگانی که واقعاً برایشان پول پرداخت می‌کنید ردیابی می‌کند - هر کدام یک مورد وضعیت، یا آنها را در یکی ادغام کنید.", "hero.download": "دانلود برای macOS", "hero.fineprint": "رایگان و متن‌باز · macOS 14+ · نسخهٔ یونیورسال از GitHub Releases و Homebrew", - "providers.title": "60 ارائه دهنده،{mobileBreak}یک نوار منو", + "providers.title": "61 ارائه دهنده،{mobileBreak}یک نوار منو", "providers.description": "ارائه‌دهندگان محبوب با پنجره‌های استفاده خاص خود، شمارش معکوس، نمودارها و منوهای ارائه‌دهنده را بازنشانی می‌کنند.", "providers.yourProvider": "ارائه دهنده شما", "providers.authoringGuide": "راهنمای نگارش", @@ -2758,8 +2758,8 @@ export const localeMessages = { }, "th": { "meta.title": "CodexBar — ทุกขีดจำกัดการเข้ารหัส AI ในแถบเมนูของคุณ", - "meta.description": "แอปแถบเมนู macOS ขนาดเล็กที่ติดตามกรอบเวลาการใช้งานของผู้ให้บริการเข้ารหัส AI เครดิต ต้นทุน และการรีเซ็ตในผู้ให้บริการ 60 ราย — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM และอีกมากมาย", - "meta.ogDescription": "ติดตามกรอบเวลาการใช้งาน เครดิต และการรีเซ็ตในผู้ให้บริการการเข้ารหัส AI 60 รายจากแถบเมนู macOS", + "meta.description": "แอปแถบเมนู macOS ขนาดเล็กที่ติดตามกรอบเวลาการใช้งานของผู้ให้บริการเข้ารหัส AI เครดิต ต้นทุน และการรีเซ็ตในผู้ให้บริการ 61 ราย — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM และอีกมากมาย", + "meta.ogDescription": "ติดตามกรอบเวลาการใช้งาน เครดิต และการรีเซ็ตในผู้ให้บริการการเข้ารหัส AI 61 รายจากแถบเมนู macOS", "nav.primary": "หลัก", "nav.language": "ภาษา", "nav.docs": "เอกสาร", @@ -2772,7 +2772,7 @@ export const localeMessages = { "hero.description": "CodexBar ติดตามกรอบเวลาการใช้งาน ยอดเครดิต และรีเซ็ตการนับถอยหลังของผู้ให้บริการที่คุณชำระเงินจริง — รายการสถานะแต่ละรายการ หรือรวมรายการเหล่านั้นเป็นรายการเดียว", "hero.download": "ดาวน์โหลดสำหรับ macOS", "hero.fineprint": "ฟรีและโอเพ่นซอร์ส · macOS 14+ · รุ่น Universal ผ่าน GitHub Releases และ Homebrew", - "providers.title": "ผู้ให้บริการ 60 ราย{mobileBreak}หนึ่งแถบเมนู", + "providers.title": "ผู้ให้บริการ 61 ราย{mobileBreak}หนึ่งแถบเมนู", "providers.description": "ผู้ให้บริการยอดนิยมจะกลายเป็นรายการสถานะที่มีหน้าต่างการใช้งานของตนเอง รีเซ็ตการนับถอยหลัง แผนภูมิ และเมนูของผู้ให้บริการ", "providers.yourProvider": "ผู้ให้บริการของคุณ", "providers.authoringGuide": "คู่มือการเขียน", @@ -2898,8 +2898,8 @@ export const localeMessages = { }, "gl": { "meta.title": "CodexBar — todos os límites de programación con IA na túa barra de menús", - "meta.description": "Unha pequena aplicación de barra de menús para macOS que controla as xanelas de uso, os créditos, os custos e os restablecementos de 60 provedores de programación con IA — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e máis.", - "meta.ogDescription": "Controla as xanelas de uso, os créditos e os restablecementos de 60 provedores de programación con IA desde a barra de menús de macOS.", + "meta.description": "Unha pequena aplicación de barra de menús para macOS que controla as xanelas de uso, os créditos, os custos e os restablecementos de 61 provedores de programación con IA — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e máis.", + "meta.ogDescription": "Controla as xanelas de uso, os créditos e os restablecementos de 61 provedores de programación con IA desde a barra de menús de macOS.", "nav.primary": "Principal", "nav.language": "Idioma", "nav.docs": "Documentación", @@ -2912,7 +2912,7 @@ export const localeMessages = { "hero.description": "CodexBar controla as xanelas de uso, os saldos de crédito e as contas atrás ata o restablecemento dos provedores polos que realmente pagas — un elemento de estado para cada un ou todos combinados nun só.", "hero.download": "Descargar para macOS", "hero.fineprint": "Gratuíto e de código aberto · macOS 14+ · Universal mediante GitHub Releases e Homebrew", - "providers.title": "60 provedores,{mobileBreak}unha barra de menús", + "providers.title": "61 provedores,{mobileBreak}unha barra de menús", "providers.description": "Os provedores populares convértense en elementos de estado coas súas propias xanelas de uso, contas atrás de restablecemento, gráficas e menús.", "providers.yourProvider": "O teu provedor", "providers.authoringGuide": "Guía de creación", @@ -3038,8 +3038,8 @@ export const localeMessages = { }, "ca": { "meta.title": "CodexBar: tots els límits de la programació amb IA a la barra de menús", - "meta.description": "Una petita aplicació de barra de menús per a macOS que fa un seguiment de les finestres d'ús, els crèdits, els costos i els restabliments dels proveïdors de programació amb IA: 60 proveïdors, entre els quals Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i més.", - "meta.ogDescription": "Feu el seguiment de les finestres d'ús, els crèdits i els restabliments de 60 proveïdors de programació amb IA des de la barra de menús del macOS.", + "meta.description": "Una petita aplicació de barra de menús per a macOS que fa un seguiment de les finestres d'ús, els crèdits, els costos i els restabliments dels proveïdors de programació amb IA: 61 proveïdors, entre els quals Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i més.", + "meta.ogDescription": "Feu el seguiment de les finestres d'ús, els crèdits i els restabliments de 61 proveïdors de programació amb IA des de la barra de menús del macOS.", "nav.primary": "Navegació principal", "nav.language": "Llengua", "nav.docs": "Documentació", @@ -3052,7 +3052,7 @@ export const localeMessages = { "hero.description": "El CodexBar fa el seguiment de les finestres d'ús, els saldos de crèdit i els comptes enrere fins al restabliment dels proveïdors pels quals realment pagueu: un element d'estat per a cadascun, o combineu-los tots en un de sol.", "hero.download": "Baixeu per al macOS", "hero.fineprint": "Gratuït i de codi obert · macOS 14+ · Universal mitjançant GitHub Releases i Homebrew", - "providers.title": "60 proveïdors,{mobileBreak}una barra de menús", + "providers.title": "61 proveïdors,{mobileBreak}una barra de menús", "providers.description": "Els proveïdors populars es converteixen en elements d'estat amb les seves pròpies finestres d'ús, comptes enrere de restabliment, gràfics i menús de proveïdor.", "providers.yourProvider": "El vostre proveïdor", "providers.authoringGuide": "Guia de creació", @@ -3178,8 +3178,8 @@ export const localeMessages = { }, "sv": { "meta.title": "CodexBar — varje AI-kodningsgräns i din menyrad", - "meta.description": "En liten macOS menyradsapp som spårar AI-kodningsleverantörers användningsfönster, krediter, kostnader och återställningar hos 60 leverantörer – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM och mer.", - "meta.ogDescription": "Spåra användningsfönster, krediter och återställningar hos 60 AI-kodningsleverantörer från din macOS-menyrad.", + "meta.description": "En liten macOS menyradsapp som spårar AI-kodningsleverantörers användningsfönster, krediter, kostnader och återställningar hos 61 leverantörer – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM och mer.", + "meta.ogDescription": "Spåra användningsfönster, krediter och återställningar hos 61 AI-kodningsleverantörer från din macOS-menyrad.", "nav.primary": "Primär", "nav.language": "Språk", "nav.docs": "Dokument", @@ -3192,7 +3192,7 @@ export const localeMessages = { "hero.description": "CodexBar spårar användningsfönster, kreditsaldon och återställningsnedräkningar för de leverantörer du faktiskt betalar för – en statuspost var, eller slå samman dem till en.", "hero.download": "Ladda ner för macOS", "hero.fineprint": "Gratis och öppen källkod · macOS 14+ · Universal via GitHub Releases och Homebrew", - "providers.title": "60 leverantörer,{mobileBreak}en menyrad", + "providers.title": "61 leverantörer,{mobileBreak}en menyrad", "providers.description": "Populära leverantörer blir statusobjekt med sina egna användningsfönster, återställer nedräkningar, diagram och leverantörsmenyer.", "providers.yourProvider": "Din leverantör", "providers.authoringGuide": "Författarguide", diff --git a/docs/social.html b/docs/social.html index 9f068160a6..eec0aa5876 100644 --- a/docs/social.html +++ b/docs/social.html @@ -199,7 +199,7 @@

Every AI coding limit, in your menu bar.

-

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

+

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