diff --git a/CHANGELOG.md b/CHANGELOG.md index b198aa0042..4b36ca3b5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Usage & Spend: keep stalled or failed Codex catch-up paused until explicit Refresh, preventing background synchronization from restarting CPU-heavy scans (partial fix for #3316). Thanks @heyajulia! - Xiaomi MiMo: prevent overlapping local usage tracker updates from colliding on a shared temporary cache file, preserving atomic publication (#3321). Thanks @Lucenx9! - Claude: avoid duplicated “Resets Reset” labels when CLI usage supplies a singular reset description (#3317). Thanks @Aternus! +- Grok: send a nonempty billing request while preserving legacy monthly usage and leaving unknown percentages unchanged (#3336). Thanks @CharlieLZ! ## 0.56.2 — 2026-08-31 diff --git a/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift b/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift index edf29385ca..8f75287d9e 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift @@ -116,6 +116,10 @@ public enum GrokWebBillingFetcher { URL(string: "https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")! private static let requestTimeoutSeconds: TimeInterval = 15 + /// Explicit field 1 = false (exclude_legacy_monthly_usage) keeps the request nonempty + /// without changing the default billing semantics. This field is not a period selector. + private static let creditsConfigRequest = Data([0x00, 0x00, 0x00, 0x00, 0x02, 0x08, 0x00]) + public static func fetch( credentials: GrokCredentials, session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, @@ -208,7 +212,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 = Self.creditsConfigRequest if let authorizationHeader { request.setValue(authorizationHeader, forHTTPHeaderField: "Authorization") } diff --git a/Tests/CodexBarTests/GrokCreditsProxyFetcherTests.swift b/Tests/CodexBarTests/GrokCreditsProxyFetcherTests.swift index cdf229e3ad..2be4f35705 100644 --- a/Tests/CodexBarTests/GrokCreditsProxyFetcherTests.swift +++ b/Tests/CodexBarTests/GrokCreditsProxyFetcherTests.swift @@ -53,6 +53,66 @@ struct GrokCreditsProxyFetcherTests { #expect(snapshot.subscriptionTier == nil) } + @Test + func `unified billing without a published percent keeps subscription usage unknown`() async throws { + let snapshot = try GrokCreditsProxyFetcher.parseSnapshot( + Data( + """ + { + "config": { + "isUnifiedBillingUser": true, + "currentPeriod": { + "type": "USAGE_PERIOD_TYPE_WEEKLY", + "start": "2026-08-31T12:35:57.056343+00:00", + "end": "2026-09-07T12:35:57.056343+00:00" + }, + "onDemandCap": { "val": 0 }, + "onDemandUsed": { "val": 0 }, + "prepaidBalance": { "val": 0 }, + "topUpMethod": "TOP_UP_METHOD_SAVED_PAYMENT_METHOD", + "billingPeriodEnd": "2026-09-07T12:35:57.056343+00:00" + } + } + """.utf8)) + + let expectedReset = try Self.date("2026-09-07T12:35:57.056343+00:00") + // Zero on-demand spending does not establish the included subscription usage. + #expect(snapshot.usedPercent == nil) + #expect(snapshot.resetsAt == expectedReset) + #expect(snapshot.subscriptionTier == nil) + + let result = try await GrokOAuthFetchStrategy.resolvingUnknownUsage( + snapshot, + credentials: Self.credentials, + grpcBilling: { _ in GrokWebBillingSnapshot(usedPercent: 35, resetsAt: nil) }) + + #expect(result.snapshot.usedPercent == 35) + #expect(result.snapshot.resetsAt == expectedReset) + #expect(result.sourceLabel == "grok-web") + } + + @Test + func `period only proxy payload without unified flag stays unknown`() throws { + let snapshot = try GrokCreditsProxyFetcher.parseSnapshot( + Data( + """ + { + "config": { + "currentPeriod": { + "type": "USAGE_PERIOD_TYPE_WEEKLY", + "start": "2026-08-31T12:35:57.056343+00:00", + "end": "2026-09-07T12:35:57.056343+00:00" + }, + "onDemandCap": { "val": 0 }, + "onDemandUsed": { "val": 0 }, + "billingPeriodEnd": "2026-09-07T12:35:57.056343+00:00" + } + } + """.utf8)) + + #expect(snapshot.usedPercent == nil) + } + @Test func `derives percent from on demand cap and usage`() throws { let snapshot = try GrokCreditsProxyFetcher.parseSnapshot( diff --git a/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift b/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift index 1faa196306..2c7c78b2fe 100644 --- a/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift +++ b/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift @@ -667,6 +667,76 @@ struct GrokWebBillingFetcherTests { // no-usage-yet reading and must never travel as a published percent. #expect(!snapshot.usedPercentIsWirePublished) } +} + +extension GrokWebBillingFetcherTests { + @Test + func `fractional billing timestamps do not publish a usage percent`() async throws { + // Captured frame from #3336. The nonzero [1,4,2]/[1,5,2] varints are timestamp + // nanoseconds, not limits or usage; the frame has no credit_usage_percent field. + let data = Data([ + 0x00, 0x00, 0x00, 0x00, 0x44, 0x0A, 0x42, 0x12, + 0x00, 0x1A, 0x00, 0x22, 0x0B, 0x08, 0xAD, 0xEA, + 0xD5, 0xD4, 0x06, 0x10, 0xD8, 0xF3, 0xEE, 0x1A, + 0x2A, 0x0B, 0x08, 0xAD, 0xDF, 0xFA, 0xD4, 0x06, + 0x10, 0xD8, 0xF3, 0xEE, 0x1A, 0x42, 0x1C, 0x08, + 0x02, 0x12, 0x0B, 0x08, 0xAD, 0xEA, 0xD5, 0xD4, + 0x06, 0x10, 0xD8, 0xF3, 0xEE, 0x1A, 0x1A, 0x0B, + 0x08, 0xAD, 0xDF, 0xFA, 0xD4, 0x06, 0x10, 0xD8, + 0xF3, 0xEE, 0x1A, 0x58, 0x01, 0x62, 0x00, 0x68, + 0x01, 0x80, 0x00, 0x00, 0x00, 0x0F, 0x67, 0x72, + 0x70, 0x63, 0x2D, 0x73, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x3A, 0x30, 0x0D, 0x0A, + ]) + + let snapshot = try GrokWebBillingFetcher.parseGRPCWebResponse( + data, + now: Date(timeIntervalSince1970: 1_788_000_000)) + + #expect(snapshot.usedPercent == 0) + #expect(snapshot.resetsAt == Date(timeIntervalSince1970: 1_788_784_557)) + #expect(snapshot.subscriptionTier == nil) + #expect(!snapshot.usedPercentIsWirePublished) + + let proxy = GrokWebBillingSnapshot( + usedPercent: nil, + resetsAt: Date(timeIntervalSince1970: 1_788_700_000), + subscriptionTier: "SuperGrok Heavy") + let result = try await GrokOAuthFetchStrategy.resolvingUnknownUsage( + proxy, + credentials: Self.credentials, + grpcBilling: { _ in snapshot }) + + #expect(result.snapshot == proxy) + #expect(result.sourceLabel == "grok-cli-proxy") + let usage = GrokUsageSnapshot( + billing: nil, + webBilling: result.snapshot, + credentials: Self.credentials, + localSummary: nil, + cliVersion: nil, + updatedAt: Date(timeIntervalSince1970: 1_788_000_000)).toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.loginMethod(for: .grok) == "SuperGrok Heavy") + } + + @Test(arguments: [Float(0), Float(20)]) + func `parsed wire percentages replace unknown proxy usage`(percent: Float) async throws { + let snapshot = try GrokWebBillingFetcher.parseGRPCWebResponse( + Self.grpcFrame(Self.protobufPayload(usedPercent: percent, resetEpoch: 1_800_604_803)), + now: Date(timeIntervalSince1970: 1_799_000_000)) + let proxyReset = Date(timeIntervalSince1970: 1_800_000_003) + let result = try await GrokOAuthFetchStrategy.resolvingUnknownUsage( + GrokWebBillingSnapshot(usedPercent: nil, resetsAt: proxyReset, subscriptionTier: "SuperGrok Heavy"), + credentials: Self.credentials, + grpcBilling: { _ in snapshot }) + + #expect(snapshot.usedPercentIsWirePublished) + #expect(result.snapshot.usedPercent == Double(percent)) + #expect(result.snapshot.resetsAt == proxyReset) + #expect(result.snapshot.subscriptionTier == "SuperGrok Heavy") + #expect(result.sourceLabel == "grok-web") + } @Test func `parses omitted zero percent with current billing period`() throws { @@ -770,7 +840,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))) } @@ -816,6 +886,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))) } @@ -920,6 +994,7 @@ extension GrokWebBillingFetcherTests { #expect(request.value(forHTTPHeaderField: "Cookie") == "sso=session; sso-rw=session") #expect(request.value(forHTTPHeaderField: "Authorization") == nil) #expect(request.value(forHTTPHeaderField: "x-user-agent") == "connect-es/2.1.1") + #expect(request.timeoutInterval == 15) let response = HTTPURLResponse( url: endpoint, statusCode: 200, @@ -935,6 +1010,7 @@ extension GrokWebBillingFetcherTests { endpoint: endpoint) #expect(snapshot.usedPercent == 9) + #expect(GrokWebBillingStubURLProtocol.requestBodies == [Data([0x00, 0x00, 0x00, 0x00, 0x02, 0x08, 0x00])]) } @Test @@ -955,6 +1031,7 @@ extension GrokWebBillingFetcherTests { GrokWebBillingStubURLProtocol.handler = { request in #expect(request.value(forHTTPHeaderField: "Cookie") == "sso=session") #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer token-123") + #expect(request.timeoutInterval == 15) let response = HTTPURLResponse( url: endpoint, statusCode: 200, @@ -970,6 +1047,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 5129130ee3..eedf42af08 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -84,8 +84,19 @@ 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`. + Explicitly encoding the default boolean supplies a nonempty protobuf message + for the reported `grpc-status 13` ("Missing request message.") rejection, + without excluding legacy monthly usage or selecting a billing period. + The current public web-client descriptor defines field 1 as this boolean, + not a weekly/monthly enum, and the web client requests `{}`. False preserves + that implicit proto3 default; whether the nonempty encoding restores the + affected account's response still requires live confirmation. + Protocol references: the public [billing descriptor](https://cdn.grok.com/_next/static/chunks/32g78bk5hhe1q.js), + [web billing query](https://cdn.grok.com/_next/static/chunks/062ueaj23tfo3.js) + (checked 2026-09-01), and [proto3 field presence](https://protobuf.dev/programming-guides/field_presence/). - 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 @@ -109,8 +120,10 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. - Parses the returned protobuf enough to recover used percent and reset timestamp, accepting both gRPC-web frames and the raw protobuf form returned by some successful requests. A current billing period with an - omitted proto3 `credit_usage_percent` is treated as zero usage. This keeps - billing visible when `grok agent stdio` returns `Method not found`. + omitted proto3 `credit_usage_percent` is treated as zero usage only on this + surface; it is not wire-published and cannot replace an unknown proxy percent. + Nonzero timestamp nanoseconds at `[1,4,2]`/`[1,5,2]` do not change that rule. + This keeps billing visible when `grok agent stdio` returns `Method not found`. 5) **Local session signals** (informational fallback) - Walks `~/.grok/sessions///signals.json` files (last 30 days). - Aggregates `totalTokensBeforeCompaction`, `contextTokensUsed`, `modelsUsed`,