From 0e372dc1a709c66ac7e4ef26864fd6f29b018849 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 25 Sep 2026 05:23:01 -0700 Subject: [PATCH] feat(grok): show product usage breakdown Preserve same-response product shares from the credits proxy and render plain rows through the shared provider details. Keep reset-credit enrichment from replacing product details and omit malformed breakdowns without changing the main quota. Adopt #3975 with parser, routing, card-model, and synthetic render proof. Co-authored-by: olddonkey --- CHANGELOG.md | 1 + .../Grok/GrokCreditsProxyFetcher.swift | 46 +- .../Grok/GrokProviderDescriptor.swift | 30 +- .../Providers/Grok/GrokStatusProbe.swift | 31 ++ .../Grok/GrokWebBillingFetcher.swift | 25 +- .../GrokCreditsProxyFetcherTests.swift | 411 ++++++++++++++++++ .../GrokMenuCardModelTests.swift | 30 ++ .../GrokPaceScreenshotRenderTests.swift | 42 ++ .../CodexBarTests/GrokProductUsageTests.swift | 29 ++ .../GrokRemainingResetsRoutingTests.swift | 33 +- docs/grok.md | 17 + 11 files changed, 676 insertions(+), 19 deletions(-) create mode 100644 Tests/CodexBarTests/GrokProductUsageTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index cbc8e8c00d..5f41753135 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Grok: show product usage shares beneath the quota bar using the existing billing response, while preserving the total and reset credits (#3975). Thanks @olddonkey! - Agent sessions: add opt-in Stay Awake for live local agent processes, including idle sessions, with automatic release and a menu status indicator (#2740). Thanks @kocaemre! - Notifications: add opt-in, account-scoped credential-expiry alerts and route Augment keepalive through shared delivery without repeated refresh notifications (#2512). Thanks @LeoLin990405! - LiteLLM: optionally show per-model input/output/total tokens and logged requests for the last 30 days while preserving personal/team budgets (#3432). Thanks @anyingiit! diff --git a/Sources/CodexBarCore/Providers/Grok/GrokCreditsProxyFetcher.swift b/Sources/CodexBarCore/Providers/Grok/GrokCreditsProxyFetcher.swift index c1e173a874..264ddf25d3 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokCreditsProxyFetcher.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokCreditsProxyFetcher.swift @@ -10,6 +10,7 @@ public enum GrokCreditsProxyFetcher { public static let defaultEndpoint = URL( string: "https://cli-chat-proxy.grok.com/v1/billing?format=credits")! private static let requestTimeoutSeconds: TimeInterval = 15 + private static let productCompositionTolerancePercent = 1.0 public static func fetch( credentials: GrokCredentials, @@ -67,7 +68,8 @@ public enum GrokCreditsProxyFetcher { usedPercent: min(100, max(0, percent)), resetsAt: resetsAt, windowMinutes: windowMinutes, - subscriptionTier: subscriptionTier) + subscriptionTier: subscriptionTier, + productUsage: Self.composingProducts(config.productUsage?.values ?? [], creditUsagePercent: percent)) } if let cap = config.onDemandCap?.val, @@ -93,6 +95,16 @@ public enum GrokCreditsProxyFetcher { throw GrokWebBillingError.parseFailed } + private static func composingProducts( + _ products: [GrokProductUsage], + creditUsagePercent: Double) -> [GrokProductUsage] + { + // Shares must compose this payload's credit percentage; any malformed entry drops the breakdown. + guard !products.isEmpty else { return [] } + let sum = products.reduce(0) { $0 + $1.usedPercent } + return abs(sum - creditUsagePercent) <= Self.productCompositionTolerancePercent ? products : [] + } + private static func windowMinutes(start: String?, end: Date?, now: Date) -> Int? { guard let start = ISO8601DateParser.parse(start), let end, end > start, start <= now, @@ -115,6 +127,38 @@ public enum GrokCreditsProxyFetcher { let onDemandCap: CreditsAmount? let onDemandUsed: CreditsAmount? let subscriptionTier: String? + let productUsage: LossyProductUsageArray? + } + + private struct LossyProductUsageArray: Decodable { + let values: [GrokProductUsage]? + + init(from decoder: Decoder) { + self.values = (try? decoder.singleValueContainer().decode([LossyProductUsage].self))? + .map(\.value) + } + } + + private struct LossyProductUsage: Decodable { + let value: GrokProductUsage + + private enum CodingKeys: String, CodingKey { + case product + case usagePercent + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let product = try container.decode(String.self, forKey: .product) + .trimmingCharacters(in: .whitespacesAndNewlines) + let percent = try container.decode(Double.self, forKey: .usagePercent) + guard !product.isEmpty, percent.isFinite, percent >= 0 else { + throw DecodingError.dataCorrupted(.init( + codingPath: decoder.codingPath, + debugDescription: "Invalid product usage")) + } + self.value = GrokProductUsage(product: product, usedPercent: percent) + } } private struct CurrentPeriod: Decodable { diff --git a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift index 1baaac936a..5f59c924e1 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift @@ -23,6 +23,16 @@ public enum GrokProviderDescriptor { manualCookieHeader: nil).sourceMode ?? base }) + fileprivate static func withResetCreditDetails( + usage: UsageSnapshot, + resetCredits: GrokRateLimitResetCreditsSnapshot?, + now: Date) -> UsageSnapshot + { + let enriched = usage.withGrokResetCredits(resetCredits) + let resetSections = GrokRemainingResetsFetcher.detailSections(snapshot: resetCredits, now: now) + return enriched.replacing(details: .value(resetSections + enriched.details)) + } + static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( id: .grok, @@ -201,12 +211,10 @@ struct GrokCLIFetchStrategy: ProviderFetchStrategy { at: snapshot.updatedAt, requiresCompleteness: context.requiresOptionalUsageCompleteness) return self.makeResult( - usage: usage - .withGrokResetCredits(resetResolution.snapshot) - .replacing(details: .value( - GrokRemainingResetsFetcher.detailSections( - snapshot: resetResolution.snapshot, - now: snapshot.updatedAt))), + usage: GrokProviderDescriptor.withResetCreditDetails( + usage: usage, + resetCredits: resetResolution.snapshot, + now: snapshot.updatedAt), sourceLabel: "grok-cli", supplementalUsageTask: resetResolution.supplementalUsageTask, diagnostic: snapshot.diagnostic) @@ -530,12 +538,10 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { at: snapshot.updatedAt, requiresCompleteness: context.requiresOptionalUsageCompleteness) return self.makeResult( - usage: usage - .withGrokResetCredits(resetResolution.snapshot) - .replacing(details: .value( - GrokRemainingResetsFetcher.detailSections( - snapshot: resetResolution.snapshot, - now: snapshot.updatedAt))), + usage: GrokProviderDescriptor.withResetCreditDetails( + usage: usage, + resetCredits: resetResolution.snapshot, + now: snapshot.updatedAt), sourceLabel: billingResult.sourceLabel, supplementalUsageTask: resetResolution.supplementalUsageTask, diagnostic: enrichedBilling.usedPercent == nil ? GrokStatusProbe.usageUnavailableMessage : nil) diff --git a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift index 46f90c9c4b..7d03741e83 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift @@ -1,5 +1,33 @@ import Foundation +enum GrokProductUsageDetails { + static func sections(for products: [GrokProductUsage]) -> [ProviderDetailSection] { + let rows = products.enumerated() + .filter { $0.element.usedPercent.isFinite && $0.element.usedPercent > 0 } + .sorted { + $0.element.usedPercent == $1.element.usedPercent + ? $0.offset < $1.offset + : $0.element.usedPercent > $1.element.usedPercent + } + .map { _, usage in + let product = usage.product.trimmingCharacters(in: .whitespacesAndNewlines) + let label = switch product { + case "GrokBuild": "Grok Build" + case "GrokChat": "Grok Chat" + case "GrokImagine": "Grok Imagine" + case "GrokAppBuilder": "Grok App Builder" + default: product + } + return ProviderDetailSection.makeRow( + id: "grok.product.\(product)", + label: label, + value: UsageFormatter.percentString(usage.usedPercent)) + } + guard !rows.isEmpty else { return [] } + return [.makeSection(title: "Usage breakdown", rows: rows)] + } +} + public struct GrokUsageSnapshot: Sendable { public let billing: GrokBillingResponse? public let webBilling: GrokWebBillingSnapshot? @@ -34,6 +62,7 @@ public struct GrokUsageSnapshot: Sendable { // Primary window: credit usage (against included limit) from the CLI RPC, // falling back to the web billing RPC used by grok.com when the agent surface lacks billing. var primary: RateWindow? + var details: [ProviderDetailSection] = [] if let billing, let percent = billing.monthlyUsedPercent { @@ -52,6 +81,7 @@ public struct GrokUsageSnapshot: Sendable { windowMinutes: webBilling.windowMinutes, resetsAt: webBilling.resetsAt, resetDescription: nil) + details = GrokProductUsageDetails.sections(for: webBilling.productUsage) } let identity = ProviderIdentitySnapshot( @@ -68,6 +98,7 @@ public struct GrokUsageSnapshot: Sendable { tertiary: nil, costUsage: self.localSummary?.toCostUsageTokenSnapshot( historyDays: GrokLocalSessionScanner.defaultLookbackDays), + details: details, updatedAt: self.updatedAt, identity: identity) } diff --git a/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift b/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift index 22cc28a12a..b88992ab3b 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift @@ -4,6 +4,16 @@ import Foundation import FoundationNetworking #endif +public struct GrokProductUsage: Sendable, Equatable { + public let product: String + public let usedPercent: Double + + public init(product: String, usedPercent: Double) { + self.product = product + self.usedPercent = usedPercent + } +} + public struct GrokWebBillingSnapshot: Sendable, Equatable { public let usedPercent: Double? public let resetsAt: Date? @@ -17,6 +27,8 @@ public struct GrokWebBillingSnapshot: Sendable, Equatable { public let usedPercentIsWirePublished: Bool /// The parser validated an active current period with an omitted proto3 usage scalar. public let usedPercentIsImplicitZero: Bool + /// Shares compose this snapshot's credit `usedPercent` from the same payload before clamping; empty if unverified. + public let productUsage: [GrokProductUsage] public init( usedPercent: Double?, @@ -24,7 +36,8 @@ public struct GrokWebBillingSnapshot: Sendable, Equatable { windowMinutes: Int? = nil, subscriptionTier: String? = nil, usedPercentIsWirePublished: Bool = true, - usedPercentIsImplicitZero: Bool = false) + usedPercentIsImplicitZero: Bool = false, + productUsage: [GrokProductUsage] = []) { self.usedPercent = usedPercent self.resetsAt = resetsAt @@ -32,6 +45,7 @@ public struct GrokWebBillingSnapshot: Sendable, Equatable { self.subscriptionTier = subscriptionTier self.usedPercentIsWirePublished = usedPercentIsWirePublished self.usedPercentIsImplicitZero = usedPercentIsImplicitZero + self.productUsage = productUsage } /// Overlay the CLI settings plan name. Usage percent stays on the existing credits rules. @@ -42,12 +56,14 @@ public struct GrokWebBillingSnapshot: Sendable, Equatable { windowMinutes: self.windowMinutes, subscriptionTier: GrokPlan.displayName(from: raw) ?? self.subscriptionTier, usedPercentIsWirePublished: self.usedPercentIsWirePublished, - usedPercentIsImplicitZero: self.usedPercentIsImplicitZero) + usedPercentIsImplicitZero: self.usedPercentIsImplicitZero, + productUsage: self.productUsage) } /// Keep period and plan metadata a second billing surface did not publish. Usage percent /// always stays with the surface that produced this snapshot, so an unknown percent is - /// never backfilled from another response. + /// never backfilled from another response. Product shares compose that same payload's + /// `usedPercent` and are never borrowed from another billing surface. func completing(with other: GrokWebBillingSnapshot) -> GrokWebBillingSnapshot { GrokWebBillingSnapshot( usedPercent: self.usedPercent, @@ -55,7 +71,8 @@ public struct GrokWebBillingSnapshot: Sendable, Equatable { windowMinutes: other.resetsAt == nil ? self.windowMinutes : other.windowMinutes, subscriptionTier: self.subscriptionTier ?? other.subscriptionTier, usedPercentIsWirePublished: self.usedPercentIsWirePublished, - usedPercentIsImplicitZero: self.usedPercentIsImplicitZero) + usedPercentIsImplicitZero: self.usedPercentIsImplicitZero, + productUsage: self.productUsage) } } diff --git a/Tests/CodexBarTests/GrokCreditsProxyFetcherTests.swift b/Tests/CodexBarTests/GrokCreditsProxyFetcherTests.swift index eae5f4e375..7cdb58f42a 100644 --- a/Tests/CodexBarTests/GrokCreditsProxyFetcherTests.swift +++ b/Tests/CodexBarTests/GrokCreditsProxyFetcherTests.swift @@ -119,6 +119,45 @@ struct GrokCreditsProxyFetcherTests { #expect(enriched.subscriptionTier == "SuperGrok Heavy") } + @Test + func `unknown proxy usage does not attach its products to grok dot com totals`() async throws { + let now = try Self.date("2026-08-12T00:00:00Z") + let proxy = try GrokCreditsProxyFetcher.parseSnapshot(Data(""" + {"config":{"currentPeriod":{"start":"2026-08-06T00:00:00Z","end":"2026-08-13T00:00:00Z"}, + "productUsage":[{"product":"GrokBuild","usagePercent":3}]}} + """.utf8), now: now) + #expect(proxy.usedPercent == nil) + #expect(proxy.windowMinutes == 10080) + #expect(proxy.productUsage.isEmpty) + + let grpcSnapshots = [ + GrokWebBillingSnapshot(usedPercent: 12, resetsAt: nil), + GrokWebBillingSnapshot( + usedPercent: 0, + resetsAt: nil, + usedPercentIsWirePublished: false, + usedPercentIsImplicitZero: true), + ] + for grpcSnapshot in grpcSnapshots { + let result = try await GrokOAuthFetchStrategy.resolvingUnknownUsage( + proxy, + credentials: Self.credentials, + grpcBilling: { _ in grpcSnapshot }) + let usage = GrokUsageSnapshot( + billing: nil, + webBilling: result.snapshot, + credentials: nil, + localSummary: nil, + cliVersion: nil, + updatedAt: now).toUsageSnapshot() + + #expect(result.snapshot.usedPercent == grpcSnapshot.usedPercent) + #expect(result.snapshot.productUsage.isEmpty) + #expect(usage.primary?.usedPercent == grpcSnapshot.usedPercent) + #expect(!usage.details.contains { $0.title == "Usage breakdown" }) + } + } + @Test func `completion never pairs a duration with a different reset`() { let original = GrokWebBillingSnapshot( @@ -759,6 +798,378 @@ struct GrokCreditsProxyFetcherTests { } } +extension GrokCreditsProxyFetcherTests { + @Test + func `live weekly credits payload retains product composition`() throws { + let now = try Self.date("2026-09-23T00:00:00Z") + let payload = [ + #"{"config":{"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","#, + #""start":"2026-09-20T18:42:45.537749+00:00","#, + #""end":"2026-09-27T18:42:45.537749+00:00"},"creditUsagePercent":1.0,"onDemandCap":{"val":0},"#, + #""onDemandUsed":{"val":0},"productUsage":[{"product":"GrokBuild","usagePercent":1.0}],"#, + #""isUnifiedBillingUser":true,"#, + #""prepaidBalance":{"val":0},"topUpMethod":"TOP_UP_METHOD_SAVED_PAYMENT_METHOD","#, + #""billingPeriodStart":"2026-09-20T18:42:45.537749+00:00","#, + #""billingPeriodEnd":"2026-09-27T18:42:45.537749+00:00"}}"#, + ].joined() + let snapshot = try GrokCreditsProxyFetcher.parseSnapshot(Data(payload.utf8), now: now) + + #expect(snapshot.usedPercent == 1) + #expect(try snapshot.resetsAt == (Self.date("2026-09-27T18:42:45.537749+00:00"))) + #expect(snapshot.windowMinutes == 10080) + #expect(snapshot.productUsage == [GrokProductUsage(product: "GrokBuild", usedPercent: 1)]) + } + + @Test(arguments: LiveMultiProductCreditsPayload.all) + func `live multi-product credits payloads compose the weekly total`( + fixture: LiveMultiProductCreditsPayload) throws + { + let now = try Self.date("2026-09-25T01:00:00Z") + let snapshot = try GrokCreditsProxyFetcher.parseSnapshot(Data(fixture.payload.utf8), now: now) + + #expect(snapshot.usedPercent == fixture.usedPercent) + #expect(snapshot.windowMinutes == 10080) + #expect(try snapshot.resetsAt == Self.date("2026-09-27T18:42:45.537749+00:00")) + #expect(snapshot.productUsage == fixture.products) + #expect(snapshot.productUsage.reduce(0) { $0 + $1.usedPercent } == snapshot.usedPercent) + } + + @Test + func `live multi-product payload renders one bar and a sorted breakdown`() throws { + let now = try Self.date("2026-09-25T01:00:00Z") + let parsed = try GrokCreditsProxyFetcher.parseSnapshot( + Data(LiveMultiProductCreditsPayload.p6.payload.utf8), now: now) + let usage = GrokUsageSnapshot( + billing: nil, + webBilling: parsed, + credentials: nil, + localSummary: nil, + cliVersion: nil, + updatedAt: now).toUsageSnapshot() + let section = try #require(usage.details.first) + + #expect(usage.primary?.usedPercent == 6) + #expect(usage.secondary == nil) + #expect(usage.tertiary == nil) + #expect(usage.extraRateWindows?.isEmpty != false) + #expect(usage.details.count == 1) + #expect(section.title == "Usage breakdown") + #expect(section.rows.map(\.label) == ["Grok Chat", "Grok Build"]) + #expect(section.rows.map(\.value) == ["4%", "2%"]) + #expect(section.rows.map(\.id) == ["grok.product.GrokChat", "grok.product.GrokBuild"]) + #expect(section.rows.allSatisfy { $0.progress == nil }) + } + + @Test + func `proxy retains product wire order and unknown names`() throws { + let snapshot = try GrokCreditsProxyFetcher.parseSnapshot(Data(""" + {"config":{"creditUsagePercent":42,"productUsage":[ + {"product":"GrokImagine","usagePercent":2.5}, + {"product":" FutureGrok ","usagePercent":0}, + {"product":"GrokChat","usagePercent":39.5} + ]}} + """.utf8)) + + #expect(snapshot.productUsage == [ + GrokProductUsage(product: "GrokImagine", usedPercent: 2.5), + GrokProductUsage(product: "FutureGrok", usedPercent: 0), + GrokProductUsage(product: "GrokChat", usedPercent: 39.5), + ]) + } + + @Test + func `malformed products do not change the weekly total or period`() throws { + let now = try Self.date("2026-09-23T00:00:00Z") + let base = #""" + {"config":{"creditUsagePercent":42,"currentPeriod":{"start":"2026-09-20T00:00:00Z","end":"2026-09-27T00:00:00Z"} + """# + let baseline = try GrokCreditsProxyFetcher.parseSnapshot(Data("\(base)}}".utf8), now: now) + let cases: [(String, [GrokProductUsage])] = [ + (#", "productUsage":null"#, []), + (#", "productUsage":{}"#, []), + (#", "productUsage":"wrong""#, []), + (#", "productUsage":[{"product":"GrokBuild","usagePercent":"1"}]"#, []), + (#", "productUsage":[{"usagePercent":1}]"#, []), + (#", "productUsage":[{"product":42,"usagePercent":1}]"#, []), + (#", "productUsage":[{"product":"GrokBuild"}]"#, []), + (#", "productUsage":[{"product":"GrokBuild","usagePercent":-1}]"#, []), + (#", "productUsage":[{"product":" ","usagePercent":1}]"#, []), + (#", "productUsage":[{"product":"GrokImagine","usagePercent":1e400}]"#, []), + (#", "productUsage":[{"product":"GrokChat","usagePercent":42},42]"#, []), + ] + for (fragment, expectedProducts) in cases { + let snapshot = try GrokCreditsProxyFetcher.parseSnapshot(Data("\(base)\(fragment)}}".utf8), now: now) + #expect(snapshot.productUsage == expectedProducts) + #expect(snapshot.usedPercent == baseline.usedPercent) + #expect(snapshot.resetsAt == baseline.resetsAt) + #expect(snapshot.windowMinutes == baseline.windowMinutes) + #expect(snapshot.subscriptionTier == baseline.subscriptionTier) + #expect(snapshot.usedPercentIsWirePublished == baseline.usedPercentIsWirePublished) + #expect(snapshot.usedPercentIsImplicitZero == baseline.usedPercentIsImplicitZero) + } + #expect(baseline.productUsage.isEmpty) + } + + @Test + func `a malformed product entry drops the whole breakdown near the tolerance`() throws { + let now = try Self.date("2026-09-23T00:00:00Z") + let base = #""" + {"config":{"creditUsagePercent":6,"currentPeriod":{"start":"2026-09-20T00:00:00Z", + "end":"2026-09-27T00:00:00Z"},"subscriptionTier":"SUPERGROK_HEAVY" + """# + let baseline = try GrokCreditsProxyFetcher.parseSnapshot(Data("\(base)}}".utf8), now: now) + let malformed = try GrokCreditsProxyFetcher.parseSnapshot(Data(""" + \(base),"productUsage":[{"product":"GrokChat","usagePercent":5}, + {"product":"GrokBuild","usagePercent":"1"}]}} + """.utf8), now: now) + + #expect(malformed.productUsage.isEmpty) + #expect(malformed.usedPercent == 6) + #expect(malformed.resetsAt == baseline.resetsAt) + #expect(malformed.windowMinutes == baseline.windowMinutes) + #expect(malformed.subscriptionTier == baseline.subscriptionTier) + #expect(malformed.usedPercentIsWirePublished == baseline.usedPercentIsWirePublished) + #expect(malformed.usedPercentIsImplicitZero == baseline.usedPercentIsImplicitZero) + + let exactRemainder = try GrokCreditsProxyFetcher.parseSnapshot(Data(""" + {"config":{"creditUsagePercent":5,"productUsage":[ + {"product":"GrokChat","usagePercent":5},{"usagePercent":1}]}} + """.utf8), now: now) + #expect(exactRemainder.usedPercent == 5) + #expect(exactRemainder.productUsage.isEmpty) + + let usage = GrokUsageSnapshot( + billing: nil, + webBilling: malformed, + credentials: nil, + localSummary: nil, + cliVersion: nil, + updatedAt: now).toUsageSnapshot() + #expect(usage.details.isEmpty) + } + + @Test + func `products attach only to the published credit percentage`() throws { + let cases: [(String, Double?)] = [ + (#""onDemandCap":{"val":100},"onDemandUsed":{"val":3}"#, 3), + (#""billingPeriodEnd":"2026-09-27T00:00:00Z""#, nil), + ] + for (fields, percent) in cases { + let payload = "{\"config\":{\(fields),\"productUsage\":[{\"product\":\"GrokBuild\",\"usagePercent\":3}]}}" + let snapshot = try GrokCreditsProxyFetcher.parseSnapshot(Data(payload.utf8)) + #expect(snapshot.usedPercent == percent) + #expect(snapshot.productUsage.isEmpty) + } + } + + @Test + func `product shares that do not compose the credit percentage are dropped`() throws { + let now = try Self.date("2026-09-23T00:00:00Z") + let base = #""" + {"config":{"creditUsagePercent":30,"currentPeriod":{"start":"2026-09-20T00:00:00Z","end":"2026-09-27T00:00:00Z"} + """# + let baseline = try GrokCreditsProxyFetcher.parseSnapshot(Data("\(base)}}".utf8), now: now) + let products = [ + #", "productUsage":[{"product":"GrokBuild","usagePercent":60}]"#, + #", "productUsage":[{"product":"GrokBuild","usagePercent":20},{"product":"GrokChat","usagePercent":5}]"#, + ] + for fragment in products { + let snapshot = try GrokCreditsProxyFetcher.parseSnapshot(Data("\(base)\(fragment)}}".utf8), now: now) + #expect(snapshot.productUsage.isEmpty) + #expect(snapshot.usedPercent == baseline.usedPercent) + #expect(snapshot.resetsAt == baseline.resetsAt) + #expect(snapshot.windowMinutes == baseline.windowMinutes) + #expect(snapshot.subscriptionTier == baseline.subscriptionTier) + #expect(snapshot.usedPercentIsWirePublished == baseline.usedPercentIsWirePublished) + #expect(snapshot.usedPercentIsImplicitZero == baseline.usedPercentIsImplicitZero) + } + } + + @Test + func `product shares within rounding of the credit percentage are kept`() throws { + let rounded = try GrokCreditsProxyFetcher.parseSnapshot(Data(""" + {"config":{"creditUsagePercent":10,"productUsage":[ + {"product":"GrokBuild","usagePercent":6.0}, + {"product":"GrokChat","usagePercent":3.6} + ]}} + """.utf8)) + #expect(rounded.usedPercent == 10) + #expect(rounded.productUsage == [ + GrokProductUsage(product: "GrokBuild", usedPercent: 6), + GrokProductUsage(product: "GrokChat", usedPercent: 3.6), + ]) + + let full = try GrokCreditsProxyFetcher.parseSnapshot(Data(""" + {"config":{"creditUsagePercent":100,"productUsage":[ + {"product":"GrokBuild","usagePercent":46}, + {"product":"GrokAppBuilder","usagePercent":45}, + {"product":"GrokChat","usagePercent":7}, + {"product":"GrokAutomations","usagePercent":2} + ]}} + """.utf8)) + #expect(full.usedPercent == 100) + #expect(full.productUsage == [ + GrokProductUsage(product: "GrokBuild", usedPercent: 46), + GrokProductUsage(product: "GrokAppBuilder", usedPercent: 45), + GrokProductUsage(product: "GrokChat", usedPercent: 7), + GrokProductUsage(product: "GrokAutomations", usedPercent: 2), + ]) + } + + @Test + func `product shares use the raw overage credit percentage`() throws { + let snapshot = try GrokCreditsProxyFetcher.parseSnapshot(Data(""" + {"config":{"creditUsagePercent":120,"productUsage":[{"product":"GrokBuild","usagePercent":120}]}} + """.utf8)) + #expect(snapshot.usedPercent == 100) + #expect(snapshot.productUsage == [GrokProductUsage(product: "GrokBuild", usedPercent: 120)]) + } + + @Test + func `billing snapshot copies keep product composition`() { + let products = [GrokProductUsage(product: "GrokBuild", usedPercent: 1)] + let proxyWithProducts = GrokWebBillingSnapshot(usedPercent: 1, resetsAt: nil, productUsage: products) + let grpcLike = GrokWebBillingSnapshot(usedPercent: 2, resetsAt: nil) + let otherProducts = GrokWebBillingSnapshot( + usedPercent: 3, + resetsAt: nil, + productUsage: [GrokProductUsage(product: "GrokChat", usedPercent: 3)]) + #expect(proxyWithProducts.applying(subscriptionTier: "SuperGrok").productUsage == products) + #expect(grpcLike.completing(with: proxyWithProducts).productUsage.isEmpty) + #expect(proxyWithProducts.completing(with: grpcLike).productUsage == products) + #expect(proxyWithProducts.completing(with: otherProducts).productUsage == products) + } + + @Test + func `product details share the primary weekly pool without extra bars`() throws { + let products = [ + GrokProductUsage(product: "GrokBuild", usedPercent: 1), + GrokProductUsage(product: "GrokChat", usedPercent: 5), + GrokProductUsage(product: "GrokImagine", usedPercent: 0), + GrokProductUsage(product: " FutureGrok ", usedPercent: 0.2), + GrokProductUsage(product: "GrokAppBuilder", usedPercent: 5), + ] + let now = Date(timeIntervalSince1970: 1_800_000_000) + let usage = GrokUsageSnapshot( + billing: nil, + webBilling: GrokWebBillingSnapshot(usedPercent: 11.2, resetsAt: nil, productUsage: products), + credentials: nil, + localSummary: nil, + cliVersion: nil, + updatedAt: now).toUsageSnapshot() + let section = try #require(usage.details.first) + + #expect(usage.primary?.usedPercent == 11.2) + #expect(usage.details.count == 1) + #expect(section.title == "Usage breakdown") + #expect(section.rows.map(\.id) == [ + "grok.product.GrokChat", "grok.product.GrokAppBuilder", "grok.product.GrokBuild", + "grok.product.FutureGrok", + ]) + #expect(section.rows.map(\.label) == ["Grok Chat", "Grok App Builder", "Grok Build", "FutureGrok"]) + #expect(section.rows.map(\.value) == ["5%", "5%", "1%", "<1%"]) + #expect(section.rows.allSatisfy { $0.progress == nil && $0.secondaryValue == nil }) + #expect(usage.secondary == nil) + #expect(usage.tertiary == nil) + #expect(usage.extraRateWindows?.isEmpty != false) + #expect(GrokProductUsageDetails.sections(for: []).isEmpty) + + let empty = GrokUsageSnapshot( + billing: nil, + webBilling: GrokWebBillingSnapshot(usedPercent: 11.2, resetsAt: nil), + credentials: nil, + localSummary: nil, + cliVersion: nil, + updatedAt: now).toUsageSnapshot() + let unknown = GrokUsageSnapshot( + billing: nil, + webBilling: GrokWebBillingSnapshot(usedPercent: nil, resetsAt: nil, productUsage: products), + credentials: nil, + localSummary: nil, + cliVersion: nil, + updatedAt: now).toUsageSnapshot() + #expect(empty.details.isEmpty) + #expect(unknown.primary == nil) + #expect(unknown.details.isEmpty) + } +} + +struct LiveMultiProductCreditsPayload: Sendable { + let payload: String + let usedPercent: Double + let products: [GrokProductUsage] + + static let p2 = Self( + payload: [ + #"{"config":{"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","#, + #""start":"2026-09-20T18:42:45.537749+00:00","#, + #""end":"2026-09-27T18:42:45.537749+00:00"},"creditUsagePercent":2.0,"onDemandCap":{"val":0},"#, + #""onDemandUsed":{"val":0},"productUsage":[{"product":"GrokBuild","usagePercent":1.0},"#, + #"{"product":"GrokChat","usagePercent":1.0}],"isUnifiedBillingUser":true,"#, + #""prepaidBalance":{"val":0},"topUpMethod":"TOP_UP_METHOD_SAVED_PAYMENT_METHOD","#, + #""billingPeriodStart":"2026-09-20T18:42:45.537749+00:00","#, + #""billingPeriodEnd":"2026-09-27T18:42:45.537749+00:00"}}"#, + ].joined(), + usedPercent: 2, + products: [ + GrokProductUsage(product: "GrokBuild", usedPercent: 1), + GrokProductUsage(product: "GrokChat", usedPercent: 1), + ]) + + static let p3 = Self( + payload: [ + #"{"config":{"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","#, + #""start":"2026-09-20T18:42:45.537749+00:00","#, + #""end":"2026-09-27T18:42:45.537749+00:00"},"creditUsagePercent":3.0,"onDemandCap":{"val":0},"#, + #""onDemandUsed":{"val":0},"productUsage":[{"product":"GrokChat","usagePercent":2.0},"#, + #"{"product":"GrokBuild","usagePercent":1.0}],"isUnifiedBillingUser":true,"#, + #""prepaidBalance":{"val":0},"topUpMethod":"TOP_UP_METHOD_SAVED_PAYMENT_METHOD","#, + #""billingPeriodStart":"2026-09-20T18:42:45.537749+00:00","#, + #""billingPeriodEnd":"2026-09-27T18:42:45.537749+00:00"}}"#, + ].joined(), + usedPercent: 3, + products: [ + GrokProductUsage(product: "GrokChat", usedPercent: 2), + GrokProductUsage(product: "GrokBuild", usedPercent: 1), + ]) + + static let p4 = Self( + payload: [ + #"{"config":{"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","#, + #""start":"2026-09-20T18:42:45.537749+00:00","#, + #""end":"2026-09-27T18:42:45.537749+00:00"},"creditUsagePercent":4.0,"onDemandCap":{"val":0},"#, + #""onDemandUsed":{"val":0},"productUsage":[{"product":"GrokChat","usagePercent":3.0},"#, + #"{"product":"GrokBuild","usagePercent":1.0}],"isUnifiedBillingUser":true,"#, + #""prepaidBalance":{"val":0},"topUpMethod":"TOP_UP_METHOD_SAVED_PAYMENT_METHOD","#, + #""billingPeriodStart":"2026-09-20T18:42:45.537749+00:00","#, + #""billingPeriodEnd":"2026-09-27T18:42:45.537749+00:00"}}"#, + ].joined(), + usedPercent: 4, + products: [ + GrokProductUsage(product: "GrokChat", usedPercent: 3), + GrokProductUsage(product: "GrokBuild", usedPercent: 1), + ]) + + static let p6 = Self( + payload: [ + #"{"config":{"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","#, + #""start":"2026-09-20T18:42:45.537749+00:00","#, + #""end":"2026-09-27T18:42:45.537749+00:00"},"creditUsagePercent":6.0,"onDemandCap":{"val":0},"#, + #""onDemandUsed":{"val":0},"productUsage":[{"product":"GrokChat","usagePercent":4.0},"#, + #"{"product":"GrokBuild","usagePercent":2.0}],"isUnifiedBillingUser":true,"#, + #""prepaidBalance":{"val":0},"topUpMethod":"TOP_UP_METHOD_SAVED_PAYMENT_METHOD","#, + #""billingPeriodStart":"2026-09-20T18:42:45.537749+00:00","#, + #""billingPeriodEnd":"2026-09-27T18:42:45.537749+00:00"}}"#, + ].joined(), + usedPercent: 6, + products: [ + GrokProductUsage(product: "GrokChat", usedPercent: 4), + GrokProductUsage(product: "GrokBuild", usedPercent: 2), + ]) + + static let all = [Self.p2, Self.p3, Self.p4, Self.p6] +} + private final class EventRecorder: @unchecked Sendable { private let lock = NSLock() private var storage: [String] = [] diff --git a/Tests/CodexBarTests/GrokMenuCardModelTests.swift b/Tests/CodexBarTests/GrokMenuCardModelTests.swift index 5a8ec88f1e..a7102a9b28 100644 --- a/Tests/CodexBarTests/GrokMenuCardModelTests.swift +++ b/Tests/CodexBarTests/GrokMenuCardModelTests.swift @@ -197,6 +197,36 @@ struct GrokMenuCardModelTests { #expect(model.providerDetails.isEmpty) } + @Test + func `product composition appears in card details without adding a metric`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let billing = GrokWebBillingSnapshot( + usedPercent: 29, + resetsAt: now.addingTimeInterval(5 * 86400), + windowMinutes: 10080, + productUsage: [ + GrokProductUsage(product: "GrokBuild", usedPercent: 28), + GrokProductUsage(product: "GrokChat", usedPercent: 1), + ]) + let usage = GrokUsageSnapshot( + billing: nil, + webBilling: billing, + credentials: nil, + localSummary: nil, + cliVersion: nil, + updatedAt: now).toUsageSnapshot() + let window = try #require(usage.primary) + let withoutProducts = try Self.model(now: now, window: window) + let withProducts = try Self.model(now: now, window: window, details: usage.details) + + #expect(withProducts.metrics.map(\.id) == withoutProducts.metrics.map(\.id)) + #expect(withProducts.metrics.count == 1) + #expect(withProducts.providerDetails.count == 1) + #expect(withProducts.providerDetails.first?.title == "Usage breakdown") + #expect(withProducts.providerDetails.first?.rows.map(\.label) == ["Grok Build", "Grok Chat"]) + #expect(withProducts.providerDetails.first?.rows.map(\.value) == ["28%", "1%"]) + } + @Test func `untyped coupon details cannot invent current reset credits`() throws { let now = Date(timeIntervalSince1970: 1_787_647_576) diff --git a/Tests/CodexBarTests/GrokPaceScreenshotRenderTests.swift b/Tests/CodexBarTests/GrokPaceScreenshotRenderTests.swift index 6d34bfc1ca..3f0a2800bb 100644 --- a/Tests/CodexBarTests/GrokPaceScreenshotRenderTests.swift +++ b/Tests/CodexBarTests/GrokPaceScreenshotRenderTests.swift @@ -9,6 +9,48 @@ import XCTest /// No app launch, account configuration, provider request, or credential access is involved. @MainActor final class GrokPaceScreenshotRenderTests: XCTestCase { + func test_renderProductUsage() throws { + guard let path = ProcessInfo.processInfo.environment["CODEXBAR_GROK_PRODUCT_PROOF_DIR"] else { + throw XCTSkip("Set CODEXBAR_GROK_PRODUCT_PROOF_DIR for synthetic product usage proof") + } + let directory = URL(fileURLWithPath: path, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let now = try XCTUnwrap(ISO8601DateParser.parse("2026-09-25T01:00:00Z")) + let billing = try GrokCreditsProxyFetcher.parseSnapshot( + Data(LiveMultiProductCreditsPayload.p6.payload.utf8), now: now) + let after = GrokUsageSnapshot( + billing: nil, + webBilling: billing, + credentials: nil, + localSummary: nil, + cliVersion: nil, + updatedAt: now).toUsageSnapshot() + let before = after.replacing(details: .value([])) + try CodexBarLocalizationOverride.$appLanguage.withValue("en") { + for (stage, snapshot) in [("before", before), ("after", after)] { + let model = try Self.model(snapshot: snapshot, now: now) + XCTAssertEqual(model.metrics.count, 1) + XCTAssertEqual(model.metrics.first?.title, "Weekly") + XCTAssertEqual(model.providerDetails.count, stage == "after" ? 1 : 0) + if stage == "after" { + XCTAssertEqual(model.providerDetails.first?.rows.map(\.label), ["Grok Chat", "Grok Build"]) + XCTAssertEqual(model.providerDetails.first?.rows.map(\.value), ["4%", "2%"]) + } + for dark in [false, true] { + let view = AnyView(UsageMenuCardView(model: model, width: 320) + .environment(\.locale, Locale(identifier: "en_US_POSIX")) + .environment(\.colorScheme, dark ? .dark : .light) + .environment(\.displayScale, 2) + .background(Color(nsColor: .windowBackgroundColor))) + let hosting = NSHostingView(rootView: view) + hosting.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + try XCTUnwrap(MenuLayoutScreenshotRenderTests.pngDataWithWindow(hosting: hosting)) + .write(to: directory.appendingPathComponent("product-\(stage)-\(dark ? "dark" : "light").png")) + } + } + } + } + func test_renderResetCoupons() throws { guard let path = ProcessInfo.processInfo.environment["CODEXBAR_GROK_COUPON_PROOF_DIR"] else { throw XCTSkip("Set CODEXBAR_GROK_COUPON_PROOF_DIR for synthetic coupon proof") diff --git a/Tests/CodexBarTests/GrokProductUsageTests.swift b/Tests/CodexBarTests/GrokProductUsageTests.swift new file mode 100644 index 0000000000..9937d93009 --- /dev/null +++ b/Tests/CodexBarTests/GrokProductUsageTests.swift @@ -0,0 +1,29 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct GrokProductUsageTests { + @Test + func `billing product shares reach the shared detail section`() throws { + let billing = try GrokCreditsProxyFetcher.parseSnapshot(Data(""" + {"config":{"creditUsagePercent":6,"productUsage":[ + {"product":"GrokBuild","usagePercent":2}, + {"product":"GrokChat","usagePercent":4} + ]}} + """.utf8)) + let usage = GrokUsageSnapshot( + billing: nil, + webBilling: billing, + credentials: nil, + localSummary: nil, + cliVersion: nil, + updatedAt: Date()).toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 6) + #expect(usage.secondary == nil) + let section = try #require(usage.details.first) + #expect(section.title == "Usage breakdown") + #expect(section.rows.map(\.label) == ["Grok Chat", "Grok Build"]) + #expect(section.rows.map(\.value) == ["4%", "2%"]) + } +} diff --git a/Tests/CodexBarTests/GrokRemainingResetsRoutingTests.swift b/Tests/CodexBarTests/GrokRemainingResetsRoutingTests.swift index 04d8d9e42b..c26ac58b21 100644 --- a/Tests/CodexBarTests/GrokRemainingResetsRoutingTests.swift +++ b/Tests/CodexBarTests/GrokRemainingResetsRoutingTests.swift @@ -84,7 +84,8 @@ struct GrokRemainingResetsRoutingTests { GrokWebBillingResult( snapshot: GrokWebBillingSnapshot( usedPercent: 29, - resetsAt: now.addingTimeInterval(86400)), + resetsAt: now.addingTimeInterval(86400), + productUsage: [GrokProductUsage(product: "GrokBuild", usedPercent: 4)]), sourceLabel: "Chrome Profile 2", authContext: .cookie("sso=winning")) }, @@ -104,6 +105,30 @@ struct GrokRemainingResetsRoutingTests { #expect(capturedCookie.value == "sso=winning") #expect(result.usage.details.first?.rows.first?.value == "1 available") + #expect(result.usage.details.count == 2) + #expect(result.usage.details[1].title == "Usage breakdown") + #expect(result.usage.details[1].rows.first?.value == "4%") + } + + @Test + func `web strategy keeps product details when no reset credit is available`() async throws { + let result = try await GrokWebFetchStrategy().fetch( + Self.webContext(includeOptionalUsage: true), + webBilling: { _ in + GrokWebBillingResult( + snapshot: GrokWebBillingSnapshot( + usedPercent: 29, + resetsAt: nil, + productUsage: [GrokProductUsage(product: "GrokChat", usedPercent: 3)]), + sourceLabel: "Chrome", + authContext: .cookie("sso=winning")) + }, + settingsTier: { _ in nil }, + remainingResets: { _, _, _ in .empty }) + + #expect(result.usage.details.count == 1) + #expect(result.usage.details.first?.title == "Usage breakdown") + #expect(result.usage.details.first?.rows.first?.label == "Grok Chat") } @Test @@ -169,7 +194,8 @@ struct GrokRemainingResetsRoutingTests { billing: nil, webBilling: GrokWebBillingSnapshot( usedPercent: 29, - resetsAt: now.addingTimeInterval(86400)), + resetsAt: now.addingTimeInterval(86400), + productUsage: [GrokProductUsage(product: "GrokImagine", usedPercent: 2)]), credentials: nil, localSummary: nil, cliVersion: nil, @@ -185,6 +211,9 @@ struct GrokRemainingResetsRoutingTests { #expect(result.usage.grokResetCredits == resetCredits) #expect(result.usage.details.first?.rows.first?.value == "1 available") + #expect(result.usage.details.count == 2) + #expect(result.usage.details[1].title == "Usage breakdown") + #expect(result.usage.details[1].rows.first?.label == "Grok Imagine") #expect(result.supplementalUsageTask == nil) } diff --git a/docs/grok.md b/docs/grok.md index d1ad11ec55..ce0fd2723d 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -239,6 +239,23 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. when `resetsAt` matches a common cycle, falling back to the registered "Credits" label otherwise. Settings and history views continue to use "Credits" as the stable metric name. +- **Usage breakdown by product**: + - From `config.productUsage` on `/v1/billing?format=credits` + (`[{ "product": "GrokBuild", "usagePercent": 1.0 }]`; also `GrokChat`, + `GrokImagine`, `GrokAppBuilder`). Shares only appear next to the total from + the same payload. If the proxy sends products without a total and the + percent comes from the grok.com fallback, the products are dropped. + - Every product percentage is a share of the same credit pool as the primary + window, so it is never a rate window or progress bar. It renders as plain + `Usage breakdown` text rows (`Grok Build 1%`) under the weekly bar, sorted by + share, with zero-usage products omitted. + - Shown only when the primary window comes from the wire `creditUsagePercent` + and the product shares add up to that raw (unclamped) percentage within + 1 percentage point, allowing for rounding. Shares are dropped under the on-demand `used/cap` fallback, + under a period-only answer, and whenever they don't add up. A single malformed + entry, or a non-array value, drops the whole breakdown. That way a partial + list can't pass the sum check as if it were complete. It never changes the + credit total or period. Reset-credit enrichment preserves the breakdown. - **Usage-limit reset coupons**: - From `GetRemainingResets`, not from `/v1/billing?format=credits`. - Shown as a `Limit Reset Credits` detail row (`1 available`, next expiry).