From 446c3bafd5eaf32b9a56a56e544c6e968fc3acd1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 20 Sep 2026 23:43:17 -0700 Subject: [PATCH] fix(grok): preserve billing defaults in nonempty requests Encode exclude_legacy_monthly_usage=false explicitly so billing requests contain a protobuf message without changing legacy monthly semantics. Verify bearer, cookie, combined-auth, and retry request bytes. Add synthetic proof that an unavailable CLI billing method still permits proxy quota and local tokens to reach the dashboard and share payload. Usage JSON intentionally omits that live-only history; #3716 remains open. Refs #3336, #3716 Co-authored-by: charlielz <1540060247@qq.com> --- CHANGELOG.md | 1 + .../Grok/GrokWebBillingFetcher.swift | 2 +- .../GrokAccountContextTests.swift | 64 +++++++++++++++++++ .../GrokWebBillingFetcherTests.swift | 8 ++- docs/grok.md | 17 ++++- 5 files changed, 89 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index badebe56f8..cb48ea2314 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed +- Grok: send a nonempty billing request while preserving legacy monthly usage and leaving unknown percentages unchanged (#3336). Thanks @CharlieLZ! - CLI: show each host's source snapshot timestamp in SSH cost text reports, making stale remote totals visible without changing JSON output (#3765). Thanks @A-l-an! - Claude/Vertex costs: prevent crashes from oversized local history values, preserving valid token components and finite spend when an aggregate exceeds the cache's numeric range; preserve retained Codex history during the upgrade. - Linux quotas: show measured provider-specific windows, omit synthetic or unmeasured quota, and keep scoped identities private in IPC without colliding with notification state (#3785). Thanks @jsonMartin! diff --git a/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift b/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift index fa9966e36a..22cc28a12a 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift @@ -220,7 +220,7 @@ public enum GrokWebBillingFetcher { var request = URLRequest(url: endpoint) request.httpMethod = "POST" request.timeoutInterval = Self.requestTimeoutSeconds - request.httpBody = Data([0x00, 0x00, 0x00, 0x00, 0x00]) + request.httpBody = Data([0x00, 0x00, 0x00, 0x00, 0x02, 0x08, 0x00]) if let authorizationHeader { request.setValue(authorizationHeader, forHTTPHeaderField: "Authorization") } diff --git a/Tests/CodexBarTests/GrokAccountContextTests.swift b/Tests/CodexBarTests/GrokAccountContextTests.swift index b3b58715c7..3fb9d9e6e5 100644 --- a/Tests/CodexBarTests/GrokAccountContextTests.swift +++ b/Tests/CodexBarTests/GrokAccountContextTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +@testable import CodexBar @testable import CodexBarCore struct GrokAccountContextTests { @@ -265,6 +266,69 @@ struct GrokAccountContextTests { #expect(calls.value == (stage == "proxy" ? ["proxy"] : ["proxy", stage])) } + @Test + func `missing RPC billing retains local tokens through the proxy fallback`() async throws { + let fixture = try GrokAccountFixture() + defer { fixture.remove() } + try fixture.write(account: "a") + let context = fixture.context(sourceMode: .auto) + let binary = try #require(context.env["GROK_CLI_PATH"]) + let script = """ + #!/bin/sh + if [ "$1" = "--version" ]; then + printf '%s\\n' 'grok synthetic-version' + exit 0 + fi + IFS= read -r initialize_request + printf '%s\\n' '{"jsonrpc":"2.0","id":1,"result":{}}' + IFS= read -r billing_request || exit 0 + printf '%s\\n' '{"jsonrpc":"2.0","id":2,"error":{"code":-32601,"message":"Method not found"}}' + """ + try script.write(toFile: binary, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary) + let session = fixture.home.appendingPathComponent("sessions/project/session", isDirectory: true) + try FileManager.default.createDirectory(at: session, withIntermediateDirectories: true) + try Data(""" + {"totalTokensBeforeCompaction":40,"contextTokensUsed":2,"primaryModelId":"example-model"} + """.utf8).write(to: session.appendingPathComponent("signals.json")) + var web = GrokWebFetchStrategy() + web.remainingResetsLookup = { _, _, _ in .empty } + let oauth = GrokOAuthFetchStrategy( + proxyBilling: { _ in GrokWebBillingSnapshot(usedPercent: 37, resetsAt: nil) }, + grpcBilling: { _ in + Issue.record("Known proxy usage does not need gRPC enrichment") + throw GrokWebBillingError.invalidResponse + }, + webStrategy: web, + settingsTier: { _ in nil }) + let pipeline = ProviderFetchPipeline(resolveStrategies: { _ in [GrokCLIFetchStrategy(), oauth] }) + let outcome = await pipeline.fetch(context: context, provider: .grok) + let result = try outcome.result.get() + + #expect(outcome.attempts.count == 2) + #expect(outcome.attempts.first?.wasAvailable == true) + #expect(outcome.attempts.first?.errorDescription?.contains("Method not found") == true) + #expect(result.sourceLabel == "grok-cli-proxy") + #expect(result.usage.primary?.usedPercent == 37) + let history = try #require(result.usage.costUsage) + #expect(history.last30DaysTokens == 42) + #expect(history.daily.map(\.totalTokens) == [42]) + #expect(history.last30DaysCostUSD == nil) + #expect(history.costProvenance == .unknown) + let model = SpendDashboardModel.build( + inputs: [.init(provider: .grok, displayName: "Grok", snapshot: history)], + requestedDays: 30, + now: history.updatedAt) + let shared = try #require(ShareStatsBuilder.make(model: model)) + #expect(shared.providers.map(\.provider) == [.grok]) + #expect(shared.providers.first?.totalTokens == 42) + #expect(shared.providers.first?.estimatedCost == nil) + // Usage JSON intentionally omits live-only history; its absence is not evidence of lost app data. + let data = try JSONEncoder().encode(result.usage) + let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(json["costUsage"] == nil) + } + private static func expectAccountA(_ credentials: GrokCredentials?) { #expect(credentials?.accessToken == "fake-token-a") #expect(credentials?.refreshToken == "fake-refresh-a") diff --git a/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift b/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift index d710f2eefb..385310a0e2 100644 --- a/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift +++ b/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift @@ -768,7 +768,7 @@ struct GrokWebBillingFetcherTests { endpoint: endpoint) #expect(GrokWebBillingStubURLProtocol.requests.count == 1) - #expect(GrokWebBillingStubURLProtocol.requestBodies == [Data([0x00, 0x00, 0x00, 0x00, 0x00])]) + #expect(GrokWebBillingStubURLProtocol.requestBodies == [Data([0x00, 0x00, 0x00, 0x00, 0x02, 0x08, 0x00])]) #expect(snapshot.usedPercent == 55.5) #expect(snapshot.resetsAt == Date(timeIntervalSince1970: TimeInterval(reset))) } @@ -814,6 +814,10 @@ struct GrokWebBillingFetcherTests { #expect(attempts.current() == 2) #expect(GrokWebBillingStubURLProtocol.requests.count == 2) + #expect(GrokWebBillingStubURLProtocol.requests.allSatisfy { $0.timeoutInterval == 15 }) + #expect(GrokWebBillingStubURLProtocol.requestBodies == Array( + repeating: Data([0x00, 0x00, 0x00, 0x00, 0x02, 0x08, 0x00]), + count: 2)) #expect(snapshot.usedPercent == 25) #expect(snapshot.resetsAt == Date(timeIntervalSince1970: TimeInterval(reset))) } @@ -933,6 +937,7 @@ extension GrokWebBillingFetcherTests { endpoint: endpoint) #expect(snapshot.usedPercent == 9) + #expect(GrokWebBillingStubURLProtocol.requestBodies == [Data([0x00, 0x00, 0x00, 0x00, 0x02, 0x08, 0x00])]) } @Test @@ -968,6 +973,7 @@ extension GrokWebBillingFetcherTests { endpoint: endpoint) #expect(snapshot.usedPercent == 9) + #expect(GrokWebBillingStubURLProtocol.requestBodies == [Data([0x00, 0x00, 0x00, 0x00, 0x02, 0x08, 0x00])]) } @Test diff --git a/docs/grok.md b/docs/grok.md index 7140b3f002..ee251c1b56 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -117,8 +117,16 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. 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. 4) **grok.com billing gRPC-web fallback** (best-effort) - - POSTs an empty gRPC-web protobuf request to + - POSTs `GetGrokCreditsConfigRequest { exclude_legacy_monthly_usage: false }` + (gRPC-web binary frame `00 00 00 00 02 08 00`) to `https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig`. + Explicit false preserves the default billing semantics while supplying a + nonempty message for servers that reject an empty frame with gRPC status 13 + (`Missing request message.`). Field 1 is a boolean, not a period selector; + `08 02` would enable exclusion of legacy monthly usage. The public + [billing descriptor](https://cdn.grok.com/_next/static/chunks/32g78bk5hhe1q.js) + was checked on September 21, 2026. No response-percentage inference changes + are required by this encoding; affected-account recovery remains unverified. - This endpoint now requires the browser-held Web Key Exchange (WKE) keypair. Cookie-only authentication can fail with gRPC status 16 and `no-credentials`; signing in through Chrome alone cannot provide that proof @@ -267,6 +275,13 @@ dollars. Local session scans run on the dedicated background usage-scan queue; menu cards and spend views reuse the already-published snapshot instead of walking the session directory whenever they render. +`costUsage` is live-only data and is intentionally omitted from `codexbar usage` +JSON and persisted usage snapshots. Its absence in JSON does not establish that +Usage & Spend lost the in-memory local token history. In Auto mode, an RPC +`-32601` failure advances to the proxy/web strategy, which scans local sessions +and attaches the token history to its successful quota snapshot. Local signals +remain token-only; they do not establish completed-turn counts or dollar spend. + ## Menu bar appearance Grok quota icons show a visor and twin antennae in both single- and two-meter layouts.