diff --git a/CHANGELOG.md b/CHANGELOG.md index ca213521b7..c8362e4c82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Cost history chart: localize the Projects, Conversations, session, and standard/fast mode labels that previously stayed English for all 23 app languages (#2983). Thanks @Yuxin-Qiao! ### Added - Usage & Spend: aggregate per-project spend into ranked, window-scoped rows and carry project/session breakdowns through the cached Codex prefill so the dashboard and the menu chart agree on project data (#2984). Thanks @Yuxin-Qiao! +- Grok: show SuperGrok Heavy from the CLI settings `subscription_tier_display` instead of labeling every OIDC login as SuperGrok (#2991). Thanks @olddonkey! ## 0.51.0 — 2026-08-16 diff --git a/Sources/CodexBarCore/Providers/Grok/GrokCLISettingsFetcher.swift b/Sources/CodexBarCore/Providers/Grok/GrokCLISettingsFetcher.swift new file mode 100644 index 0000000000..f3649f193c --- /dev/null +++ b/Sources/CodexBarCore/Providers/Grok/GrokCLISettingsFetcher.swift @@ -0,0 +1,62 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Reads the Grok CLI settings envelope. Plan names live here (`subscription_tier_display`), +/// not on `/v1/billing?format=credits`. +enum GrokCLISettingsFetcher { + static let defaultEndpoint = URL(string: "https://cli-chat-proxy.grok.com/v1/settings")! + static let requestTimeoutSeconds: TimeInterval = 2 + + static func endpoint(fromBilling billing: URL) -> URL { + var components = URLComponents(url: billing, resolvingAgainstBaseURL: false) + components?.path = "/v1/settings" + components?.query = nil + return components?.url ?? self.defaultEndpoint + } + + static func subscriptionTierDisplay( + credentials: GrokCredentials, + session transport: any ProviderHTTPTransport, + endpoint: URL = Self.defaultEndpoint) async throws -> String? + { + guard !credentials.isExpired else { return nil } + + var request = URLRequest(url: endpoint) + request.httpMethod = "GET" + request.timeoutInterval = Self.requestTimeoutSeconds + request.setValue("Bearer \(credentials.accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("xai-grok-cli", forHTTPHeaderField: "x-xai-token-auth") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("CodexBar", forHTTPHeaderField: "User-Agent") + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw error + } catch { + return nil + } + guard response.statusCode == 200 else { return nil } + return self.parse(response.data) + } + + static func parse(_ data: Data) -> String? { + struct SettingsResponse: Decodable { + let subscriptionTierDisplay: String? + + enum CodingKeys: String, CodingKey { + case subscriptionTierDisplay = "subscription_tier_display" + } + } + + guard let response = try? JSONDecoder().decode(SettingsResponse.self, from: data) else { + return nil + } + return GrokPlan.displayName(from: response.subscriptionTierDisplay) + } +} diff --git a/Sources/CodexBarCore/Providers/Grok/GrokCreditsProxyFetcher.swift b/Sources/CodexBarCore/Providers/Grok/GrokCreditsProxyFetcher.swift index a16ec0b04c..062692f5fb 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokCreditsProxyFetcher.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokCreditsProxyFetcher.swift @@ -52,13 +52,15 @@ public enum GrokCreditsProxyFetcher { throw GrokWebBillingError.parseFailed } + let subscriptionTier = GrokPlan.displayName(from: config.subscriptionTier ?? response.subscriptionTier) let resetsAt = config.currentPeriod?.end.flatMap(Self.parseISO8601) ?? config.billingPeriodEnd.flatMap(Self.parseISO8601) if let percent = config.creditUsagePercent, percent.isFinite { return GrokWebBillingSnapshot( usedPercent: min(100, max(0, percent)), - resetsAt: resetsAt) + resetsAt: resetsAt, + subscriptionTier: subscriptionTier) } if let cap = config.onDemandCap?.val, @@ -66,13 +68,20 @@ public enum GrokCreditsProxyFetcher { let used = config.onDemandUsed?.val { let percent = min(100, max(0, used / cap * 100)) - return GrokWebBillingSnapshot(usedPercent: percent, resetsAt: resetsAt) + return GrokWebBillingSnapshot( + usedPercent: percent, + resetsAt: resetsAt, + subscriptionTier: subscriptionTier) } - guard resetsAt != nil else { - throw GrokWebBillingError.parseFailed + if resetsAt != nil { + return GrokWebBillingSnapshot( + usedPercent: 0, + resetsAt: resetsAt, + subscriptionTier: subscriptionTier) } - return GrokWebBillingSnapshot(usedPercent: 0, resetsAt: resetsAt) + + throw GrokWebBillingError.parseFailed } private static func parseISO8601(_ raw: String) -> Date? { @@ -87,6 +96,7 @@ public enum GrokCreditsProxyFetcher { private struct CreditsResponse: Decodable { let config: CreditsConfig? + let subscriptionTier: String? } private struct CreditsConfig: Decodable { @@ -95,6 +105,7 @@ public enum GrokCreditsProxyFetcher { let billingPeriodEnd: String? let onDemandCap: CreditsAmount? let onDemandUsed: CreditsAmount? + let subscriptionTier: String? } private struct CurrentPeriod: Decodable { diff --git a/Sources/CodexBarCore/Providers/Grok/GrokPlan.swift b/Sources/CodexBarCore/Providers/Grok/GrokPlan.swift new file mode 100644 index 0000000000..f13589736f --- /dev/null +++ b/Sources/CodexBarCore/Providers/Grok/GrokPlan.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Grok consumer plan labels. OIDC only tells CodexBar that the user signed in as SuperGrok; +/// the billed tier (SuperGrok vs SuperGrok Heavy) comes from CLI settings +/// `subscription_tier_display`. +public enum GrokPlan: Sendable { + /// Prefer the billing `subscriptionTier`, then the OIDC login-method fallback. + public static func loginMethod(subscriptionTier: String?, credentials: GrokCredentials?) -> String? { + self.displayName(from: subscriptionTier) ?? credentials?.loginMethod + } + + public static func displayName(from raw: String?) -> String? { + let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return nil } + switch self.compactToken(trimmed) { + case "supergrokheavy", "heavy": + return "SuperGrok Heavy" + case "supergrok": + return "SuperGrok" + default: + return trimmed + } + } + + private static func compactToken(_ raw: String) -> String { + raw.lowercased().filter(\.isLetter) + } +} diff --git a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift index b3f1b6c969..7a63038677 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift @@ -157,6 +157,7 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { snapshot: GrokWebBillingSnapshot, sourceLabel: String, authenticatedByAuthFile: Bool) + typealias SettingsTierFetch = @Sendable (GrokCredentials?) async throws -> String? /// Browser-cookie import must stay limited to surfaces where a person explicitly asked for it: /// the menu-bar app runtime, a `userInitiated` interaction (set only by explicit refresh @@ -190,42 +191,58 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { func fetch( _ context: ProviderFetchContext, - webBilling fetchWebBilling: @escaping WebBillingFetch) async throws -> ProviderFetchResult + webBilling fetchWebBilling: @escaping WebBillingFetch, + settingsTier loadSettingsTier: SettingsTierFetch? = nil) async throws -> ProviderFetchResult { + let authCredentials = (try? GrokCredentialsStore.load(env: context.env)).flatMap { credentials in + credentials.isExpired ? nil : credentials + } + let resolveSettingsTier = loadSettingsTier ?? { credentials in + try await GrokStatusProbe.loadSettingsTier(credentials: credentials) + } + let webBilling: GrokWebBillingSnapshot let sourceLabel: String let authenticatedByAuthFile: Bool do { (webBilling, sourceLabel, authenticatedByAuthFile) = try await fetchWebBilling() } catch GrokWebBillingError.teamUsageUnsupported { - guard let authState = try? GrokCredentialsStore.load(env: context.env), - !authState.isExpired, - authState.isTeamPrincipal - else { + guard let authState = authCredentials, authState.isTeamPrincipal else { throw GrokWebBillingError.teamUsageUnsupported } + let subscriptionTier = try await resolveSettingsTier(authState) let identitySnapshot = GrokStatusProbe.identityOnlySnapshot( credentials: authState, localSummary: GrokLocalSessionScanner.summarize(env: context.env), - cliVersion: GrokStatusProbe.detectVersion(env: context.env)) + cliVersion: GrokStatusProbe.detectVersion(env: context.env), + subscriptionTier: subscriptionTier) return self.makeResult( usage: identitySnapshot.toUsageSnapshot(), sourceLabel: "grok-web", diagnostic: identitySnapshot.diagnostic) } let credentials = Self.credentialsForWebBillingSnapshot( - credentials: try? GrokCredentialsStore.load(env: context.env), + credentials: authCredentials, authenticatedByAuthFile: authenticatedByAuthFile) + // Cookie/gRPC fallback is a different browser session. Never attach the + // auth.json account's settings tier onto that usage. + let subscriptionTier: String? = if authenticatedByAuthFile { + try await resolveSettingsTier(authCredentials) + } else { + nil + } + let enrichedBilling = webBilling.applying(subscriptionTier: subscriptionTier) let snapshot = GrokUsageSnapshot( billing: nil, - webBilling: webBilling, + webBilling: enrichedBilling, credentials: GrokStatusProbe.credentialsForSnapshot( credentials: credentials, billing: nil, - webBilling: webBilling), + webBilling: enrichedBilling), localSummary: GrokLocalSessionScanner.summarize(env: context.env), cliVersion: GrokStatusProbe.detectVersion(env: context.env), - updatedAt: Date()) + updatedAt: Date(), + subscriptionTier: subscriptionTier ?? enrichedBilling.subscriptionTier) return self.makeResult( usage: snapshot.toUsageSnapshot(), sourceLabel: sourceLabel) diff --git a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift index cb1e41ace7..8269c0c55a 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift @@ -8,6 +8,7 @@ public struct GrokUsageSnapshot: Sendable { public let cliVersion: String? public let diagnostic: String? public let updatedAt: Date + public let subscriptionTier: String? public init( billing: GrokBillingResponse?, @@ -16,7 +17,8 @@ public struct GrokUsageSnapshot: Sendable { localSummary: GrokLocalSessionSummary?, cliVersion: String?, updatedAt: Date, - diagnostic: String? = nil) + diagnostic: String? = nil, + subscriptionTier: String? = nil) { self.billing = billing self.webBilling = webBilling @@ -25,6 +27,7 @@ public struct GrokUsageSnapshot: Sendable { self.cliVersion = cliVersion self.diagnostic = diagnostic self.updatedAt = updatedAt + self.subscriptionTier = subscriptionTier } public func toUsageSnapshot() -> UsageSnapshot { @@ -55,7 +58,9 @@ public struct GrokUsageSnapshot: Sendable { providerID: .grok, accountEmail: self.credentials?.email, accountOrganization: self.credentials?.teamId, - loginMethod: self.credentials?.loginMethod) + loginMethod: GrokPlan.loginMethod( + subscriptionTier: self.subscriptionTier ?? self.webBilling?.subscriptionTier, + credentials: self.credentials)) return UsageSnapshot( primary: primary, @@ -123,16 +128,19 @@ public struct GrokStatusProbe: Sendable { billingAttempted: billingAttempted, error: rpcError) { + let subscriptionTier = try await Self.loadSettingsTier(credentials: credentials) return Self.identityOnlySnapshot( credentials: credentials, localSummary: localSummary, - cliVersion: cliVersion) + cliVersion: cliVersion, + subscriptionTier: subscriptionTier) } if billing == nil { throw rpcError ?? GrokRPCError.notAuthenticated } + let subscriptionTier = try await Self.loadSettingsTier(credentials: credentials) return GrokUsageSnapshot( billing: billing, webBilling: nil, @@ -142,14 +150,16 @@ public struct GrokStatusProbe: Sendable { webBilling: nil), localSummary: localSummary, cliVersion: cliVersion, - updatedAt: Date()) + updatedAt: Date(), + subscriptionTier: subscriptionTier) } static func identityOnlySnapshot( credentials: GrokCredentials, localSummary: GrokLocalSessionSummary?, cliVersion: String?, - updatedAt: Date = .init()) -> GrokUsageSnapshot + updatedAt: Date = .init(), + subscriptionTier: String? = nil) -> GrokUsageSnapshot { GrokUsageSnapshot( billing: nil, @@ -158,7 +168,38 @@ public struct GrokStatusProbe: Sendable { localSummary: localSummary, cliVersion: cliVersion, updatedAt: updatedAt, - diagnostic: GrokStatusProbe.teamUsageUnavailableMessage) + diagnostic: GrokStatusProbe.teamUsageUnavailableMessage, + subscriptionTier: subscriptionTier) + } + + static let settingsJoinGrace = Duration.seconds(2) + + static func loadSettingsTier( + credentials: GrokCredentials?, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> String? + { + guard let credentials, !credentials.isExpired else { return nil } + let sourceTask = Task { + try await GrokCLISettingsFetcher.subscriptionTierDisplay( + credentials: credentials, + session: transport) + } + let outcome = await BoundedTaskJoin(sourceTask: sourceTask).value(joinGrace: Self.settingsJoinGrace) + try Task.checkCancellation() + switch outcome { + case let .value(tier): + return tier + case .timedOut: + return nil + case let .failure(error): + if error is CancellationError { + throw CancellationError() + } + if let urlError = error as? URLError, urlError.code == .cancelled { + throw urlError + } + return nil + } } static func isBillingMethodUnavailable(_ error: Error?) -> Bool { diff --git a/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift b/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift index ee19ebef9e..79ee2b4d8b 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift @@ -6,10 +6,20 @@ import FoundationNetworking public struct GrokWebBillingSnapshot: Sendable, Equatable { public let usedPercent: Double? public let resetsAt: Date? + public let subscriptionTier: String? - public init(usedPercent: Double?, resetsAt: Date?) { + public init(usedPercent: Double?, resetsAt: Date?, subscriptionTier: String? = nil) { self.usedPercent = usedPercent self.resetsAt = resetsAt + self.subscriptionTier = subscriptionTier + } + + /// Overlay the CLI settings plan name. Usage percent stays on the existing credits rules. + func applying(subscriptionTier raw: String?) -> GrokWebBillingSnapshot { + GrokWebBillingSnapshot( + usedPercent: self.usedPercent, + resetsAt: self.resetsAt, + subscriptionTier: GrokPlan.displayName(from: raw) ?? self.subscriptionTier) } } diff --git a/Tests/CodexBarTests/GrokCreditsProxyFetcherTests.swift b/Tests/CodexBarTests/GrokCreditsProxyFetcherTests.swift index 93d51da7d9..f7dae9483a 100644 --- a/Tests/CodexBarTests/GrokCreditsProxyFetcherTests.swift +++ b/Tests/CodexBarTests/GrokCreditsProxyFetcherTests.swift @@ -49,6 +49,7 @@ struct GrokCreditsProxyFetcherTests { #expect(GrokCreditsProxyStubURLProtocol.requests.count == 1) #expect(snapshot.usedPercent == 12.5) #expect(snapshot.resetsAt == expectedReset) + #expect(snapshot.subscriptionTier == nil) } @Test @@ -101,6 +102,63 @@ struct GrokCreditsProxyFetcherTests { #expect(snapshot.usedPercent == 0) #expect(snapshot.resetsAt == expectedReset) + #expect(snapshot.subscriptionTier == nil) + } + + @Test + func `reads SuperGrok Heavy from the top-level subscription tier`() throws { + let snapshot = try GrokCreditsProxyFetcher.parseSnapshot(Data(""" + { + "config": { + "currentPeriod": { + "type": "USAGE_PERIOD_TYPE_WEEKLY", + "start": "2026-08-16T18:42:45.537749+00:00", + "end": "2026-08-23T18:42:45.537749+00:00" + }, + "onDemandCap": { "val": 0 }, + "onDemandUsed": { "val": 0 }, + "billingPeriodEnd": "2026-08-23T18:42:45.537749+00:00" + }, + "subscriptionTier": "SuperGrok Heavy" + } + """.utf8)) + let expectedReset = try Self.date("2026-08-23T18:42:45.537749+00:00") + + #expect(snapshot.subscriptionTier == "SuperGrok Heavy") + #expect(snapshot.usedPercent == 0) + #expect(snapshot.resetsAt == expectedReset) + } + + @Test + func `prefers config subscription tier over the envelope`() throws { + let snapshot = try GrokCreditsProxyFetcher.parseSnapshot(Data(""" + { + "config": { + "creditUsagePercent": 8, + "billingPeriodEnd": "2026-08-13T00:00:00Z", + "subscriptionTier": "SuperGrok Heavy" + }, + "subscriptionTier": "SuperGrok" + } + """.utf8)) + + #expect(snapshot.subscriptionTier == "SuperGrok Heavy") + #expect(snapshot.usedPercent == 8) + } + + @Test + func `rejects a tier-only response so legacy billing can run`() { + #expect { + _ = try GrokCreditsProxyFetcher.parseSnapshot(Data(""" + { + "config": { "onDemandCap": { "val": 0 } }, + "subscriptionTier": "supergrok_heavy" + } + """.utf8)) + } throws: { error in + guard case GrokWebBillingError.parseFailed = error else { return false } + return true + } } @Test @@ -202,6 +260,31 @@ struct GrokCreditsProxyFetcherTests { #expect(result.authenticatedByAuthFile) } + @Test + func `tier-only proxy parse failure falls through to legacy billing`() async throws { + let events = EventRecorder() + let result = try await GrokWebFetchStrategy.fetchProxyFirst( + credentials: Self.credentials, + proxyBilling: { _ in + events.append("proxy") + throw GrokWebBillingError.parseFailed + }, + legacyBilling: { + events.append("legacy") + return ( + GrokWebBillingSnapshot( + usedPercent: 33, + resetsAt: Date(timeIntervalSince1970: 1_800_000_003)), + "Chrome", + false) + }) + + #expect(events.values == ["proxy", "legacy"]) + #expect(result.snapshot.usedPercent == 33) + #expect(result.sourceLabel == "Chrome") + #expect(!result.authenticatedByAuthFile) + } + @Test func `proxy failure falls through to legacy web billing`() async throws { let events = EventRecorder() diff --git a/Tests/CodexBarTests/GrokPlanTests.swift b/Tests/CodexBarTests/GrokPlanTests.swift new file mode 100644 index 0000000000..5135d40674 --- /dev/null +++ b/Tests/CodexBarTests/GrokPlanTests.swift @@ -0,0 +1,118 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBarCore + +struct GrokPlanTests { + @Test + func `normalizes SuperGrok Heavy subscription tiers`() { + #expect(GrokPlan.displayName(from: "SuperGrok Heavy") == "SuperGrok Heavy") + #expect(GrokPlan.displayName(from: "supergrok_heavy") == "SuperGrok Heavy") + #expect(GrokPlan.displayName(from: " HEAVY ") == "SuperGrok Heavy") + #expect(GrokPlan.displayName(from: "SuperGrok") == "SuperGrok") + #expect(GrokPlan.displayName(from: "Custom Team") == "Custom Team") + #expect(GrokPlan.displayName(from: " ") == nil) + #expect(GrokPlan.displayName(from: nil) == nil) + } + + @Test + func `login method prefers billing tier over OIDC SuperGrok`() { + let credentials = GrokCredentials( + accessToken: "token", + refreshToken: nil, + scope: "https://auth.x.ai::client", + authMode: "oidc", + userId: nil, + email: "grok@example.com", + firstName: nil, + lastName: nil, + teamId: nil, + oidcIssuer: nil, + oidcClientId: nil, + expiresAt: nil, + createTime: nil) + + #expect(credentials.loginMethod == "SuperGrok") + #expect(GrokPlan.loginMethod(subscriptionTier: "SuperGrok Heavy", credentials: credentials) + == "SuperGrok Heavy") + #expect(GrokPlan.loginMethod(subscriptionTier: nil, credentials: credentials) == "SuperGrok") + #expect(GrokPlan.loginMethod(subscriptionTier: " ", credentials: credentials) == "SuperGrok") + } + + @Test + func `settings parser reads subscription_tier_display`() { + #expect(GrokCLISettingsFetcher.parse(Data(#"{"subscription_tier_display":"SuperGrok Heavy"}"#.utf8)) + == "SuperGrok Heavy") + #expect(GrokCLISettingsFetcher.parse(Data(#"{"subscription_tier_display":"supergrok"}"#.utf8)) + == "SuperGrok") + #expect(GrokCLISettingsFetcher.parse(Data(#"{}"#.utf8)) == nil) + #expect(GrokCLISettingsFetcher.parse(Data("not-json".utf8)) == nil) + } + + @Test + func `applying a plan name keeps the existing usage percent`() { + let snapshot = GrokWebBillingSnapshot( + usedPercent: 0, + resetsAt: Date(timeIntervalSince1970: 1_800_000_000)) + let applied = snapshot.applying(subscriptionTier: "SuperGrok Heavy") + #expect(applied.subscriptionTier == "SuperGrok Heavy") + #expect(applied.usedPercent == 0) + #expect(applied.resetsAt == Date(timeIntervalSince1970: 1_800_000_000)) + } + + @Test + func `settings load failure does not invent a previous Heavy tier`() async throws { + let credentials = GrokCredentials( + accessToken: "token-a", + refreshToken: nil, + scope: "https://auth.x.ai::client", + authMode: "oidc", + userId: "user-a", + email: "a@example.com", + firstName: nil, + lastName: nil, + teamId: nil, + oidcIssuer: nil, + oidcClientId: nil, + expiresAt: Date(timeIntervalSince1970: 1_900_000_000), + createTime: nil) + let transport = GrokSettingsFailingTransport() + + let tier = try await GrokStatusProbe.loadSettingsTier( + credentials: credentials, + session: transport) + + #expect(tier == nil) + #expect(GrokPlan.loginMethod(subscriptionTier: tier, credentials: credentials) == "SuperGrok") + } + + @Test + func `settings request uses a short enrichment timeout`() { + #expect(GrokCLISettingsFetcher.requestTimeoutSeconds == 2) + #expect(GrokStatusProbe.settingsJoinGrace == .seconds(2)) + } + + @Test + func `settings endpoint is derived from the billing host`() throws { + let billing = try #require(URL(string: "https://grok.test/v1/billing?format=credits")) + #expect(GrokCLISettingsFetcher.endpoint(fromBilling: billing).absoluteString + == "https://grok.test/v1/settings") + } +} + +private struct GrokSettingsFailingTransport: ProviderHTTPTransport { + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 500, + httpVersion: nil, + headerFields: nil) + else { + throw URLError(.badURL) + } + return (Data("nope".utf8), response) + } +} diff --git a/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift b/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift index d0684be3a0..8afc1ff9f6 100644 --- a/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift +++ b/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift @@ -384,15 +384,80 @@ struct GrokWebBillingFetcherTests { let result = try await GrokWebFetchStrategy().fetch(context) { throw GrokWebBillingError.teamUsageUnsupported + } settingsTier: { _ in + "SuperGrok Heavy" } #expect(result.sourceLabel == "grok-web") #expect(result.diagnostic == GrokStatusProbe.teamUsageUnavailableMessage) #expect(result.usage.primary == nil) + #expect(result.usage.loginMethod(for: .grok) == "SuperGrok Heavy") #expect(result.usage.accountEmail(for: .grok) == "team@example.com") #expect(result.usage.accountOrganization(for: .grok) == "team-123") } + @Test + func `web strategy does not attach auth-file settings tier to cookie billing`() async throws { + let asked = LockIsolated(false) + let result = try await GrokWebFetchStrategy().fetch( + Self.webContext(grokHome: nil), + webBilling: { + ( + GrokWebBillingSnapshot( + usedPercent: 0, + resetsAt: Date(timeIntervalSince1970: 1_800_000_003)), + "Chrome", + false) + }, + settingsTier: { _ in + asked.setValue(true) + return "SuperGrok Heavy" + }) + + #expect(result.sourceLabel == "Chrome") + #expect(asked.value == false) + #expect(result.usage.loginMethod(for: .grok) == nil) + #expect(result.usage.primary?.usedPercent == 0) + } + + @Test + func `web strategy applies settings tier when billing used the auth file`() async throws { + let result = try await GrokWebFetchStrategy().fetch( + Self.webContext(grokHome: nil), + webBilling: { + ( + GrokWebBillingSnapshot( + usedPercent: 0, + resetsAt: Date(timeIntervalSince1970: 1_800_000_003)), + "grok-cli-proxy", + true) + }, + settingsTier: { _ in "SuperGrok Heavy" }) + + #expect(result.sourceLabel == "grok-cli-proxy") + #expect(result.usage.loginMethod(for: .grok) == "SuperGrok Heavy") + #expect(result.usage.primary?.usedPercent == 0) + } + + @Test + func `web strategy keeps credits when settings enrichment fails`() async throws { + let result = try await GrokWebFetchStrategy().fetch( + Self.webContext(grokHome: nil), + webBilling: { + ( + GrokWebBillingSnapshot( + usedPercent: 18, + resetsAt: Date(timeIntervalSince1970: 1_800_000_003)), + "grok-cli-proxy", + true) + }, + settingsTier: { _ in nil }) + + #expect(result.sourceLabel == "grok-cli-proxy") + #expect(result.usage.primary?.usedPercent == 18) + #expect(result.usage.loginMethod(for: .grok) == nil) + } + @Test func `ignores grpc web trailer frames`() { let payload = Self.protobufPayload(usedPercent: 12.25, resetEpoch: 1_800_000_001) @@ -920,6 +985,83 @@ extension GrokWebBillingFetcherTests { #expect(usage.loginMethod(for: .grok) == "SuperGrok") } + @Test + func `usage snapshot prefers billing SuperGrok Heavy over OIDC SuperGrok`() { + let snapshot = GrokUsageSnapshot( + billing: nil, + webBilling: GrokWebBillingSnapshot( + usedPercent: 0, + resetsAt: Date(timeIntervalSince1970: 1_800_000_003), + subscriptionTier: "SuperGrok Heavy"), + credentials: Self.credentials, + localSummary: nil, + cliVersion: nil, + updatedAt: Date(timeIntervalSince1970: 1_799_000_000)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 0) + #expect(usage.loginMethod(for: .grok) == "SuperGrok Heavy") + #expect(usage.accountEmail(for: .grok) == "grok@example.com") + } + + @Test + func `CLI RPC usage snapshot uses settings tier when web billing is absent`() throws { + let billing = try JSONDecoder().decode( + GrokBillingResponse.self, + from: Data(""" + { + "billingCycle": { + "billingPeriodStart": "2026-08-16T18:42:45Z", + "billingPeriodEnd": "2026-08-23T18:42:45Z" + }, + "monthlyLimit": { "val": 100 }, + "usage": { "totalUsed": { "val": 0 } } + } + """.utf8)) + let snapshot = GrokUsageSnapshot( + billing: billing, + webBilling: nil, + credentials: Self.credentials, + localSummary: nil, + cliVersion: "1.0.4", + updatedAt: Date(timeIntervalSince1970: 1_799_000_000), + subscriptionTier: "SuperGrok Heavy") + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 0) + #expect(usage.loginMethod(for: .grok) == "SuperGrok Heavy") + } + + @Test + func `CLI RPC usage snapshot falls back to OIDC SuperGrok when settings are missing`() throws { + let billing = try JSONDecoder().decode( + GrokBillingResponse.self, + from: Data(#"{"monthlyLimit":{"val":100},"usage":{"totalUsed":{"val":10}}}"#.utf8)) + let snapshot = GrokUsageSnapshot( + billing: billing, + webBilling: nil, + credentials: Self.credentials, + localSummary: nil, + cliVersion: "1.0.4", + updatedAt: Date(timeIntervalSince1970: 1_799_000_000)) + + #expect(snapshot.toUsageSnapshot().loginMethod(for: .grok) == "SuperGrok") + } + + @Test + func `identity-only CLI fallback keeps a settings SuperGrok Heavy label`() { + let snapshot = GrokStatusProbe.identityOnlySnapshot( + credentials: Self.credentials, + localSummary: nil, + cliVersion: "1.0.4", + subscriptionTier: "SuperGrok Heavy") + + #expect(snapshot.toUsageSnapshot().loginMethod(for: .grok) == "SuperGrok Heavy") + #expect(snapshot.diagnostic == GrokStatusProbe.teamUsageUnavailableMessage) + } + @Test func `usage snapshot does not classify a monthly reset near its end as weekly`() { // A monthly quota with six days left must not be reported as a weekly window. @@ -995,6 +1137,28 @@ extension GrokWebBillingFetcherTests { ]) } + private static func webContext(grokHome: URL?) -> ProviderFetchContext { + let home = grokHome ?? FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBar-GrokWebContext-\(UUID().uuidString)", isDirectory: true) + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .cli, + sourceMode: .web, + includeCredits: true, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [ + "GROK_HOME": home.path, + "GROK_CLI_PATH": home.appendingPathComponent("missing-grok").path, + "PATH": home.path, + ], + settings: nil, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + private static func data(hexString: String) -> Data? { var data = Data() var index = hexString.startIndex diff --git a/docs/grok.md b/docs/grok.md index 00661f0766..33ca5bee5d 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -30,6 +30,10 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. fallback, while a team principal degrades to identity-only with an explicit unsupported-team-usage diagnostic. When xAI exposes billing on the agent protocol, no code change is required. + - After a successful RPC billing result (or the identity-only team fallback), + CodexBar still GETs `/v1/settings` for `subscription_tier_display` so the + billed plan is not lost just because the CLI route succeeded first. The + settings lookup is optional enrichment with a 2-second budget. - One non-obvious quirk: grok's ACP parser does not unescape `\/` in method names. `Foundation.JSONSerialization.data` defaults to escaping forward slashes, so payloads must be re-encoded with `\/` → `/` before being @@ -44,6 +48,16 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. `onDemandUsed.val / onDemandCap.val * 100`. A parseable current period without either value represents zero usage. The reset timestamp comes from `config.currentPeriod.end`, then `config.billingPeriodEnd`. + - Plan name does not come from the credits payload. After a successful + auth-file web billing result (CLI-proxy) or the team identity-only path, + CodexBar GETs `https://cli-chat-proxy.grok.com/v1/settings` with the same + bearer headers and reads `subscription_tier_display` (`SuperGrok Heavy` vs + `SuperGrok`). Cookie/gRPC fallbacks are a different browser session and do + not reuse the auth-file settings tier. The request uses a 2-second timeout + and `BoundedTaskJoin`, so a stuck settings call cannot delay already-fetched + usage by 15 seconds. Settings timeouts, request failures, and 200 responses + that omit `subscription_tier_display` all drop the plan overlay and fall + back to the OIDC SuperGrok label. There is no process-lifetime tier cache. - This is the Grok CLI's supported token-authenticated billing backend. If it fails, CodexBar continues through the existing browser-cookie and legacy bearer fallbacks. @@ -143,7 +157,10 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. - **Identity**: - `accountEmail` from credential `email`. - `accountOrganization` from credential `team_id`. - - `loginMethod` = "SuperGrok" for OIDC, otherwise the raw `auth_mode`. + - `loginMethod` = CLI settings `subscription_tier_display` when present + (`SuperGrok Heavy` or `SuperGrok`), on both the CLI RPC route and the + CLI-proxy web route. Otherwise "SuperGrok" for OIDC and the raw `auth_mode` + for other login modes. ## Local fallback (`~/.grok/sessions/`) @@ -174,8 +191,10 @@ points to `https://status.x.ai`. - `Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift` - `Sources/CodexBarCore/Providers/Grok/GrokAuth.swift` +- `Sources/CodexBarCore/Providers/Grok/GrokPlan.swift` - `Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift` - `Sources/CodexBarCore/Providers/Grok/GrokCreditsProxyFetcher.swift` +- `Sources/CodexBarCore/Providers/Grok/GrokCLISettingsFetcher.swift` - `Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift` - `Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift` - `Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift`