diff --git a/CHANGELOG.md b/CHANGELOG.md index a4d430a3f1d..3db2cb83a1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Fixed +- Grok: restore the weekly Credits window on zero-usage weeks when ACP omits the percentage, while preserving prepaid balances and leaving unvalidated usage unknown (#4674, #4679). Thanks @LeoLin990405 and @psl75011! - Linux CLI: prevent static musl builds from crashing when scanning non-empty Codex session files, with a regression check for musl and glibc builds (#4672, #4673). Thanks @harjothkhara and @psl75011! ## 0.79.0 — 2026-10-20 diff --git a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift index fb6e9521516..e3c689c7bd6 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift @@ -367,16 +367,11 @@ struct GrokOAuthFetchStrategy: ProviderFetchStrategy { try await GrokWebBillingFetcher.fetch(credentials: $0) }) async throws -> GrokWebBillingResult { - guard proxySnapshot.usedPercent == nil else { - return GrokWebBillingResult( - snapshot: proxySnapshot, - sourceLabel: "grok-cli-proxy", - authContext: .oauth(credentials)) - } let proxyAnswer = GrokWebBillingResult( snapshot: proxySnapshot, sourceLabel: "grok-cli-proxy", authContext: .oauth(credentials)) + guard proxySnapshot.usedPercent == nil else { return proxyAnswer } let join = BoundedTaskJoin(sourceTask: Task { try await grpcBilling(credentials) }) switch await join.value(joinGrace: budget) { case let .value(grpcSnapshot): diff --git a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift index 9b6f5b4cd05..712508315cd 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift @@ -174,6 +174,14 @@ public struct GrokStatusProbe: Sendable { rpcError = error } + // ACP wallet-only success needs the same bounded quota enrichment as the proxy. + if let credits = webBilling, let credentials, !credentials.isExpired { + webBilling = try await GrokOAuthFetchStrategy.resolvingUnknownUsage( + credits, credentials: credentials, grpcBilling: { + try await GrokWebBillingFetcher.fetch(credentials: $0, session: self.settingsTransport) + }).snapshot + } + 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. @@ -283,10 +291,7 @@ public struct GrokStatusProbe: Sendable { { // If remote usage succeeded, xAI accepted auth and the local // identity is still useful even when the persisted expires_at is stale. - if billing != nil || webBilling != nil { - return credentials - } - return credentials.flatMap { $0.isExpired ? nil : $0 } + credentials.flatMap { billing != nil || webBilling != nil || !$0.isExpired ? $0 : nil } } static func shouldSurfaceRemoteAuthError(_ error: Error?) -> Bool { diff --git a/Tests/CodexBarTests/GrokACPUnknownUsageTests.swift b/Tests/CodexBarTests/GrokACPUnknownUsageTests.swift new file mode 100644 index 00000000000..ad23e5ee370 --- /dev/null +++ b/Tests/CodexBarTests/GrokACPUnknownUsageTests.swift @@ -0,0 +1,179 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct GrokACPUnknownUsageTests { + @Test + func `active ACP period with omitted percentage adopts validated implicit zero`() async throws { + let fixture = try Self.makeFixture(credentials: .valid, prepaidCents: 123) + defer { fixture.cleanup() } + var probe = GrokStatusProbe() + probe.localSummary = { _ in nil } + probe.settingsTransport = ProviderHTTPTransportHandler { request in + let response = try #require(HTTPURLResponse( + url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)) + if request.url == GrokCLISettingsFetcher.defaultEndpoint { + return (Data("{}".utf8), response) + } + #expect(request.url == GrokWebBillingFetcher.defaultEndpoint) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer synthetic-token") + #expect(request.value(forHTTPHeaderField: "Cookie") == nil) + // The bearer response validates zero but must not replace the ACP reset or wallet. + return (Self.zeroUsagePayload(endingAt: fixture.periodEnd.addingTimeInterval(-90)), response) + } + let snapshot = try await probe.fetch(env: fixture.environment) + let usage = snapshot.toUsageSnapshot() + let primary = try #require(usage.primary) + #expect(primary.usedPercent == 0) + #expect(primary.windowMinutes == 10080) + #expect(primary.resetsAt == fixture.periodEnd) + #expect(usage.providerCost?.balance == 1.23) + #expect(snapshot.webBilling?.usedPercentIsImplicitZero == true) + } + + @Test + func `published ACP usage skips enrichment`() async throws { + let fixture = try Self.makeFixture(credentials: .valid, publishedPercent: 5) + defer { fixture.cleanup() } + var probe = GrokStatusProbe() + probe.localSummary = { _ in nil } + probe.settingsTransport = ProviderHTTPTransportHandler { request in + #expect(request.url == GrokCLISettingsFetcher.defaultEndpoint) + return try (Data("{}".utf8), #require(HTTPURLResponse( + url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil))) + } + #expect(try await probe.fetch(env: fixture.environment).toUsageSnapshot().primary?.usedPercent == 5) + } + + @Test(arguments: [CredentialsMode.valid, .expired, .missing]) + func `unavailable enrichment preserves prepaid billing`(_ mode: CredentialsMode) async throws { + let fixture = try Self.makeFixture(credentials: mode, prepaidCents: 123) + defer { fixture.cleanup() } + var probe = GrokStatusProbe() + probe.localSummary = { _ in nil } + probe.settingsTransport = ProviderHTTPTransportHandler { request in + if request.url == GrokWebBillingFetcher.defaultEndpoint { + #expect(mode == .valid) + } + throw URLError(.timedOut) + } + let usage = try await probe.fetch(env: fixture.environment).toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.providerCost?.balance == 1.23) + } + + private static func zeroUsagePayload(endingAt end: Date) -> Data { + func varint(_ number: UInt64) -> Data { + var number = number + var bytes = Data() + while number >= 0x80 { + bytes.append(UInt8(number & 0x7F) | 0x80) + number >>= 7 + } + bytes.append(UInt8(number)) + return bytes + } + func message(_ field: UInt8, _ data: Data) -> Data { + Data([field << 3 | 2, UInt8(data.count)]) + data + } + let start = Data([0x08]) + varint(UInt64(end.timeIntervalSince1970) - 7 * 86400) + let finish = Data([0x08]) + varint(UInt64(end.timeIntervalSince1970)) + let period = Data([0x08, 0x02]) + message(2, start) + message(3, finish) + return message(1, message(8, period)) + } + + enum CredentialsMode: Sendable { + case valid, expired, missing + } + + private struct ACPFixture: Sendable { + let root: URL + let environment: [String: String] + let periodEnd: Date + + func cleanup() { + try? FileManager.default.removeItem(at: self.root) + } + } + + private static func makeFixture( + credentials: CredentialsMode, + prepaidCents: Int? = nil, + publishedPercent: Double? = nil) throws -> ACPFixture + { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-acp-unknown-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + let now = Date() + let periodStart = Date(timeIntervalSince1970: now.timeIntervalSince1970.rounded(.down) - 3 * 86400) + let periodEnd = Date(timeIntervalSince1970: now.timeIntervalSince1970.rounded(.down) + 4 * 86400) + let startString = formatter.string(from: periodStart) + let endString = formatter.string(from: periodEnd) + + if credentials != .missing { + let expiresAt = credentials == .expired ? "2000-01-01T00:00:00Z" : "2099-01-01T00:00:00Z" + let auth: [String: Any] = [ + "https://auth.x.ai::fake-client": [ + "key": "synthetic-token", + "refresh_token": "synthetic-refresh", + "email": "grok@example.com", + "auth_mode": "oidc", + "principal_type": "User", + "expires_at": expiresAt, + ], + ] + try JSONSerialization.data(withJSONObject: auth) + .write(to: root.appendingPathComponent("auth.json")) + } + + var config: [String: Any] = [ + "currentPeriod": [ + "type": "USAGE_PERIOD_TYPE_WEEKLY", + "start": startString, + "end": endString, + ], + ] + if let prepaidCents { + config["prepaidBalance"] = ["val": prepaidCents] + } + if let publishedPercent { + config["creditUsagePercent"] = publishedPercent + } + let billing: [String: Any] = ["config": config, "subscription_tier": "SuperGrok Heavy"] + + func reply(id: Int, result: [String: Any]) throws -> String { + let data = try JSONSerialization.data( + withJSONObject: ["jsonrpc": "2.0", "id": id, "result": result]) + return try #require(String(bytes: data, encoding: .utf8)) + } + let initialize = try reply(id: 1, result: [:]) + let billingReply = try reply(id: 2, result: billing) + let binary = root.appendingPathComponent("grok-fixture") + 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' '\(initialize)' + IFS= read -r billing_request || exit 0 + printf '%s\\n' '\(billingReply)' + """ + try script.write(to: binary, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path) + + return try ACPFixture( + root: root, + environment: [ + "HOME": root.path, + "GROK_HOME": root.path, + "GROK_CLI_PATH": binary.path, + "PATH": "/usr/bin:/bin", + ], + periodEnd: #require(ISO8601DateParser.parse(endString))) + } +} diff --git a/docs/grok.md b/docs/grok.md index 93872a5ebe9..5987b49c5fa 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -38,6 +38,13 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. 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. + - An active period plus prepaid balance can omit `creditUsagePercent` at the start of a week. + The ACP snapshot alone has unknown usage, so the fetch applies the same bounded + grok.com bearer retry described for the CLI-proxy credits path (step 3) before accepting the + wallet-only answer. Only a wire-published percentage or a parser-validated implicit zero for an + active period can restore the weekly Credits window; the ACP period, plan, and prepaid balance + stay authoritative, and failed, slow, or absent enrichment leaves usage unknown without + dropping the wallet. Missing or expired `auth.json` tokens skip the retry entirely. - 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