diff --git a/Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift b/Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift
new file mode 100644
index 000000000..fa361e4a5
--- /dev/null
+++ b/Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift
@@ -0,0 +1,105 @@
+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
+ }
+ context.settings.ensureGrokAPITokenLoaded()
+ 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-token-...",
+ 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/settings/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..ec7527aa1
--- /dev/null
+++ b/Sources/CodexBar/Providers/Grok/GrokSettingsStore.swift
@@ -0,0 +1,37 @@
+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)
+ }
+ }
+ }
+
+ func ensureGrokAPITokenLoaded() {}
+}
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..22174690e 100644
--- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
+++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
@@ -6,8 +6,21 @@ 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?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !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 +42,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..7fb6974e9
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift
@@ -0,0 +1,112 @@
+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,
+ browserCookieOrder: nil,
+ 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: 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? {
+ 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)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Grok/GrokSettingsReader.swift b/Sources/CodexBarCore/Providers/Grok/GrokSettingsReader.swift
new file mode 100644
index 000000000..2f4d1ead7
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Grok/GrokSettingsReader.swift
@@ -0,0 +1,89 @@
+import Foundation
+
+/// Reads Grok/xAI settings from environment variables
+public struct GrokSettingsReader: Sendable {
+ public static let apiKeyEnvironmentKeys = ["XAI_API_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
+ public static func apiKey(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
+ {
+ for key in self.apiKeyEnvironmentKeys {
+ if let token = self.cleaned(environment[key]) { return token }
+ }
+ return nil
+ }
+
+ /// Returns the Management API key from environment if present
+ public static func managementKey(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
+ {
+ self.cleaned(environment[Self.managementKeyEnvironmentKey])
+ }
+
+ /// Returns the team ID, defaulting to "default"
+ public static func teamID(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> String
+ {
+ 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 = self.cleaned(environment["XAI_API_URL"]),
+ let url = URL(string: 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 = 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? {
+ guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
+ return nil
+ }
+
+ if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
+ (value.hasPrefix("'") && value.hasSuffix("'"))
+ {
+ value.removeFirst()
+ value.removeLast()
+ }
+
+ 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."
+ }
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Grok/GrokUsageFetcher.swift b/Sources/CodexBarCore/Providers/Grok/GrokUsageFetcher.swift
new file mode 100644
index 000000000..1918bcba5
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Grok/GrokUsageFetcher.swift
@@ -0,0 +1,307 @@
+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
+
+/// 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 spendingLimits: SpendingLimits
+
+ 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 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?
+ }
+
+ 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
+ }
+}
+
+// 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 missingAPIKey
+ case missingManagementKey
+ case networkError(String)
+ case apiError(String)
+ case parseFailed(String)
+
+ public var errorDescription: String? {
+ switch self {
+ 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):
+ "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.missingManagementKey
+ }
+
+ let baseURL = GrokSettingsReader.managementAPIURL(environment: environment)
+
+ // 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)
+
+ // 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
+
+ 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.missingAPIKey
+ }
+
+ 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/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 {
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
}
}
}