From 16ace1a66d9525ff16e0261d1a1b0c1757efb762 Mon Sep 17 00:00:00 2001 From: noisy Date: Fri, 16 Oct 2026 22:24:20 +0200 Subject: [PATCH 1/3] Fix Grok CLI billing method name Grok's ACP server exposes billing as the extension method `_x.ai/billing`; `x.ai/billing` returns JSON-RPC -32601 on Grok CLI 1.0.60 and 1.0.62. Request the underscore name first and retry the legacy name once when the method is unavailable. Co-Authored-By: Claude Opus 5.5 --- .../Providers/Grok/GrokRPCClient.swift | 22 +++++++----- .../GrokFailedBillingWorkTests.swift | 34 ++++++++++++++++--- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift b/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift index 1178e1d638f..2b24b8c17df 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift @@ -4,7 +4,7 @@ import Foundation /// /// The protocol mirrors Codex's app-server (newline-delimited JSON-RPC 2.0 over stdin/stdout), /// but uses `protocolVersion`/`clientCapabilities` for the `initialize` call instead of -/// `clientInfo`. Billing is fetched via the `x.ai/billing` extension method. +/// `clientInfo`. Billing uses `_x.ai/billing`, with `x.ai/billing` as a legacy fallback. final class GrokRPCClient: @unchecked Sendable { private static let log = CodexBarLog.logger(LogCategories.provider(.grok)) @@ -77,9 +77,15 @@ final class GrokRPCClient: @unchecked Sendable { timeout: self.initializeTimeoutSeconds) } - /// Calls `x.ai/billing` and returns the decoded response. + /// Calls `_x.ai/billing`, retrying legacy `x.ai/billing` only when the method is unavailable. func fetchBilling() async throws -> GrokBillingResponse { - let message = try await self.request(method: "x.ai/billing", params: [:]) + let message: [String: Any] + do { + message = try await self.request(method: "_x.ai/billing", params: [:]) + } catch { + guard GrokStatusProbe.isBillingMethodUnavailable(error) else { throw error } + message = try await self.request(method: "x.ai/billing", params: [:]) + } return try self.decodeResult(from: message) } @@ -150,11 +156,9 @@ final class GrokRPCClient: @unchecked Sendable { private func sendPayload(_ payload: [String: Any]) throws { let raw = try JSONSerialization.data(withJSONObject: payload) - // Foundation's JSONSerialization escapes "/" as "\/" by default. Grok's - // ACP server treats the escaped form as a *different* method name (it does - // not unescape before lookup), so `x.ai/billing` becomes "Method not found" - // when sent as `x.ai\/billing`. Re-encode without slash escapes to match - // the on-the-wire shape the grok agent expects. + // Foundation's JSONSerialization escapes "/" as "\/" by default. Keep + // `_x.ai/billing` and legacy `x.ai/billing` unescaped on the wire for + // compatibility with Grok ACP servers that look up method names literally. let unescaped = String(data: raw, encoding: .utf8)? .replacingOccurrences(of: "\\/", with: "/") var data = unescaped.flatMap { $0.data(using: .utf8) } ?? raw @@ -232,7 +236,7 @@ public enum GrokRPCError: LocalizedError, Sendable { } } -/// Schema for the `x.ai/billing` result. +/// Schema for the `_x.ai/billing` result (also used by legacy `x.ai/billing`). /// /// All monetary amounts (`monthlyLimit`, `onDemandCap`, `includedUsed`, etc.) are /// represented as `{ val: }` in the wire format. diff --git a/Tests/CodexBarTests/GrokFailedBillingWorkTests.swift b/Tests/CodexBarTests/GrokFailedBillingWorkTests.swift index 69b0ad8e0b8..6e827c113dc 100644 --- a/Tests/CodexBarTests/GrokFailedBillingWorkTests.swift +++ b/Tests/CodexBarTests/GrokFailedBillingWorkTests.swift @@ -6,10 +6,18 @@ struct GrokFailedBillingWorkTests { enum Scenario: String, CaseIterable, Sendable { case personalUnavailable, teamUnauthorized, initializationFailure, expiredTeam, teamFallback, billingSuccess case expiresDuringScan, expiresDuringVersion, acceptedIdentity - case methodUnavailableAlternateMessage, methodMessageWrongCode + case methodUnavailableAlternateMessage, methodMessageWrongCode, legacyBillingSuccess + + var hasBilling: Bool { + self == .billingSuccess || self == .legacyBillingSuccess + } + + var retriesBilling: Bool { + self != .initializationFailure && self != .billingSuccess && self.errorCode == -32601 + } var acceptsSnapshot: Bool { - self == .teamFallback || self == .billingSuccess || self == .acceptedIdentity + self == .teamFallback || self.hasBilling || self == .acceptedIdentity || self == .methodUnavailableAlternateMessage } @@ -40,7 +48,7 @@ struct GrokFailedBillingWorkTests { let root = FileManager.default.temporaryDirectory.appendingPathComponent("grok-work-\(UUID().uuidString)") try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: root) } - if scenario != .personalUnavailable, scenario != .billingSuccess { + if scenario != .personalUnavailable, !scenario.hasBilling { let auth = try JSONSerialization.data(withJSONObject: [ "https://auth.x.ai::fake-client": [ "key": "synthetic-token", "refresh_token": "synthetic-refresh", @@ -69,6 +77,10 @@ struct GrokFailedBillingWorkTests { id: 2, result: ["monthlyLimit": ["val": 100], "usage": ["totalUsed": ["val": 25]]], error: scenario == .billingSuccess ? nil : scenario.errorMessage) + let legacyBilling = try reply( + id: 3, + result: ["monthlyLimit": ["val": 100], "usage": ["totalUsed": ["val": 25]]], + error: scenario == .legacyBillingSuccess ? nil : scenario.errorMessage) let script = """ #!/bin/sh if [ "$1" = "--version" ]; then @@ -79,7 +91,11 @@ struct GrokFailedBillingWorkTests { IFS= read -r initialize_request printf '%s\\n' '\(initialize)' IFS= read -r billing_request || exit 0 + printf '%s\\n' "$billing_request" >> "$GROK_HOME/billing-requests.jsonl" printf '%s\\n' '\(billing)' + IFS= read -r legacy_billing_request || exit 0 + printf '%s\\n' "$legacy_billing_request" >> "$GROK_HOME/billing-requests.jsonl" + printf '%s\\n' '\(legacyBilling)' """ try script.write(to: binary, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path) @@ -131,7 +147,7 @@ struct GrokFailedBillingWorkTests { #expect(snapshot.localSummary?.daily == summary.daily) #expect(snapshot.toUsageSnapshot().costUsage?.last30DaysTokens == 42) #expect(snapshot.cliVersion == "synthetic-version") - if scenario != .billingSuccess { + if !scenario.hasBilling { #expect(snapshot.billing == nil) #expect(snapshot.credentials?.email == "team@example.com") #expect(snapshot.diagnostic == GrokStatusProbe.teamUsageUnavailableMessage) @@ -148,6 +164,16 @@ struct GrokFailedBillingWorkTests { #expect(message == scenario.errorMessage) #expect(code == scenario.errorCode) } + if scenario != .initializationFailure { + let requests = try String( + contentsOf: root.appendingPathComponent("billing-requests.jsonl"), encoding: .utf8) + let methods = try requests.split(separator: "\n").map { line in + let request = try #require( + JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any]) + return try #require(request["method"] as? String) + } + #expect(methods == (scenario.retriesBilling ? ["_x.ai/billing", "x.ai/billing"] : ["_x.ai/billing"])) + } #expect(await calls.scans == (scenario.scansBeforeResult ? 1 : 0)) #expect(await calls .settings == ([.teamFallback, .methodUnavailableAlternateMessage].contains(scenario) ? 1 : 0)) From 5392c4d1c603ff0899994715ed79d380cb9a6ed8 Mon Sep 17 00:00:00 2001 From: noisy Date: Sat, 17 Oct 2026 00:28:48 +0200 Subject: [PATCH 2/3] Decode nested Grok billing and answer retry in fixture `_x.ai/billing` returns `{config: {creditUsagePercent, currentPeriod, ...}}`. Reuse the nested credits parser for it, keep the legacy top-level decoder, and reject results with no usage percentage so the pipeline can fall back. Teach the account-context fixture to answer the legacy retry. Co-Authored-By: Claude Opus 5.5 --- .../Providers/Grok/GrokRPCClient.swift | 22 +++++-- .../Providers/Grok/GrokStatusProbe.swift | 15 +++-- .../BoundedChildProcessProofTests.swift | 5 +- .../GrokAccountContextTests.swift | 2 + .../GrokFailedBillingWorkTests.swift | 59 ++++++++++++++++--- 5 files changed, 83 insertions(+), 20 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift b/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift index 2b24b8c17df..bd0d1e9bf89 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift @@ -78,7 +78,7 @@ final class GrokRPCClient: @unchecked Sendable { } /// Calls `_x.ai/billing`, retrying legacy `x.ai/billing` only when the method is unavailable. - func fetchBilling() async throws -> GrokBillingResponse { + func fetchBilling() async throws -> GrokRPCBilling { let message: [String: Any] do { message = try await self.request(method: "_x.ai/billing", params: [:]) @@ -186,13 +186,20 @@ final class GrokRPCClient: @unchecked Sendable { throw GrokRPCError.malformed("grok agent stdio closed stdout") } - private func decodeResult(from message: [String: Any]) throws -> T { + private func decodeResult(from message: [String: Any]) throws -> GrokRPCBilling { guard let result = message["result"] else { throw GrokRPCError.malformed("missing result field") } let data = try JSONSerialization.data(withJSONObject: result) - let decoder = JSONDecoder() - return try decoder.decode(T.self, from: data) + if let credits = try? GrokCreditsProxyFetcher.parseSnapshot(data), credits.usedPercent != nil { + return .credits(credits) + } + if let billing = try? JSONDecoder().decode(GrokBillingResponse.self, from: data), + billing.monthlyUsedPercent != nil + { + return .legacy(billing) + } + throw GrokRPCError.malformed("billing result did not report a usage percentage") } private func jsonID(_ value: Any?) -> Int? { @@ -204,6 +211,11 @@ final class GrokRPCClient: @unchecked Sendable { } } +enum GrokRPCBilling: Sendable { + case credits(GrokWebBillingSnapshot) + case legacy(GrokBillingResponse) +} + public enum GrokRPCError: LocalizedError, Sendable { case binaryNotFound case startFailed(String) @@ -236,7 +248,7 @@ public enum GrokRPCError: LocalizedError, Sendable { } } -/// Schema for the `_x.ai/billing` result (also used by legacy `x.ai/billing`). +/// Legacy top-level billing schema; current `_x.ai/billing` results use the nested credits parser. /// /// All monetary amounts (`monthlyLimit`, `onDemandCap`, `includedUsed`, etc.) are /// represented as `{ val: }` in the wire format. diff --git a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift index 6eb8d2f054b..9b6f5b4cd05 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift @@ -158,6 +158,7 @@ public struct GrokStatusProbe: Sendable { let credentials = try? GrokCredentialsStore.load(env: env) var billing: GrokBillingResponse? + var webBilling: GrokWebBillingSnapshot? var rpcError: Error? var billingAttempted = false do { @@ -165,14 +166,18 @@ public struct GrokStatusProbe: Sendable { defer { client.shutdown() } try await client.initialize() billingAttempted = true - billing = try await client.fetchBilling() + switch try await client.fetchBilling() { + case let .legacy(response): billing = response + case let .credits(response): webBilling = response + } } catch { rpcError = error } - let isIdentityOnly = billing == nil && self.identityOnlyFallback(credentials, billingAttempted, rpcError) + let hasBilling = billing != nil || webBilling != nil + let isIdentityOnly = !hasBilling && self.identityOnlyFallback(credentials, billingAttempted, rpcError) // Terminal CLI failures must reach the provider's web fallback without scanning discarded history. - guard billing != nil || isIdentityOnly else { + guard hasBilling || isIdentityOnly else { throw rpcError ?? GrokRPCError.notAuthenticated } @@ -187,11 +192,11 @@ public struct GrokStatusProbe: Sendable { session: self.settingsTransport) return GrokUsageSnapshot( billing: billing, - webBilling: nil, + webBilling: webBilling, credentials: isIdentityOnly ? credentials : Self.credentialsForSnapshot( credentials: credentials, billing: billing, - webBilling: nil), + webBilling: webBilling), localSummary: localSummary, cliVersion: cliVersion, updatedAt: Date(), diff --git a/Tests/CodexBarTests/BoundedChildProcessProofTests.swift b/Tests/CodexBarTests/BoundedChildProcessProofTests.swift index e644968645d..132f8330171 100644 --- a/Tests/CodexBarTests/BoundedChildProcessProofTests.swift +++ b/Tests/CodexBarTests/BoundedChildProcessProofTests.swift @@ -85,7 +85,10 @@ struct BoundedChildProcessProofTests { defer { client.shutdown() } try await client.initialize() - let billing = try await client.fetchBilling() + guard case let .legacy(billing) = try await client.fetchBilling() else { + Issue.record("Expected legacy billing response") + return + } #expect(billing.monthlyLimit?.val == 100) #expect(billing.usage?.totalUsed?.val == 25) diff --git a/Tests/CodexBarTests/GrokAccountContextTests.swift b/Tests/CodexBarTests/GrokAccountContextTests.swift index 34bee529c2a..2ada945544e 100644 --- a/Tests/CodexBarTests/GrokAccountContextTests.swift +++ b/Tests/CodexBarTests/GrokAccountContextTests.swift @@ -283,6 +283,8 @@ struct GrokAccountContextTests { 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"}}' + IFS= read -r legacy_billing_request || exit 0 + printf '%s\\n' '{"jsonrpc":"2.0","id":3,"error":{"code":-32601,"message":"Method not found"}}' """ try script.write(toFile: binary, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary) diff --git a/Tests/CodexBarTests/GrokFailedBillingWorkTests.swift b/Tests/CodexBarTests/GrokFailedBillingWorkTests.swift index 6e827c113dc..818708e6f43 100644 --- a/Tests/CodexBarTests/GrokFailedBillingWorkTests.swift +++ b/Tests/CodexBarTests/GrokFailedBillingWorkTests.swift @@ -7,13 +7,22 @@ struct GrokFailedBillingWorkTests { case personalUnavailable, teamUnauthorized, initializationFailure, expiredTeam, teamFallback, billingSuccess case expiresDuringScan, expiresDuringVersion, acceptedIdentity case methodUnavailableAlternateMessage, methodMessageWrongCode, legacyBillingSuccess + case nestedBillingSuccess, nestedBillingPeriodFallback, unknownBillingResult + + var hasCredits: Bool { + self == .nestedBillingSuccess || self == .nestedBillingPeriodFallback + } var hasBilling: Bool { - self == .billingSuccess || self == .legacyBillingSuccess + self == .billingSuccess || self == .legacyBillingSuccess || self.hasCredits + } + + var returnsBillingResult: Bool { + self == .billingSuccess || self.hasCredits || self == .unknownBillingResult } var retriesBilling: Bool { - self != .initializationFailure && self != .billingSuccess && self.errorCode == -32601 + self != .initializationFailure && !self.returnsBillingResult && self.errorCode == -32601 } var acceptsSnapshot: Bool { @@ -73,10 +82,30 @@ struct GrokFailedBillingWorkTests { } let initialize = try reply( id: 1, result: [:], error: scenario == .initializationFailure ? scenario.errorMessage : nil) + let billingResult: [String: Any] + if scenario.hasCredits { + var config: [String: Any] = [ + "creditUsagePercent": 5.0, + "billingPeriodEnd": "2026-10-22T17:24:18.000000+00:00", + ] + if scenario == .nestedBillingSuccess { + config["currentPeriod"] = [ + "type": "USAGE_PERIOD_TYPE_WEEKLY", + "start": "2026-10-15T17:24:18.000000+00:00", + "end": "2026-10-22T17:24:18.000000+00:00", + ] + config["billingPeriodEnd"] = "2026-11-15T17:24:18Z" + } + billingResult = ["config": config, "subscription_tier": "SuperGrok"] + } else if scenario == .unknownBillingResult { + billingResult = ["subscription_tier": "SuperGrok", "config": [:]] + } else { + billingResult = ["monthlyLimit": ["val": 100], "usage": ["totalUsed": ["val": 25]]] + } let billing = try reply( id: 2, - result: ["monthlyLimit": ["val": 100], "usage": ["totalUsed": ["val": 25]]], - error: scenario == .billingSuccess ? nil : scenario.errorMessage) + result: billingResult, + error: scenario.returnsBillingResult ? nil : scenario.errorMessage) let legacyBilling = try reply( id: 3, result: ["monthlyLimit": ["val": 100], "usage": ["totalUsed": ["val": 25]]], @@ -151,18 +180,30 @@ struct GrokFailedBillingWorkTests { #expect(snapshot.billing == nil) #expect(snapshot.credentials?.email == "team@example.com") #expect(snapshot.diagnostic == GrokStatusProbe.teamUsageUnavailableMessage) + } else if scenario.hasCredits { + let primary = try #require(snapshot.toUsageSnapshot().primary) + #expect(primary.usedPercent == 5) + #expect(primary.resetsAt == ISO8601DateParser.parse("2026-10-22T17:24:18Z")) + #expect(snapshot.diagnostic == nil) } else { #expect(snapshot.billing?.monthlyUsedPercent == 25) #expect(snapshot.diagnostic == nil) } } catch let error as GrokRPCError { #expect(!scenario.acceptsSnapshot) - guard case let .requestFailed(message, code) = error else { - Issue.record("Unexpected RPC error: \(error)") - return + if scenario == .unknownBillingResult { + guard case .malformed = error else { + Issue.record("Expected malformed billing error: \(error)") + return + } + } 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(message == scenario.errorMessage) - #expect(code == scenario.errorCode) } if scenario != .initializationFailure { let requests = try String( From 48b56639a1d7f721ce59b0f2fe2c64890757ab14 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Oct 2026 19:19:08 -0700 Subject: [PATCH 3/3] fix(grok): validate ACP billing and preserve plan metadata Refs #4595 Co-authored-by: noisy --- CHANGELOG.md | 4 + .../Providers/Grok/GrokRPCClient.swift | 74 ++++++----------- .../GrokAccountContextTests.swift | 2 - .../GrokFailedBillingWorkTests.swift | 82 +++++++++---------- docs/grok.md | 35 ++++---- 5 files changed, 87 insertions(+), 110 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 636ab772be1..b342bc497ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.77.1 — Unreleased +### Fixed + +- Grok: restore CLI billing through the ACP extension method, including current usage, reset, plan, and prepaid balance fields (#4595). Thanks @noisy! + ## 0.77.0 — 2026-10-16 ### Highlights diff --git a/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift b/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift index bd0d1e9bf89..3ab1b621606 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift @@ -4,7 +4,7 @@ import Foundation /// /// The protocol mirrors Codex's app-server (newline-delimited JSON-RPC 2.0 over stdin/stdout), /// but uses `protocolVersion`/`clientCapabilities` for the `initialize` call instead of -/// `clientInfo`. Billing uses `_x.ai/billing`, with `x.ai/billing` as a legacy fallback. +/// `clientInfo`. Billing uses the `_x.ai/billing` ACP extension method. final class GrokRPCClient: @unchecked Sendable { private static let log = CodexBarLog.logger(LogCategories.provider(.grok)) @@ -77,16 +77,24 @@ final class GrokRPCClient: @unchecked Sendable { timeout: self.initializeTimeoutSeconds) } - /// Calls `_x.ai/billing`, retrying legacy `x.ai/billing` only when the method is unavailable. + /// Calls the ACP billing extension and decodes current or legacy response payloads. func fetchBilling() async throws -> GrokRPCBilling { - let message: [String: Any] - do { - message = try await self.request(method: "_x.ai/billing", params: [:]) - } catch { - guard GrokStatusProbe.isBillingMethodUnavailable(error) else { throw error } - message = try await self.request(method: "x.ai/billing", params: [:]) + let message = try await self.request(method: "_x.ai/billing", params: [:]) + guard let result = message["result"] as? [String: Any] else { + throw GrokRPCError.malformed("missing result field") + } + let data = try JSONSerialization.data(withJSONObject: result) + if let credits = try? GrokCreditsProxyFetcher.parseSnapshot(data), + credits.usedPercent != nil || credits.prepaidBalanceUSD != nil + { + return .credits(credits.applying(subscriptionTier: result["subscription_tier"] as? String)) + } + if let billing = try? JSONDecoder().decode(GrokBillingResponse.self, from: data), + billing.monthlyUsedPercent != nil + { + return .legacy(billing) } - return try self.decodeResult(from: message) + throw GrokRPCError.malformed(GrokStatusProbe.usageUnavailableMessage) } func shutdown() { @@ -107,7 +115,12 @@ final class GrokRPCClient: @unchecked Sendable { { let id = self.nextID self.nextID += 1 - try self.sendRequest(id: id, method: method, params: params) + try self.sendPayload([ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params ?? [:], + ]) let resolvedTimeout = timeout ?? self.requestTimeoutSeconds let wrapped = try await RPCRequestTimeout.run( @@ -143,25 +156,8 @@ final class GrokRPCClient: @unchecked Sendable { } } - private func sendRequest(id: Int, method: String, params: [String: Any]?) throws { - let paramsValue: Any = params ?? [:] - let payload: [String: Any] = [ - "jsonrpc": "2.0", - "id": id, - "method": method, - "params": paramsValue, - ] - try self.sendPayload(payload) - } - private func sendPayload(_ payload: [String: Any]) throws { - let raw = try JSONSerialization.data(withJSONObject: payload) - // Foundation's JSONSerialization escapes "/" as "\/" by default. Keep - // `_x.ai/billing` and legacy `x.ai/billing` unescaped on the wire for - // compatibility with Grok ACP servers that look up method names literally. - let unescaped = String(data: raw, encoding: .utf8)? - .replacingOccurrences(of: "\\/", with: "/") - var data = unescaped.flatMap { $0.data(using: .utf8) } ?? raw + var data = try JSONSerialization.data(withJSONObject: payload, options: [.withoutEscapingSlashes]) if let preview = String(data: data.prefix(200), encoding: .utf8) { Self.log.debug("grok rpc -> \(preview)") } @@ -186,28 +182,8 @@ final class GrokRPCClient: @unchecked Sendable { throw GrokRPCError.malformed("grok agent stdio closed stdout") } - private func decodeResult(from message: [String: Any]) throws -> GrokRPCBilling { - guard let result = message["result"] else { - throw GrokRPCError.malformed("missing result field") - } - let data = try JSONSerialization.data(withJSONObject: result) - if let credits = try? GrokCreditsProxyFetcher.parseSnapshot(data), credits.usedPercent != nil { - return .credits(credits) - } - if let billing = try? JSONDecoder().decode(GrokBillingResponse.self, from: data), - billing.monthlyUsedPercent != nil - { - return .legacy(billing) - } - throw GrokRPCError.malformed("billing result did not report a usage percentage") - } - private func jsonID(_ value: Any?) -> Int? { - switch value { - case let int as Int: int - case let number as NSNumber: number.intValue - default: nil - } + (value as? NSNumber)?.intValue } } diff --git a/Tests/CodexBarTests/GrokAccountContextTests.swift b/Tests/CodexBarTests/GrokAccountContextTests.swift index 2ada945544e..34bee529c2a 100644 --- a/Tests/CodexBarTests/GrokAccountContextTests.swift +++ b/Tests/CodexBarTests/GrokAccountContextTests.swift @@ -283,8 +283,6 @@ struct GrokAccountContextTests { 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"}}' - IFS= read -r legacy_billing_request || exit 0 - printf '%s\\n' '{"jsonrpc":"2.0","id":3,"error":{"code":-32601,"message":"Method not found"}}' """ try script.write(toFile: binary, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary) diff --git a/Tests/CodexBarTests/GrokFailedBillingWorkTests.swift b/Tests/CodexBarTests/GrokFailedBillingWorkTests.swift index 818708e6f43..672faee40d7 100644 --- a/Tests/CodexBarTests/GrokFailedBillingWorkTests.swift +++ b/Tests/CodexBarTests/GrokFailedBillingWorkTests.swift @@ -6,25 +6,21 @@ struct GrokFailedBillingWorkTests { enum Scenario: String, CaseIterable, Sendable { case personalUnavailable, teamUnauthorized, initializationFailure, expiredTeam, teamFallback, billingSuccess case expiresDuringScan, expiresDuringVersion, acceptedIdentity - case methodUnavailableAlternateMessage, methodMessageWrongCode, legacyBillingSuccess - case nestedBillingSuccess, nestedBillingPeriodFallback, unknownBillingResult + case methodUnavailableAlternateMessage, methodMessageWrongCode + case nestedBillingSuccess, nestedBillingPeriodFallback, prepaidOnlyBillingSuccess, unknownBillingResult var hasCredits: Bool { - self == .nestedBillingSuccess || self == .nestedBillingPeriodFallback + self == .nestedBillingSuccess || self == .nestedBillingPeriodFallback || self == .prepaidOnlyBillingSuccess } var hasBilling: Bool { - self == .billingSuccess || self == .legacyBillingSuccess || self.hasCredits + self == .billingSuccess || self.hasCredits } var returnsBillingResult: Bool { self == .billingSuccess || self.hasCredits || self == .unknownBillingResult } - var retriesBilling: Bool { - self != .initializationFailure && !self.returnsBillingResult && self.errorCode == -32601 - } - var acceptsSnapshot: Bool { self == .teamFallback || self.hasBilling || self == .acceptedIdentity || self == .methodUnavailableAlternateMessage @@ -38,11 +34,35 @@ struct GrokFailedBillingWorkTests { switch self { case .teamUnauthorized: "Unauthorized" case .initializationFailure: "Initialize failed" - case .methodUnavailableAlternateMessage: "Unsupported RPC method: x.ai/billing" + case .methodUnavailableAlternateMessage: "Unsupported RPC method: _x.ai/billing" default: "Method not found" } } + var billingResult: [String: Any] { + if self.hasCredits { + var config: [String: Any] = [ + "creditUsagePercent": 5.0, + "billingPeriodEnd": "2026-10-22T17:24:18.000000+00:00", + ] + if self == .nestedBillingSuccess { + config["currentPeriod"] = [ + "type": "USAGE_PERIOD_TYPE_WEEKLY", + "start": "2026-10-15T17:24:18.000000+00:00", + "end": "2026-10-22T17:24:18.000000+00:00", + ] + config["billingPeriodEnd"] = "2026-11-15T17:24:18Z" + } else if self == .prepaidOnlyBillingSuccess { + config = ["prepaidBalance": ["val": 123]] + } + return ["config": config, "subscription_tier": "SuperGrok Heavy"] + } else if self == .unknownBillingResult { + return ["subscription_tier": "SuperGrok", "config": [:]] + } else { + return ["monthlyLimit": ["val": 100], "usage": ["totalUsed": ["val": 25]]] + } + } + var errorCode: Int { switch self { case .teamUnauthorized, .methodMessageWrongCode: -32001 @@ -82,34 +102,10 @@ struct GrokFailedBillingWorkTests { } let initialize = try reply( id: 1, result: [:], error: scenario == .initializationFailure ? scenario.errorMessage : nil) - let billingResult: [String: Any] - if scenario.hasCredits { - var config: [String: Any] = [ - "creditUsagePercent": 5.0, - "billingPeriodEnd": "2026-10-22T17:24:18.000000+00:00", - ] - if scenario == .nestedBillingSuccess { - config["currentPeriod"] = [ - "type": "USAGE_PERIOD_TYPE_WEEKLY", - "start": "2026-10-15T17:24:18.000000+00:00", - "end": "2026-10-22T17:24:18.000000+00:00", - ] - config["billingPeriodEnd"] = "2026-11-15T17:24:18Z" - } - billingResult = ["config": config, "subscription_tier": "SuperGrok"] - } else if scenario == .unknownBillingResult { - billingResult = ["subscription_tier": "SuperGrok", "config": [:]] - } else { - billingResult = ["monthlyLimit": ["val": 100], "usage": ["totalUsed": ["val": 25]]] - } let billing = try reply( id: 2, - result: billingResult, + result: scenario.billingResult, error: scenario.returnsBillingResult ? nil : scenario.errorMessage) - let legacyBilling = try reply( - id: 3, - result: ["monthlyLimit": ["val": 100], "usage": ["totalUsed": ["val": 25]]], - error: scenario == .legacyBillingSuccess ? nil : scenario.errorMessage) let script = """ #!/bin/sh if [ "$1" = "--version" ]; then @@ -122,9 +118,6 @@ struct GrokFailedBillingWorkTests { IFS= read -r billing_request || exit 0 printf '%s\\n' "$billing_request" >> "$GROK_HOME/billing-requests.jsonl" printf '%s\\n' '\(billing)' - IFS= read -r legacy_billing_request || exit 0 - printf '%s\\n' "$legacy_billing_request" >> "$GROK_HOME/billing-requests.jsonl" - printf '%s\\n' '\(legacyBilling)' """ try script.write(to: binary, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path) @@ -181,9 +174,16 @@ struct GrokFailedBillingWorkTests { #expect(snapshot.credentials?.email == "team@example.com") #expect(snapshot.diagnostic == GrokStatusProbe.teamUsageUnavailableMessage) } else if scenario.hasCredits { - let primary = try #require(snapshot.toUsageSnapshot().primary) - #expect(primary.usedPercent == 5) - #expect(primary.resetsAt == ISO8601DateParser.parse("2026-10-22T17:24:18Z")) + let usage = snapshot.toUsageSnapshot() + if scenario == .prepaidOnlyBillingSuccess { + #expect(usage.primary == nil) + #expect(usage.providerCost?.balance == 1.23) + } else { + let primary = try #require(usage.primary) + #expect(primary.usedPercent == 5) + #expect(primary.resetsAt == ISO8601DateParser.parse("2026-10-22T17:24:18Z")) + } + #expect(usage.identity?.loginMethod == "SuperGrok Heavy") #expect(snapshot.diagnostic == nil) } else { #expect(snapshot.billing?.monthlyUsedPercent == 25) @@ -213,7 +213,7 @@ struct GrokFailedBillingWorkTests { JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any]) return try #require(request["method"] as? String) } - #expect(methods == (scenario.retriesBilling ? ["_x.ai/billing", "x.ai/billing"] : ["_x.ai/billing"])) + #expect(methods == ["_x.ai/billing"]) } #expect(await calls.scans == (scenario.scansBeforeResult ? 1 : 0)) #expect(await calls diff --git a/docs/grok.md b/docs/grok.md index 798b5346f2c..93872a5ebe9 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -9,7 +9,7 @@ read_when: # Grok provider Grok uses xAI's official Grok Build CLI (`grok`, released 2026-05-14). Usage data is -fetched via the ACP JSON-RPC `x.ai/billing` extension method over `grok agent stdio` +fetched via the ACP JSON-RPC `_x.ai/billing` extension method over `grok agent stdio` when available, then via the Grok CLI billing REST API using the local login token. The grok.com billing gRPC-web endpoint remains a best-effort fallback. @@ -31,14 +31,15 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. exposes a supported team usage surface, CodexBar keeps the identity row and reports that team usage is unavailable instead of exposing the personal-team rejection verbatim. -2) **`grok agent stdio` ACP JSON-RPC** (best-effort, currently disabled in grok 0.1.210) - - We spawn `grok agent stdio` and call `initialize` + `x.ai/billing` (no params). - - **Known limitation:** in grok 0.1.210 the `x.ai/billing` extension method - is only wired in the interactive TUI; the agent-stdio surface returns - `-32601 Method not found`. Personal/unknown principals continue to the web - 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. +2) **`grok agent stdio` ACP JSON-RPC** + - We spawn `grok agent stdio` and call `initialize` + `_x.ai/billing` (no params). + ACP prefixes extension methods with `_` on the wire; the unprefixed method is not retried. + - Current responses use the shared CLI-proxy credits parser for nested `config` usage, + periods, product usage, and prepaid balance. The RPC wrapper's `subscription_tier` supplies the plan. + Older `monthlyLimit` / `usage.totalUsed` payloads remain supported. A prepaid balance remains + useful without a quota percentage; results with neither are unavailable instead of empty snapshots. + - If the extension returns `-32601`, personal/unknown principals continue to the web fallback, + while a team principal degrades to identity-only with the unsupported-team-usage diagnostic. - 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. @@ -47,11 +48,7 @@ The grok.com billing gRPC-web endpoint remains a best-effort 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 - written to stdin or grok will silently drop them (12s client-side - timeout instead of the expected error response). + - Requests use `.withoutEscapingSlashes` so `_x.ai/billing` is written literally on the wire. 3) **Grok CLI-proxy billing REST API** (primary web-path attempt) - When a non-expired `~/.grok/auth.json` token exists, GETs `https://cli-chat-proxy.grok.com/v1/billing?format=credits` with @@ -205,7 +202,8 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. } } ``` -- `x.ai/billing` result shape (all monetary values are `{ val: }`): +- `_x.ai/billing` accepts the current nested `config` credits shape described above. + The older result shape is also supported (all monetary values are `{ val: }`): ```json { "billingCycle": { @@ -225,14 +223,15 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. ``` - Auth errors surface as JSON-RPC errors with the message `"Authentication required to fetch billing data. Run 'grok login' to authenticate."`. -- Timeouts: 8s for `initialize`, 12s for `x.ai/billing`. CodexBar terminates the +- Timeouts: 4s for `initialize`, 3s for `_x.ai/billing`. CodexBar terminates the child `grok` process on timeout to avoid leaking subprocesses. ## Mapping to `UsageSnapshot` - **Primary window** = credit usage (against the subscription/included limit): - - CLI RPC: `usedPercent` = `usage.totalUsed.val / monthlyLimit.val * 100`; - `resetsAt` = `billingCycle.billingPeriodEnd`. + - CLI RPC: current nested credits use the same mapping as the CLI-proxy fallback below. + Older payloads use `usage.totalUsed.val / monthlyLimit.val * 100` and + `billingCycle.billingPeriodEnd` for the reset. - CLI-proxy fallback: `usedPercent` from the JSON percent or on-demand ratio; `resetsAt` from the current-period end or billing-period end. - grok.com fallback: `usedPercent` and `resetsAt` parsed from the gRPC-web