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.