From 2eb5b37b28c0b6326c081e008998fad97d434cf0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 24 Sep 2026 02:57:15 -0700 Subject: [PATCH] fix(grok): classify missing billing methods by RPC code --- CHANGELOG.md | 1 + .../Providers/Grok/GrokRPCClient.swift | 6 +++--- .../Providers/Grok/GrokStatusProbe.swift | 5 ++--- Tests/CodexBarTests/GrokAuthTests.swift | 10 +++++++--- .../GrokFailedBillingWorkTests.swift | 20 ++++++++++++++++--- .../RPCChildProcessTeardownTests.swift | 3 ++- docs/grok.md | 3 +++ 7 files changed, 35 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3400b6d76a..c3c12375b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Fixed +- Grok: preserve team identity and local token history when a missing billing RPC method changes its error wording, using the JSON-RPC error code for fallback (related to #3716). - Command Code: size monthly usage from the grant reported with credits, keeping the row available when the optional subscription lookup fails (#3939). Thanks @enieuwy! - Kimi: import web access tokens from Chromium local storage for the selected region, preserving manual and saved-account credential isolation (#3923). Thanks @kaishin! diff --git a/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift b/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift index e5e986a7cb..19629f8096 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift @@ -159,7 +159,7 @@ final class GrokRPCClient: @unchecked Sendable { guard let messageID = self.jsonID(message["id"]), messageID == id else { continue } if let error = message["error"] as? [String: Any] { let messageText = (error["message"] as? String) ?? "unknown JSON-RPC error" - throw GrokRPCError.requestFailed(messageText) + throw GrokRPCError.requestFailed(messageText, code: error["code"] as? Int) } return SendableJSONMessage(value: message) } @@ -246,7 +246,7 @@ final class GrokRPCClient: @unchecked Sendable { public enum GrokRPCError: LocalizedError, Sendable { case binaryNotFound case startFailed(String) - case requestFailed(String) + case requestFailed(String, code: Int? = nil) case timeout(method: String) case malformed(String) case notAuthenticated @@ -257,7 +257,7 @@ public enum GrokRPCError: LocalizedError, Sendable { return "Grok CLI not found. Install via `curl -fsSL https://x.ai/cli/install.sh | bash`." case let .startFailed(message): return "Grok CLI failed to start: \(message)" - case let .requestFailed(message): + case let .requestFailed(message, _): // Surface the auth-required hint that billing.rs emits verbatim. if message.localizedCaseInsensitiveContains("authentication required") || message.localizedCaseInsensitiveContains("grok login") diff --git a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift index 31a3bdaa7c..46f90c9c4b 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift @@ -209,12 +209,11 @@ public struct GrokStatusProbe: Sendable { static func isBillingMethodUnavailable(_ error: Error?) -> Bool { guard let error, - case let GrokRPCError.requestFailed(message) = error + case let GrokRPCError.requestFailed(_, code) = error else { return false } - let normalized = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return normalized == "method not found" || normalized.hasPrefix("method not found:") + return code == -32601 } static func shouldUseIdentityOnlyFallback( diff --git a/Tests/CodexBarTests/GrokAuthTests.swift b/Tests/CodexBarTests/GrokAuthTests.swift index ea4bb801ec..bc54243adf 100644 --- a/Tests/CodexBarTests/GrokAuthTests.swift +++ b/Tests/CodexBarTests/GrokAuthTests.swift @@ -196,9 +196,13 @@ struct GrokAuthTests { @Test func `team method unavailable is classified without broadening other rpc failures`() { #expect(GrokStatusProbe.isBillingMethodUnavailable( - GrokRPCError.requestFailed("Method not found"))) + GrokRPCError.requestFailed("Method not found", code: -32601))) #expect(GrokStatusProbe.isBillingMethodUnavailable( - GrokRPCError.requestFailed("Method not found: x.ai/billing"))) + GrokRPCError.requestFailed("Unsupported RPC method: x.ai/billing", code: -32601))) + #expect(!GrokStatusProbe.isBillingMethodUnavailable( + GrokRPCError.requestFailed("Method not found", code: -32001))) + #expect(!GrokStatusProbe.isBillingMethodUnavailable( + GrokRPCError.requestFailed("Method not found"))) #expect(!GrokStatusProbe.isBillingMethodUnavailable( GrokRPCError.requestFailed("Authentication required"))) #expect(!GrokStatusProbe.isBillingMethodUnavailable(nil)) @@ -208,7 +212,7 @@ struct GrokAuthTests { func `team identity fallback requires an attempted billing call`() throws { let json = #"{"https://auth.x.ai::client":{"key":"token","principal_type":"Team"}}"# let credentials = try GrokCredentialsStore.parse(data: Data(json.utf8)) - let methodNotFound = GrokRPCError.requestFailed("Method not found") + let methodNotFound = GrokRPCError.requestFailed("Method not found", code: -32601) #expect(GrokStatusProbe.shouldUseIdentityOnlyFallback( credentials: credentials, diff --git a/Tests/CodexBarTests/GrokFailedBillingWorkTests.swift b/Tests/CodexBarTests/GrokFailedBillingWorkTests.swift index 65aae950d3..69b0ad8e0b 100644 --- a/Tests/CodexBarTests/GrokFailedBillingWorkTests.swift +++ b/Tests/CodexBarTests/GrokFailedBillingWorkTests.swift @@ -6,9 +6,11 @@ struct GrokFailedBillingWorkTests { enum Scenario: String, CaseIterable, Sendable { case personalUnavailable, teamUnauthorized, initializationFailure, expiredTeam, teamFallback, billingSuccess case expiresDuringScan, expiresDuringVersion, acceptedIdentity + case methodUnavailableAlternateMessage, methodMessageWrongCode var acceptsSnapshot: Bool { self == .teamFallback || self == .billingSuccess || self == .acceptedIdentity + || self == .methodUnavailableAlternateMessage } var scansBeforeResult: Bool { @@ -19,9 +21,18 @@ struct GrokFailedBillingWorkTests { switch self { case .teamUnauthorized: "Unauthorized" case .initializationFailure: "Initialize failed" + case .methodUnavailableAlternateMessage: "Unsupported RPC method: x.ai/billing" default: "Method not found" } } + + var errorCode: Int { + switch self { + case .teamUnauthorized, .methodMessageWrongCode: -32001 + case .initializationFailure: -32603 + default: -32601 + } + } } @Test(arguments: Scenario.allCases) @@ -45,7 +56,7 @@ struct GrokFailedBillingWorkTests { func reply(id: Int, result: [String: Any], error: String?) throws -> String { var value: [String: Any] = ["jsonrpc": "2.0", "id": id] if let error { - value["error"] = ["code": -32601, "message": error] + value["error"] = ["code": scenario.errorCode, "message": error] } else { value["result"] = result } @@ -118,6 +129,7 @@ struct GrokFailedBillingWorkTests { #expect(scenario.acceptsSnapshot) #expect(snapshot.localSummary?.totalTokens == 42) #expect(snapshot.localSummary?.daily == summary.daily) + #expect(snapshot.toUsageSnapshot().costUsage?.last30DaysTokens == 42) #expect(snapshot.cliVersion == "synthetic-version") if scenario != .billingSuccess { #expect(snapshot.billing == nil) @@ -129,14 +141,16 @@ struct GrokFailedBillingWorkTests { } } catch let error as GrokRPCError { #expect(!scenario.acceptsSnapshot) - guard case let .requestFailed(message) = error else { + guard case let .requestFailed(message, code) = error else { Issue.record("Unexpected RPC error: \(error)") return } #expect(message == scenario.errorMessage) + #expect(code == scenario.errorCode) } #expect(await calls.scans == (scenario.scansBeforeResult ? 1 : 0)) - #expect(await calls.settings == (scenario == .teamFallback ? 1 : 0)) + #expect(await calls + .settings == ([.teamFallback, .methodUnavailableAlternateMessage].contains(scenario) ? 1 : 0)) } } diff --git a/Tests/CodexBarTests/RPCChildProcessTeardownTests.swift b/Tests/CodexBarTests/RPCChildProcessTeardownTests.swift index 3ccc61edb1..e8921416bb 100644 --- a/Tests/CodexBarTests/RPCChildProcessTeardownTests.swift +++ b/Tests/CodexBarTests/RPCChildProcessTeardownTests.swift @@ -98,11 +98,12 @@ struct RPCChildProcessTeardownTests { let error = await #expect(throws: GrokRPCError.self) { try await client.initialize() } - guard case let .requestFailed(message) = error else { + guard case let .requestFailed(message, code) = error else { Issue.record("Expected a normal Grok request failure, got \(String(describing: error))") return } #expect(message.contains("stdin closed")) + #expect(code == nil) } @Test diff --git a/docs/grok.md b/docs/grok.md index ee251c1b56..d1ad11ec55 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -39,6 +39,9 @@ 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. + - Missing methods are classified by JSON-RPC code `-32601`, independently of + the error message. The team fallback retains local token history even when + the CLI changes its wording; other RPC errors remain failures. - A terminal CLI billing failure returns before scanning local session history or probing the CLI version, so the provider fallback does not wait for data that would be discarded. Successful billing and the established identity-only team fallback retain local history and plan enrichment. - After a successful RPC billing result (or the identity-only team fallback), CodexBar still GETs `/v1/settings` for `subscription_tier_display` so the