diff --git a/CHANGELOG.md b/CHANGELOG.md index b0c360c74e1..e735ce0eddf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.79.1 — Unreleased +### Fixed + +- Grok: restore the weekly Credits window on a zero-usage week when the ACP `_x.ai/billing` answer omits the usage percentage, by enriching wallet-only responses with the bounded, validated grok.com usage reading while keeping the prepaid balance and leaving usage unknown when the retry cannot validate a percent (#4674). + ## 0.79.0 — 2026-10-20 ### Highlights diff --git a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift index 9b6f5b4cd05..04d5595a5e9 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift @@ -126,6 +126,13 @@ public struct GrokStatusProbe: Sendable { var settingsTransport: any ProviderHTTPTransport = ProviderHTTPClient.shared var identityOnlyFallback: @Sendable (GrokCredentials?, Bool, Error?) -> Bool = GrokStatusProbe.shouldUseIdentityOnlyFallback + /// Best-effort grok.com usage lookup for ACP credits that carry an active period and a wallet + /// balance but omit the usage percentage (a zero-usage week). Injectable so probe tests stay + /// hermetic instead of reaching the network. + var enrichUnknownCredits: @Sendable (GrokWebBillingSnapshot, GrokCredentials?) async throws + -> GrokWebBillingSnapshot = { snapshot, credentials in + try await GrokStatusProbe.enrichingUnknownCredits(snapshot, credentials: credentials) + } public init() {} @@ -174,6 +181,9 @@ public struct GrokStatusProbe: Sendable { rpcError = error } + // PROOF VARIANT (#4674): the ACP unknown-usage enrichment wiring is intentionally reverted + // here so the regression in `GrokACPUnknownUsageTests` shows its failing-before-fix state. + // The seam and helpers below stay in place; only the call from `fetch()` is removed. 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. @@ -204,6 +214,23 @@ public struct GrokStatusProbe: Sendable { subscriptionTier: subscriptionTier) } + /// Enrichment is best-effort: cancellation propagates, but any other failure keeps the + /// already-valid wallet-only billing instead of discarding it. + func enrichingCredits( + _ snapshot: GrokWebBillingSnapshot, + credentials: GrokCredentials?) async throws -> GrokWebBillingSnapshot + { + do { + return try await self.enrichUnknownCredits(snapshot, credentials) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw error + } catch { + return snapshot + } + } + static func identityOnlySnapshot( credentials: GrokCredentials, localSummary: GrokLocalSessionSummary?, @@ -222,6 +249,35 @@ public struct GrokStatusProbe: Sendable { subscriptionTier: subscriptionTier) } + /// The ACP credits answer can describe an active billing period and a prepaid balance while + /// omitting `creditUsagePercent` (a zero-usage week). That is unknown usage, and an unknown + /// percent yields no rate window, so ask the grok.com bearer surface with the same auth-file + /// token (no browser cookies) before accepting the wallet-only answer. This reuses the CLI-proxy + /// retry's bounded budget and strict validation: only a wire-published percentage or a + /// parser-validated implicit zero from an active period can replace unknown usage, and the + /// ACP period, plan, and wallet metadata stay authoritative. + static func enrichingUnknownCredits( + _ snapshot: GrokWebBillingSnapshot, + credentials: GrokCredentials?, + budget: Duration = GrokOAuthFetchStrategy.unknownUsageEnrichmentBudget, + grpcBilling: @escaping GrokWebFetchStrategy.ProxyBillingFetch = { + try await GrokWebBillingFetcher.fetch(credentials: $0) + }) async throws -> GrokWebBillingSnapshot + { + guard snapshot.usedPercent == nil, + let credentials, + !credentials.isExpired + else { + return snapshot + } + return try await GrokOAuthFetchStrategy.resolvingUnknownUsage( + snapshot, + credentials: credentials, + budget: budget, + grpcBilling: grpcBilling) + .snapshot + } + static let settingsJoinGrace = Duration.seconds(2) static func loadSettingsTier( diff --git a/Tests/CodexBarTests/GrokACPUnknownUsageTests.swift b/Tests/CodexBarTests/GrokACPUnknownUsageTests.swift new file mode 100644 index 00000000000..af1fea667f1 --- /dev/null +++ b/Tests/CodexBarTests/GrokACPUnknownUsageTests.swift @@ -0,0 +1,363 @@ +import Foundation +import Testing +@testable import CodexBarCore + +/// Regression coverage for #4674: the ACP `_x.ai/billing` answer can describe an active weekly +/// period and a prepaid balance while omitting `creditUsagePercent` (a zero-usage week). That +/// wallet-only answer is valid and must keep its prepaid balance, but it must also regain the +/// weekly Credits window through the bounded, validated grok.com usage enrichment rather than +/// ending the pipeline with unknown usage. +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() } + let probe = Self.makeProbe { snapshot, credentials in + #expect(credentials?.accessToken == "synthetic-token") + return try await GrokStatusProbe.enrichingUnknownCredits( + snapshot, + credentials: credentials, + grpcBilling: { _ in + // grok.com reports its own reset; the ACP period stays authoritative. + GrokWebBillingSnapshot( + usedPercent: 0, + resetsAt: fixture.periodEnd.addingTimeInterval(-90), + windowMinutes: 10080, + usedPercentIsWirePublished: false, + usedPercentIsImplicitZero: true) + }) + } + + let snapshot = try await probe.fetch(env: fixture.environment) + #expect(snapshot.billing == nil) + #expect(snapshot.webBilling?.usedPercent == 0) + #expect(snapshot.webBilling?.usedPercentIsImplicitZero == true) + #expect(snapshot.webBilling?.windowMinutes == 10080) + #expect(snapshot.webBilling?.resetsAt == fixture.periodEnd) + #expect(snapshot.webBilling?.prepaidBalanceUSD == 1.23) + #expect(snapshot.diagnostic == nil) + + 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) + } + + @Test + func `published ACP percentage never triggers the grok.com retry`() async throws { + let fixture = try Self.makeFixture(credentials: .valid, publishedPercent: 5) + defer { fixture.cleanup() } + let probe = Self.makeProbe { _, _ in + Issue.record("Enrichment must not run when ACP already published a percentage") + throw GrokWebBillingError.parseFailed + } + + let usage = try await probe.fetch(env: fixture.environment).toUsageSnapshot() + #expect(usage.primary?.usedPercent == 5) + } + + @Test + func `enrichment without a validated percentage keeps unknown usage and the wallet`() async throws { + let fixture = try Self.makeFixture(credentials: .valid, prepaidCents: 123) + defer { fixture.cleanup() } + let probe = Self.makeProbe { snapshot, credentials in + try await GrokStatusProbe.enrichingUnknownCredits( + snapshot, + credentials: credentials, + grpcBilling: { _ in GrokWebBillingSnapshot(usedPercent: nil, resetsAt: nil) }) + } + + let snapshot = try await probe.fetch(env: fixture.environment) + let usage = snapshot.toUsageSnapshot() + #expect(snapshot.webBilling?.usedPercent == nil) + #expect(usage.primary == nil) + #expect(usage.providerCost?.balance == 1.23) + } + + @Test + func `enrichment failure cannot discard already-valid wallet-only billing`() async throws { + let fixture = try Self.makeFixture(credentials: .valid, prepaidCents: 123) + defer { fixture.cleanup() } + let probe = Self.makeProbe { _, _ in + throw GrokWebBillingError.requestFailed(503, "synthetic outage") + } + + let snapshot = try await probe.fetch(env: fixture.environment) + let usage = snapshot.toUsageSnapshot() + #expect(snapshot.webBilling?.prepaidBalanceUSD == 1.23) + #expect(usage.primary == nil) + #expect(usage.providerCost?.balance == 1.23) + } + + @Test(arguments: [CredentialsMode.expired, .missing]) + func `expired or missing credentials skip enrichment but keep the wallet`(_ mode: CredentialsMode) async throws { + let fixture = try Self.makeFixture(credentials: mode, prepaidCents: 123) + defer { fixture.cleanup() } + // The default seam must short-circuit before any grok.com request: this test would hang or + // fail hermeticity if it reached the network. + let probe = GrokStatusProbe() + + let snapshot = try await probe.fetch(env: fixture.environment) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.providerCost?.balance == 1.23) + } + + @Test + func `enrichment cancellation propagates instead of settling for unknown usage`() async throws { + let fixture = try Self.makeFixture(credentials: .valid, prepaidCents: 123) + defer { fixture.cleanup() } + let probe = Self.makeProbe { _, _ in + throw CancellationError() + } + + do { + _ = try await probe.fetch(env: fixture.environment) + Issue.record("Expected ACP usage enrichment cancellation to propagate") + } catch is CancellationError { + // Expected: the caller canceled instead of waiting for wallet-only data. + } + } + + @Test + func `published percentage and live credentials never enter the bounded retry`() async throws { + let snapshot = GrokWebBillingSnapshot( + usedPercent: 5, + resetsAt: Date(timeIntervalSince1970: 1_900_000_000), + windowMinutes: 10080, + subscriptionTier: "SuperGrok Heavy", + prepaidBalanceUSD: 1.23) + let enriched = try await GrokStatusProbe.enrichingUnknownCredits( + snapshot, + credentials: Self.validCredentials, + grpcBilling: { _ in + Issue.record("Published ACP usage must not schedule another request") + throw GrokWebBillingError.parseFailed + }) + #expect(enriched == snapshot) + } + + @Test + func `missing or expired credentials never enter the bounded retry`() async throws { + let snapshot = Self.unknownCreditsSnapshot() + let credentialSets: [GrokCredentials?] = [nil, Self.expiredCredentials] + for credentials in credentialSets { + let enriched = try await GrokStatusProbe.enrichingUnknownCredits( + snapshot, + credentials: credentials, + grpcBilling: { _ in + Issue.record("grok.com billing must not run without non-expired ACP credentials") + throw GrokWebBillingError.missingCredentials + }) + #expect(enriched == snapshot) + } + } + + @Test + func `unvalidated zero cannot replace unknown ACP usage`() async throws { + let snapshot = Self.unknownCreditsSnapshot() + let enriched = try await GrokStatusProbe.enrichingUnknownCredits( + snapshot, + credentials: Self.validCredentials, + grpcBilling: { _ in + GrokWebBillingSnapshot( + usedPercent: 0, + resetsAt: nil, + usedPercentIsWirePublished: false, + usedPercentIsImplicitZero: false) + }) + #expect(enriched == snapshot) + #expect(enriched.usedPercent == nil) + } + + @Test + func `bounded retry timeout keeps the ACP period, plan, and wallet`() async throws { + let snapshot = Self.unknownCreditsSnapshot() + let enriched = try await GrokStatusProbe.enrichingUnknownCredits( + snapshot, + credentials: Self.validCredentials, + budget: .milliseconds(20), + grpcBilling: { _ in + try await Task.sleep(for: .seconds(30)) + return GrokWebBillingSnapshot(usedPercent: 99, resetsAt: nil) + }) + #expect(enriched == snapshot) + } + + @Test + func `published grok.com percentage adopts the ACP period, plan, and wallet`() async throws { + let snapshot = Self.unknownCreditsSnapshot() + let enriched = try await GrokStatusProbe.enrichingUnknownCredits( + snapshot, + credentials: Self.validCredentials, + grpcBilling: { _ in + GrokWebBillingSnapshot( + usedPercent: 12, + resetsAt: Date(timeIntervalSince1970: 1_950_000_000), + windowMinutes: 10080, + usedPercentIsWirePublished: true) + }) + #expect(enriched.usedPercent == 12) + #expect(enriched.usedPercentIsWirePublished) + #expect(enriched.resetsAt == snapshot.resetsAt) + #expect(enriched.windowMinutes == snapshot.windowMinutes) + #expect(enriched.subscriptionTier == snapshot.subscriptionTier) + #expect(enriched.prepaidBalanceUSD == snapshot.prepaidBalanceUSD) + } + + // MARK: - Synthetic ACP fixture + + /// The `@Test(arguments:)` entry point is internal, so its parameter type must not be private. + 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"] + let requestLog = root.appendingPathComponent("billing-requests.jsonl") + + 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' "$billing_request" >> "\(requestLog.path)" + 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))) + } + + private static func makeProbe( + enrich: @escaping @Sendable (GrokWebBillingSnapshot, GrokCredentials?) async throws + -> GrokWebBillingSnapshot) -> GrokStatusProbe + { + 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)) + return (Data("{}".utf8), response) + } + probe.enrichUnknownCredits = enrich + return probe + } + + private static func unknownCreditsSnapshot() -> GrokWebBillingSnapshot { + GrokWebBillingSnapshot( + usedPercent: nil, + resetsAt: Date(timeIntervalSince1970: 1_900_000_000), + windowMinutes: 10080, + subscriptionTier: "SuperGrok Heavy", + prepaidBalanceUSD: 1.23) + } + + private static let validCredentials = GrokCredentials( + accessToken: "synthetic-token", + refreshToken: nil, + scope: "https://auth.x.ai::test", + authMode: "oidc", + userId: nil, + email: nil, + firstName: nil, + lastName: nil, + teamId: nil, + oidcIssuer: nil, + oidcClientId: nil, + expiresAt: .distantFuture, + createTime: nil) + + private static let expiredCredentials = GrokCredentials( + accessToken: "synthetic-token", + refreshToken: nil, + scope: "https://auth.x.ai::test", + authMode: "oidc", + userId: nil, + email: nil, + firstName: nil, + lastName: nil, + teamId: nil, + oidcIssuer: nil, + oidcClientId: nil, + expiresAt: Date(timeIntervalSince1970: 1_000_000), + createTime: nil) +} diff --git a/docs/grok.md b/docs/grok.md index 93872a5ebe9..fd94f2adb09 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 without `creditUsagePercent` is a zero-usage week, not + data to forget: the ACP snapshot alone has no percentage, 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