From 8417b65f58fd61d5348d982ecde5e58fea6f492c Mon Sep 17 00:00:00 2001 From: Bryan Tegomoh Date: Wed, 18 Mar 2026 22:26:52 -0500 Subject: [PATCH 1/3] Add Grok (xAI) provider with Management API billing support Two-strategy fetch pipeline: - Primary: Management API billing data (spending limits + invoice preview) - Fallback: Regular API key status check Settings: API key, Management key (for billing), Team ID --- .../Grok/GrokProviderImplementation.swift | 104 +++++++ .../Providers/Grok/GrokSettingsStore.swift | 35 +++ .../ProviderImplementationRegistry.swift | 1 + .../CodexBar/Resources/ProviderIcon-grok.svg | 3 + Sources/CodexBar/UsageStore.swift | 5 + Sources/CodexBarCLI/TokenAccountCLI.swift | 2 +- .../Config/ProviderConfigEnvironment.swift | 17 +- .../CodexBarCore/Logging/LogCategories.swift | 1 + .../Grok/GrokProviderDescriptor.swift | 125 ++++++++ .../Providers/Grok/GrokSettingsReader.swift | 88 ++++++ .../Providers/Grok/GrokUsageFetcher.swift | 269 ++++++++++++++++++ .../Providers/ProviderTokenResolver.swift | 10 + .../CodexBarCore/Providers/Providers.swift | 2 + .../Vendored/CostUsage/CostUsageScanner.swift | 2 +- .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + 16 files changed, 665 insertions(+), 3 deletions(-) create mode 100644 Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift create mode 100644 Sources/CodexBar/Providers/Grok/GrokSettingsStore.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-grok.svg create mode 100644 Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/Grok/GrokSettingsReader.swift create mode 100644 Sources/CodexBarCore/Providers/Grok/GrokUsageFetcher.swift diff --git a/Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift b/Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift new file mode 100644 index 000000000..d566f06e9 --- /dev/null +++ b/Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift @@ -0,0 +1,104 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct GrokProviderImplementation: ProviderImplementation { + let id: UsageProvider = .grok + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.grokAPIToken + _ = settings.grokManagementToken + _ = settings.grokTeamID + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + _ = context + return nil + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if GrokSettingsReader.apiKey(environment: context.environment) != nil { + return true + } + if GrokSettingsReader.managementKey(environment: context.environment) != nil { + return true + } + let apiToken = context.settings.grokAPIToken.trimmingCharacters(in: .whitespacesAndNewlines) + let mgmtToken = context.settings.grokManagementToken.trimmingCharacters(in: .whitespacesAndNewlines) + return !apiToken.isEmpty || !mgmtToken.isEmpty + } + + @MainActor + func settingsPickers(context _: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + [] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "grok-api-key", + title: "API key", + subtitle: "For key status monitoring. Get your key from console.x.ai.", + kind: .secure, + placeholder: "xai-...", + binding: context.stringBinding(\.grokAPIToken), + actions: [ + ProviderSettingsActionDescriptor( + id: "grok-open-console", + title: "Open Console", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://console.x.ai") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: nil, + onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "grok-management-key", + title: "Management key", + subtitle: "For billing/usage tracking. Console > Settings > Management Keys.", + kind: .secure, + placeholder: "xai-mgmt-...", + binding: context.stringBinding(\.grokManagementToken), + actions: [ + ProviderSettingsActionDescriptor( + id: "grok-open-management-keys", + title: "Get Key", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://console.x.ai/team/default/management-keys") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: nil, + onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "grok-team-id", + title: "Team ID", + subtitle: "Your xAI team identifier. Usually \"default\" for personal accounts.", + kind: .text, + placeholder: "default", + binding: context.stringBinding(\.grokTeamID), + actions: [], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/Grok/GrokSettingsStore.swift b/Sources/CodexBar/Providers/Grok/GrokSettingsStore.swift new file mode 100644 index 000000000..b1ebd0932 --- /dev/null +++ b/Sources/CodexBar/Providers/Grok/GrokSettingsStore.swift @@ -0,0 +1,35 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var grokAPIToken: String { + get { self.configSnapshot.providerConfig(for: .grok)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .grok) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .grok, field: "apiKey", value: newValue) + } + } + + /// Management API key, stored in the cookieHeader field (Grok uses API tokens, not cookies) + var grokManagementToken: String { + get { self.configSnapshot.providerConfig(for: .grok)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .grok) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .grok, field: "managementKey", value: newValue) + } + } + + /// Team ID, stored in the workspaceID field + var grokTeamID: String { + get { self.configSnapshot.providerConfig(for: .grok)?.workspaceID ?? "default" } + set { + self.updateProviderConfig(provider: .grok) { entry in + entry.workspaceID = self.normalizedConfigValue(newValue) + } + } + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 7938b3d49..ced1492f4 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -35,6 +35,7 @@ enum ProviderImplementationRegistry { case .synthetic: SyntheticProviderImplementation() case .openrouter: OpenRouterProviderImplementation() case .warp: WarpProviderImplementation() + case .grok: GrokProviderImplementation() } } diff --git a/Sources/CodexBar/Resources/ProviderIcon-grok.svg b/Sources/CodexBar/Resources/ProviderIcon-grok.svg new file mode 100644 index 000000000..96936a3ec --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-grok.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 5d863c240..6d7e502fd 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1254,6 +1254,11 @@ extension UsageStore { let hasAny = resolution != nil let source = resolution?.source.rawValue ?? "none" return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" + case .grok: + let resolution = ProviderTokenResolver.grokResolution() + let hasAny = resolution != nil + let source = resolution?.source.rawValue ?? "none" + return "XAI_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" case .gemini, .antigravity, .opencode, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi, .kimik2, .jetbrains: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index dc62e5a11..0d709ba7e 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -169,7 +169,7 @@ struct TokenAccountCLIContext { return self.makeSnapshot( jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings( ideBasePath: nil)) - case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp: + case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp, .grok: return nil } } diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index 9fabc4b80..7d2d101e0 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -6,8 +6,19 @@ public enum ProviderConfigEnvironment { provider: UsageProvider, config: ProviderConfig?) -> [String: String] { - guard let apiKey = config?.sanitizedAPIKey, !apiKey.isEmpty else { return base } var env = base + + // Grok needs special handling: management key and team ID from config fields + if provider == .grok, let config { + if let mgmtKey = config.sanitizedCookieHeader, !mgmtKey.isEmpty { + env[GrokSettingsReader.managementKeyEnvironmentKey] = mgmtKey + } + if let teamID = config.workspaceID, !teamID.isEmpty { + env[GrokSettingsReader.teamIDEnvironmentKey] = teamID + } + } + + guard let apiKey = config?.sanitizedAPIKey, !apiKey.isEmpty else { return env } switch provider { case .zai: env[ZaiSettingsReader.apiTokenKey] = apiKey @@ -29,6 +40,10 @@ public enum ProviderConfigEnvironment { } case .openrouter: env[OpenRouterSettingsReader.envKey] = apiKey + case .grok: + if let key = GrokSettingsReader.apiKeyEnvironmentKeys.first { + env[key] = apiKey + } default: break } diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index 37a7726ef..478546f1f 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -18,6 +18,7 @@ public enum LogCategories { public static let creditsPurchase = "creditsPurchase" public static let cursorLogin = "cursor-login" public static let geminiProbe = "gemini-probe" + public static let grokUsage = "grok-usage" public static let keychainCache = "keychain-cache" public static let keychainMigration = "keychain-migration" public static let keychainPreflight = "keychain-preflight" diff --git a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift new file mode 100644 index 000000000..56c1d723b --- /dev/null +++ b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift @@ -0,0 +1,125 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum GrokProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .grok, + metadata: ProviderMetadata( + id: .grok, + displayName: "Grok", + sessionLabel: "Usage", + weeklyLabel: "Balance", + opusLabel: nil, + supportsOpus: false, + supportsCredits: true, + creditsHint: "Credit balance from xAI Management API", + toggleTitle: "Show Grok usage", + cliName: "grok", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: "https://console.x.ai", + statusPageURL: nil, + statusLinkURL: "https://status.x.ai"), + branding: ProviderBranding( + iconStyle: .grok, + iconResourceName: "ProviderIcon-grok", + color: ProviderColor(red: 0 / 255, green: 0 / 255, blue: 0 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Grok cost summary is not yet supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in + [GrokManagementFetchStrategy(), GrokAPIFetchStrategy()] + })), + cli: ProviderCLIConfig( + name: "grok", + aliases: ["xai"], + versionDetector: nil)) + } +} + +// MARK: - Management API Strategy (primary: billing data) + +struct GrokManagementFetchStrategy: ProviderFetchStrategy { + let id: String = "grok.management" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.resolveManagementKey(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let managementKey = Self.resolveManagementKey(environment: context.env) else { + throw GrokSettingsError.missingManagementKey + } + let teamID = GrokSettingsReader.teamID(environment: context.env) + let billing = try await GrokUsageFetcher.fetchBilling( + managementKey: managementKey, + teamID: teamID, + environment: context.env) + return self.makeResult( + usage: billing.toUsageSnapshot(), + sourceLabel: "management-api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + true // Fall back to regular API key strategy + } + + private static func resolveManagementKey(environment: [String: String]) -> String? { + GrokSettingsReader.managementKey(environment: environment) + } +} + +// MARK: - Regular API Strategy (fallback: key status) + +struct GrokAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "grok.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.resolveToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = Self.resolveToken(environment: context.env) else { + throw GrokSettingsError.missingToken + } + let keyStatus = try await GrokUsageFetcher.fetchKeyStatus( + apiKey: apiKey, + environment: context.env) + return self.makeResult( + usage: keyStatus.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func resolveToken(environment: [String: String]) -> String? { + ProviderTokenResolver.grokToken(environment: environment) + } +} + +// MARK: - Errors + +/// Errors related to Grok settings +public enum GrokSettingsError: LocalizedError, Sendable { + case missingToken + case missingManagementKey + + public var errorDescription: String? { + switch self { + case .missingToken: + "xAI API key not configured. Set XAI_API_KEY environment variable or configure in Settings." + case .missingManagementKey: + "xAI Management key not configured. Set XAI_MANAGEMENT_KEY or configure in Settings." + } + } +} diff --git a/Sources/CodexBarCore/Providers/Grok/GrokSettingsReader.swift b/Sources/CodexBarCore/Providers/Grok/GrokSettingsReader.swift new file mode 100644 index 000000000..e50ec1955 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Grok/GrokSettingsReader.swift @@ -0,0 +1,88 @@ +import Foundation + +/// Reads Grok/xAI settings from environment variables +public struct GrokSettingsReader: Sendable { + public static let apiKeyEnvironmentKeys = [ + "XAI_API_KEY", + "GROK_API_KEY", + ] + + public static let managementKeyEnvironmentKey = "XAI_MANAGEMENT_KEY" + public static let teamIDEnvironmentKey = "XAI_TEAM_ID" + + /// Returns the API key from environment if present and non-empty + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + for key in self.apiKeyEnvironmentKeys { + guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { + continue + } + let cleaned = Self.cleaned(raw) + if !cleaned.isEmpty { + return cleaned + } + } + return nil + } + + /// Returns the Management API key from environment if present + public static func managementKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + guard let raw = environment[Self.managementKeyEnvironmentKey], + !Self.cleaned(raw).isEmpty + else { + return nil + } + return Self.cleaned(raw) + } + + /// Returns the team ID, defaulting to "default" + public static func teamID( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String + { + let raw = environment[Self.teamIDEnvironmentKey] ?? "" + let cleaned = Self.cleaned(raw) + return cleaned.isEmpty ? "default" : cleaned + } + + /// Returns the inference API URL, defaulting to production endpoint + public static func apiURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL { + if let override = environment["XAI_API_URL"], + let url = URL(string: Self.cleaned(override)) + { + return url + } + return URL(string: "https://api.x.ai/v1")! + } + + /// Returns the Management API URL + public static func managementAPIURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let override = environment["XAI_MANAGEMENT_API_URL"], + let url = URL(string: Self.cleaned(override)) + { + return url + } + return URL(string: "https://management-api.x.ai/v1")! + } + + static func cleaned(_ raw: String?) -> String { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return "" + } + + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Sources/CodexBarCore/Providers/Grok/GrokUsageFetcher.swift b/Sources/CodexBarCore/Providers/Grok/GrokUsageFetcher.swift new file mode 100644 index 000000000..ae2b0f1cc --- /dev/null +++ b/Sources/CodexBarCore/Providers/Grok/GrokUsageFetcher.swift @@ -0,0 +1,269 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +// MARK: - Regular API Key Response + +/// Grok/xAI API key info response from GET /v1/api-key +public struct GrokAPIKeyResponse: Decodable, Sendable { + public let apiKeyId: String? + public let name: String? + public let redactedApiKey: String? + public let teamBlocked: Bool? + public let apiKeyBlocked: Bool? + public let apiKeyDisabled: Bool? + + private enum CodingKeys: String, CodingKey { + case apiKeyId = "api_key_id" + case name + case redactedApiKey = "redacted_api_key" + case teamBlocked = "team_blocked" + case apiKeyBlocked = "api_key_blocked" + case apiKeyDisabled = "api_key_disabled" + } + + /// Whether this API key is active and usable + public var isActive: Bool { + !(self.teamBlocked ?? false) && !(self.apiKeyBlocked ?? false) && !(self.apiKeyDisabled ?? false) + } +} + +// MARK: - Management API Billing Responses + +/// Spending limits from GET /v1/billing/teams/{team_id}/postpaid/spending-limits +public struct GrokSpendingLimitsResponse: Decodable, Sendable { + public let hardLimit: Double? + public let softLimit: Double? + public let monthlyBudget: Double? + public let currentSpend: Double? + + private enum CodingKeys: String, CodingKey { + case hardLimit = "hard_limit" + case softLimit = "soft_limit" + case monthlyBudget = "monthly_budget" + case currentSpend = "current_spend" + } +} + +/// Invoice preview from GET /v1/billing/teams/{team_id}/postpaid/invoice/preview +public struct GrokInvoicePreviewResponse: Decodable, Sendable { + public let totalAmount: Double? + public let amountDue: Double? + public let periodStart: String? + public let periodEnd: String? + + private enum CodingKeys: String, CodingKey { + case totalAmount = "total_amount" + case amountDue = "amount_due" + case periodStart = "period_start" + case periodEnd = "period_end" + } +} + +// MARK: - Snapshots + +/// Complete Grok usage snapshot from Management API billing data +public struct GrokBillingSnapshot: Sendable { + public let usageCap: Double + public let totalUsage: Double + public let remaining: Double + public let usedPercent: Double + public let updatedAt: Date + + public init(usageCap: Double, totalUsage: Double, remaining: Double, usedPercent: Double, updatedAt: Date) { + self.usageCap = usageCap + self.totalUsage = totalUsage + self.remaining = remaining + self.usedPercent = usedPercent + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let primary = RateWindow( + usedPercent: self.usedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil) + + let balanceStr = String(format: "$%.2f", self.remaining) + let capStr = String(format: "$%.2f", self.usageCap) + let identity = ProviderIdentitySnapshot( + providerID: .grok, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Balance: \(balanceStr) / \(capStr)") + + return UsageSnapshot( + primary: primary, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} + +/// Grok key-only snapshot (fallback when no Management key) +public struct GrokKeySnapshot: Sendable { + public let keyName: String? + public let redactedKey: String? + public let isActive: Bool + public let updatedAt: Date + + public init(keyName: String?, redactedKey: String?, isActive: Bool, updatedAt: Date) { + self.keyName = keyName + self.redactedKey = redactedKey + self.isActive = isActive + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let statusText = self.isActive ? "Active" : "Blocked" + let loginText: String = if let keyName, !keyName.isEmpty { + "Key: \(keyName) (\(statusText))" + } else if let redactedKey, !redactedKey.isEmpty { + "Key: \(redactedKey) (\(statusText))" + } else { + "Key: \(statusText)" + } + + let identity = ProviderIdentitySnapshot( + providerID: .grok, + accountEmail: nil, + accountOrganization: nil, + loginMethod: loginText) + + return UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} + +// MARK: - Errors + +/// Errors that can occur during Grok usage fetching +public enum GrokUsageError: LocalizedError, Sendable { + case missingCredentials + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing xAI/Grok API key." + case let .networkError(message): + "xAI network error: \(message)" + case let .apiError(message): + "xAI API error: \(message)" + case let .parseFailed(message): + "Failed to parse xAI response: \(message)" + } + } +} + +// MARK: - Fetcher + +/// Fetches usage/billing data from the xAI APIs +public struct GrokUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.grokUsage) + private static let requestTimeoutSeconds: TimeInterval = 15 + + // MARK: Management API (billing data) + + /// Fetches billing data from the xAI Management API + public static func fetchBilling( + managementKey: String, + teamID: String, + environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> GrokBillingSnapshot + { + guard !managementKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw GrokUsageError.missingCredentials + } + + let baseURL = GrokSettingsReader.managementAPIURL(environment: environment) + + // Fetch spending limits + let limitsURL = baseURL + .appendingPathComponent("billing/teams/\(teamID)/postpaid/spending-limits") + let limitsResponse: GrokSpendingLimitsResponse = try await Self.fetchJSON( + url: limitsURL, bearerToken: managementKey) + + // Fetch invoice preview for current usage + let invoiceURL = baseURL + .appendingPathComponent("billing/teams/\(teamID)/postpaid/invoice/preview") + let invoiceResponse: GrokInvoicePreviewResponse = try await Self.fetchJSON( + url: invoiceURL, bearerToken: managementKey) + + let usageCap = limitsResponse.hardLimit ?? limitsResponse.monthlyBudget ?? 0 + let totalUsage = invoiceResponse.totalAmount ?? invoiceResponse.amountDue ?? 0 + let remaining = max(0, usageCap - totalUsage) + let usedPercent = usageCap > 0 ? min(100, (totalUsage / usageCap) * 100) : 0 + + return GrokBillingSnapshot( + usageCap: usageCap, + totalUsage: totalUsage, + remaining: remaining, + usedPercent: usedPercent, + updatedAt: Date()) + } + + // MARK: Regular API (key status) + + /// Fetches API key info from xAI using the provided API key + public static func fetchKeyStatus( + apiKey: String, + environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> GrokKeySnapshot + { + guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw GrokUsageError.missingCredentials + } + + let baseURL = GrokSettingsReader.apiURL(environment: environment) + let keyURL = baseURL.appendingPathComponent("api-key") + + let keyResponse: GrokAPIKeyResponse = try await Self.fetchJSON( + url: keyURL, bearerToken: apiKey) + + return GrokKeySnapshot( + keyName: keyResponse.name, + redactedKey: keyResponse.redactedApiKey, + isActive: keyResponse.isActive, + updatedAt: Date()) + } + + // MARK: Shared HTTP helper + + private static func fetchJSON(url: URL, bearerToken: String) async throws -> T { + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.requestTimeoutSeconds + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw GrokUsageError.networkError("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "HTTP \(httpResponse.statusCode)" + Self.log.error("xAI API returned \(httpResponse.statusCode): \(LogRedactor.redact(body))") + throw GrokUsageError.apiError("HTTP \(httpResponse.statusCode)") + } + + do { + let decoder = JSONDecoder() + return try decoder.decode(T.self, from: data) + } catch let error as DecodingError { + Self.log.error("xAI JSON decoding error: \(error.localizedDescription)") + throw GrokUsageError.parseFailed(error.localizedDescription) + } + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift index ada9fac8d..3ed0eb759 100644 --- a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift +++ b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift @@ -61,6 +61,10 @@ public enum ProviderTokenResolver { self.openRouterResolution(environment: environment)?.token } + public static func grokToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.grokResolution(environment: environment)?.token + } + public static func zaiResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { @@ -141,6 +145,12 @@ public enum ProviderTokenResolver { self.resolveEnv(OpenRouterSettingsReader.apiToken(environment: environment)) } + public static func grokResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(GrokSettingsReader.apiKey(environment: environment)) + } + private static func cleaned(_ raw: String?) -> String? { guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index f48eefe43..ad48cfa1b 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -25,6 +25,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case synthetic case warp case openrouter + case grok } // swiftformat:enable sortDeclarations @@ -52,6 +53,7 @@ public enum IconStyle: Sendable, CaseIterable { case synthetic case warp case openrouter + case grok case combined } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 60e57996c..486c86317 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -71,7 +71,7 @@ enum CostUsageScanner { } return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered) case .zai, .gemini, .antigravity, .cursor, .opencode, .factory, .copilot, .minimax, .kilo, .kiro, .kimi, - .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp: + .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .grok: return emptyReport } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index eb0d00574..a2076f408 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -67,6 +67,7 @@ enum ProviderChoice: String, AppEnum { case .synthetic: return nil // Synthetic not yet supported in widgets case .openrouter: return nil // OpenRouter not yet supported in widgets case .warp: return nil // Warp not yet supported in widgets + case .grok: return nil // Grok not yet supported in widgets } } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index fbb8c5d9c..a079c5c38 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -279,6 +279,7 @@ private struct ProviderSwitchChip: View { case .synthetic: "Synthetic" case .openrouter: "OpenRouter" case .warp: "Warp" + case .grok: "Grok" } } } @@ -618,6 +619,8 @@ enum WidgetColors { Color(red: 111 / 255, green: 66 / 255, blue: 193 / 255) // OpenRouter purple case .warp: Color(red: 147 / 255, green: 139 / 255, blue: 180 / 255) + case .grok: + Color(red: 0 / 255, green: 0 / 255, blue: 0 / 255) // Grok/xAI black } } } From 3a2f66427920b17ef9d9b20c184fc50bacc23a03 Mon Sep 17 00:00:00 2001 From: Bryan Tegomoh Date: Wed, 18 Mar 2026 22:54:39 -0500 Subject: [PATCH 2/3] Fix env var names and align with xAI docs and CodexBar patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - XAI_MANAGEMENT_KEY → XAI_MANAGEMENT_API_KEY (per official xAI docs) - Remove unofficial GROK_API_KEY (only XAI_API_KEY is documented) - cleaned() returns String? to match Zai/MiniMax/Kilo pattern - Move GrokSettingsError to GrokSettingsReader (matches ZaiSettingsError placement) - Add ensureGrokAPITokenLoaded() stub (matches ensureZaiAPITokenLoaded pattern) --- .../Grok/GrokProviderImplementation.swift | 1 + .../Providers/Grok/GrokSettingsStore.swift | 2 + .../Grok/GrokProviderDescriptor.swift | 16 ----- .../Providers/Grok/GrokSettingsReader.swift | 61 ++++++++++--------- 4 files changed, 34 insertions(+), 46 deletions(-) diff --git a/Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift b/Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift index d566f06e9..8620f0c87 100644 --- a/Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift @@ -34,6 +34,7 @@ struct GrokProviderImplementation: ProviderImplementation { if GrokSettingsReader.managementKey(environment: context.environment) != nil { return true } + context.settings.ensureGrokAPITokenLoaded() let apiToken = context.settings.grokAPIToken.trimmingCharacters(in: .whitespacesAndNewlines) let mgmtToken = context.settings.grokManagementToken.trimmingCharacters(in: .whitespacesAndNewlines) return !apiToken.isEmpty || !mgmtToken.isEmpty diff --git a/Sources/CodexBar/Providers/Grok/GrokSettingsStore.swift b/Sources/CodexBar/Providers/Grok/GrokSettingsStore.swift index b1ebd0932..ec7527aa1 100644 --- a/Sources/CodexBar/Providers/Grok/GrokSettingsStore.swift +++ b/Sources/CodexBar/Providers/Grok/GrokSettingsStore.swift @@ -32,4 +32,6 @@ extension SettingsStore { } } } + + func ensureGrokAPITokenLoaded() {} } diff --git a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift index 56c1d723b..3b77530d8 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift @@ -107,19 +107,3 @@ struct GrokAPIFetchStrategy: ProviderFetchStrategy { } } -// MARK: - Errors - -/// Errors related to Grok settings -public enum GrokSettingsError: LocalizedError, Sendable { - case missingToken - case missingManagementKey - - public var errorDescription: String? { - switch self { - case .missingToken: - "xAI API key not configured. Set XAI_API_KEY environment variable or configure in Settings." - case .missingManagementKey: - "xAI Management key not configured. Set XAI_MANAGEMENT_KEY or configure in Settings." - } - } -} diff --git a/Sources/CodexBarCore/Providers/Grok/GrokSettingsReader.swift b/Sources/CodexBarCore/Providers/Grok/GrokSettingsReader.swift index e50ec1955..2f4d1ead7 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokSettingsReader.swift @@ -2,12 +2,9 @@ import Foundation /// Reads Grok/xAI settings from environment variables public struct GrokSettingsReader: Sendable { - public static let apiKeyEnvironmentKeys = [ - "XAI_API_KEY", - "GROK_API_KEY", - ] + public static let apiKeyEnvironmentKeys = ["XAI_API_KEY"] - public static let managementKeyEnvironmentKey = "XAI_MANAGEMENT_KEY" + public static let managementKeyEnvironmentKey = "XAI_MANAGEMENT_API_KEY" public static let teamIDEnvironmentKey = "XAI_TEAM_ID" /// Returns the API key from environment if present and non-empty @@ -15,15 +12,7 @@ public struct GrokSettingsReader: Sendable { environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { for key in self.apiKeyEnvironmentKeys { - guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), - !raw.isEmpty - else { - continue - } - let cleaned = Self.cleaned(raw) - if !cleaned.isEmpty { - return cleaned - } + if let token = self.cleaned(environment[key]) { return token } } return nil } @@ -32,27 +21,23 @@ public struct GrokSettingsReader: Sendable { public static func managementKey( environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { - guard let raw = environment[Self.managementKeyEnvironmentKey], - !Self.cleaned(raw).isEmpty - else { - return nil - } - return Self.cleaned(raw) + self.cleaned(environment[Self.managementKeyEnvironmentKey]) } /// Returns the team ID, defaulting to "default" public static func teamID( environment: [String: String] = ProcessInfo.processInfo.environment) -> String { - let raw = environment[Self.teamIDEnvironmentKey] ?? "" - let cleaned = Self.cleaned(raw) - return cleaned.isEmpty ? "default" : cleaned + guard let id = self.cleaned(environment[Self.teamIDEnvironmentKey]) else { + return "default" + } + return id } /// Returns the inference API URL, defaulting to production endpoint public static func apiURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL { - if let override = environment["XAI_API_URL"], - let url = URL(string: Self.cleaned(override)) + if let override = self.cleaned(environment["XAI_API_URL"]), + let url = URL(string: override) { return url } @@ -63,17 +48,17 @@ public struct GrokSettingsReader: Sendable { public static func managementAPIURL( environment: [String: String] = ProcessInfo.processInfo.environment) -> URL { - if let override = environment["XAI_MANAGEMENT_API_URL"], - let url = URL(string: Self.cleaned(override)) + if let override = self.cleaned(environment["XAI_MANAGEMENT_API_URL"]), + let url = URL(string: override) { return url } return URL(string: "https://management-api.x.ai/v1")! } - static func cleaned(_ raw: String?) -> String { + static func cleaned(_ raw: String?) -> String? { guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { - return "" + return nil } if (value.hasPrefix("\"") && value.hasSuffix("\"")) || @@ -83,6 +68,22 @@ public struct GrokSettingsReader: Sendable { value.removeLast() } - return value.trimmingCharacters(in: .whitespacesAndNewlines) + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} + +/// Errors related to Grok settings +public enum GrokSettingsError: LocalizedError, Sendable { + case missingToken + case missingManagementKey + + public var errorDescription: String? { + switch self { + case .missingToken: + "xAI API key not configured. Set XAI_API_KEY environment variable or configure in Settings." + case .missingManagementKey: + "xAI Management key not configured. Set XAI_MANAGEMENT_API_KEY or configure in Settings." + } } } From 49e351bd1abb765690ec1821279c1ca02e7c9832 Mon Sep 17 00:00:00 2001 From: Bryan Tegomoh Date: Fri, 20 Mar 2026 11:07:57 -0500 Subject: [PATCH 3/3] Fix descriptor registry, billing schema, and review feedback - Add .grok to ProviderDescriptorRegistry.descriptorsByID (startup crash) - Rewrite billing Decodable models to match actual xAI Management API response: nested spendingLimits with cent-string vals, coreInvoice with amountBeforeVat, effectiveSpendingLimit at top level - shouldFallback only on credential errors, not API/network failures - Fix management key URL to team-agnostic path - Trim workspaceID before env injection - Split missingCredentials into missingAPIKey / missingManagementKey - Fix management key placeholder to match actual format (xai-token-...) - Add explicit browserCookieOrder: nil to metadata --- .../Grok/GrokProviderImplementation.swift | 4 +- .../Config/ProviderConfigEnvironment.swift | 4 +- .../Grok/GrokProviderDescriptor.swift | 9 +- .../Providers/Grok/GrokUsageFetcher.swift | 102 ++++++++++++------ .../Providers/ProviderDescriptor.swift | 1 + 5 files changed, 82 insertions(+), 38 deletions(-) diff --git a/Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift b/Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift index 8620f0c87..fa361e4a5 100644 --- a/Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift @@ -74,7 +74,7 @@ struct GrokProviderImplementation: ProviderImplementation { title: "Management key", subtitle: "For billing/usage tracking. Console > Settings > Management Keys.", kind: .secure, - placeholder: "xai-mgmt-...", + placeholder: "xai-token-...", binding: context.stringBinding(\.grokManagementToken), actions: [ ProviderSettingsActionDescriptor( @@ -83,7 +83,7 @@ struct GrokProviderImplementation: ProviderImplementation { style: .link, isVisible: nil, perform: { - if let url = URL(string: "https://console.x.ai/team/default/management-keys") { + if let url = URL(string: "https://console.x.ai/settings/management-keys") { NSWorkspace.shared.open(url) } }), diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index 7d2d101e0..22174690e 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -13,7 +13,9 @@ public enum ProviderConfigEnvironment { if let mgmtKey = config.sanitizedCookieHeader, !mgmtKey.isEmpty { env[GrokSettingsReader.managementKeyEnvironmentKey] = mgmtKey } - if let teamID = config.workspaceID, !teamID.isEmpty { + if let teamID = config.workspaceID?.trimmingCharacters(in: .whitespacesAndNewlines), + !teamID.isEmpty + { env[GrokSettingsReader.teamIDEnvironmentKey] = teamID } } diff --git a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift index 3b77530d8..7fb6974e9 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift @@ -21,6 +21,7 @@ public enum GrokProviderDescriptor { defaultEnabled: false, isPrimaryProvider: false, usesAccountFallback: false, + browserCookieOrder: nil, dashboardURL: "https://console.x.ai", statusPageURL: nil, statusLinkURL: "https://status.x.ai"), @@ -67,8 +68,11 @@ struct GrokManagementFetchStrategy: ProviderFetchStrategy { sourceLabel: "management-api") } - func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { - true // Fall back to regular API key strategy + func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { + guard context.sourceMode == .auto else { return false } + if error is GrokSettingsError { return true } + if case GrokUsageError.missingManagementKey = error { return true } + return false } private static func resolveManagementKey(environment: [String: String]) -> String? { @@ -106,4 +110,3 @@ struct GrokAPIFetchStrategy: ProviderFetchStrategy { ProviderTokenResolver.grokToken(environment: environment) } } - diff --git a/Sources/CodexBarCore/Providers/Grok/GrokUsageFetcher.swift b/Sources/CodexBarCore/Providers/Grok/GrokUsageFetcher.swift index ae2b0f1cc..1918bcba5 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokUsageFetcher.swift @@ -31,33 +31,65 @@ public struct GrokAPIKeyResponse: Decodable, Sendable { // MARK: - Management API Billing Responses +/// Helper for xAI's `{"val": "12345"}` cent-string wrapper +struct GrokCentValue: Decodable, Sendable { + let val: String + + /// Converts from USD cents string to dollars + var dollars: Double { + guard let cents = Double(self.val) else { return 0 } + return cents / 100.0 + } +} + /// Spending limits from GET /v1/billing/teams/{team_id}/postpaid/spending-limits public struct GrokSpendingLimitsResponse: Decodable, Sendable { - public let hardLimit: Double? - public let softLimit: Double? - public let monthlyBudget: Double? - public let currentSpend: Double? + public let spendingLimits: SpendingLimits - private enum CodingKeys: String, CodingKey { - case hardLimit = "hard_limit" - case softLimit = "soft_limit" - case monthlyBudget = "monthly_budget" - case currentSpend = "current_spend" + public struct SpendingLimits: Decodable, Sendable { + let effectiveHardSl: GrokCentValue? + let hardSlAuto: GrokCentValue? + let softSl: GrokCentValue? + let effectiveSl: GrokCentValue? + } + + /// The effective spending limit in dollars + var effectiveLimitDollars: Double { + self.spendingLimits.effectiveSl?.dollars + ?? self.spendingLimits.effectiveHardSl?.dollars + ?? self.spendingLimits.softSl?.dollars + ?? 0 } } /// Invoice preview from GET /v1/billing/teams/{team_id}/postpaid/invoice/preview public struct GrokInvoicePreviewResponse: Decodable, Sendable { - public let totalAmount: Double? - public let amountDue: Double? - public let periodStart: String? - public let periodEnd: String? + public let coreInvoice: CoreInvoice + public let effectiveSpendingLimit: String? + public let defaultCredits: String? + public let billingCycle: BillingCycle? + + public struct CoreInvoice: Decodable, Sendable { + let amountBeforeVat: String? + let amountAfterVat: String? + let prepaidCreditsUsed: GrokCentValue? + } - private enum CodingKeys: String, CodingKey { - case totalAmount = "total_amount" - case amountDue = "amount_due" - case periodStart = "period_start" - case periodEnd = "period_end" + public struct BillingCycle: Decodable, Sendable { + let year: Int? + let month: Int? + } + + /// Current usage in dollars (from cents string) + var usageDollars: Double { + guard let cents = self.coreInvoice.amountBeforeVat, let val = Double(cents) else { return 0 } + return val / 100.0 + } + + /// Spending limit in dollars (from top-level cents string) + var limitDollars: Double { + guard let cents = self.effectiveSpendingLimit, let val = Double(cents) else { return 0 } + return val / 100.0 } } @@ -148,15 +180,18 @@ public struct GrokKeySnapshot: Sendable { /// Errors that can occur during Grok usage fetching public enum GrokUsageError: LocalizedError, Sendable { - case missingCredentials + case missingAPIKey + case missingManagementKey case networkError(String) case apiError(String) case parseFailed(String) public var errorDescription: String? { switch self { - case .missingCredentials: - "Missing xAI/Grok API key." + case .missingAPIKey: + "Missing xAI API key." + case .missingManagementKey: + "Missing xAI Management API key." case let .networkError(message): "xAI network error: \(message)" case let .apiError(message): @@ -183,25 +218,28 @@ public struct GrokUsageFetcher: Sendable { environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> GrokBillingSnapshot { guard !managementKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw GrokUsageError.missingCredentials + throw GrokUsageError.missingManagementKey } let baseURL = GrokSettingsReader.managementAPIURL(environment: environment) - // Fetch spending limits - let limitsURL = baseURL - .appendingPathComponent("billing/teams/\(teamID)/postpaid/spending-limits") - let limitsResponse: GrokSpendingLimitsResponse = try await Self.fetchJSON( - url: limitsURL, bearerToken: managementKey) - - // Fetch invoice preview for current usage + // Fetch invoice preview (contains both usage amount and spending limit) let invoiceURL = baseURL .appendingPathComponent("billing/teams/\(teamID)/postpaid/invoice/preview") let invoiceResponse: GrokInvoicePreviewResponse = try await Self.fetchJSON( url: invoiceURL, bearerToken: managementKey) - let usageCap = limitsResponse.hardLimit ?? limitsResponse.monthlyBudget ?? 0 - let totalUsage = invoiceResponse.totalAmount ?? invoiceResponse.amountDue ?? 0 + // Use spending limit from invoice response; fall back to dedicated endpoint + var usageCap = invoiceResponse.limitDollars + if usageCap <= 0 { + let limitsURL = baseURL + .appendingPathComponent("billing/teams/\(teamID)/postpaid/spending-limits") + let limitsResponse: GrokSpendingLimitsResponse = try await Self.fetchJSON( + url: limitsURL, bearerToken: managementKey) + usageCap = limitsResponse.effectiveLimitDollars + } + + let totalUsage = invoiceResponse.usageDollars let remaining = max(0, usageCap - totalUsage) let usedPercent = usageCap > 0 ? min(100, (totalUsage / usageCap) * 100) : 0 @@ -221,7 +259,7 @@ public struct GrokUsageFetcher: Sendable { environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> GrokKeySnapshot { guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw GrokUsageError.missingCredentials + throw GrokUsageError.missingAPIKey } let baseURL = GrokSettingsReader.apiURL(environment: environment) diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index d7a3669d4..f5eddb8b8 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -75,6 +75,7 @@ public enum ProviderDescriptorRegistry { .synthetic: SyntheticProviderDescriptor.descriptor, .openrouter: OpenRouterProviderDescriptor.descriptor, .warp: WarpProviderDescriptor.descriptor, + .grok: GrokProviderDescriptor.descriptor, ] private static let bootstrap: Void = { for provider in UsageProvider.allCases {