diff --git a/.github/pr-proof/grok-grpc-product-usage/README.md b/.github/pr-proof/grok-grpc-product-usage/README.md new file mode 100644 index 0000000000..7b82a3fd10 --- /dev/null +++ b/.github/pr-proof/grok-grpc-product-usage/README.md @@ -0,0 +1,22 @@ +# Grok grok.com-path product breakdown proof + +These images are the production `UsageMenuCardView`, rendered offscreen at +310 pt with `hidePersonalInfo: true`. The data comes from a **live** +grok.com `GetGrokCreditsConfig` gRPC-web response (2026-09-26 21:58 UTC), +fetched and parsed by this branch's `GrokWebBillingFetcher`. + +- **Credentials:** the run read the bearer from `~/.grok/auth.json`. No + browser cookies and no Keychain were involved. +- **Parsed result:** `usedPercent 6.0` (wire-published) and + `productUsage [GrokChat 4.0, GrokBuild 2.0]`. The same response is checked + in, verbatim, as the fixture in `GrokWebBillingProductUsageTests`. +- **after.png:** that snapshot rendered as-is: one weekly bar plus + `Grok Chat 4%` / `Grok Build 2%`. +- **before.png:** the same snapshot with `details` cleared. That is what main + shows on this path, because its gRPC parser never pairs the `[1, 7]` ids with + their percentages. + +The fetch-and-render harness was a temporary test and was not committed. It +calls `GrokCredentialsStore.load`, then `GrokWebBillingFetcher.fetch`, then +`GrokUsageSnapshot.toUsageSnapshot`, and renders through `NSHostingView` + +`cacheDisplay`. diff --git a/.github/pr-proof/grok-grpc-product-usage/after.png b/.github/pr-proof/grok-grpc-product-usage/after.png new file mode 100644 index 0000000000..dadc4ec593 Binary files /dev/null and b/.github/pr-proof/grok-grpc-product-usage/after.png differ diff --git a/.github/pr-proof/grok-grpc-product-usage/before.png b/.github/pr-proof/grok-grpc-product-usage/before.png new file mode 100644 index 0000000000..b48a57a4f8 Binary files /dev/null and b/.github/pr-proof/grok-grpc-product-usage/before.png differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 04cf9084a5..8fe6beef4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! - TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! +- Grok: retain the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey! ### Changed - Menu bar: align the persistent Refresh row with other menu actions by removing its decorative icon, preserving the shortcut and accessibility action (#4057). Thanks @elijahfriedman! diff --git a/Sources/CodexBarCore/Providers/Grok/GrokCreditsProxyFetcher.swift b/Sources/CodexBarCore/Providers/Grok/GrokCreditsProxyFetcher.swift index 264ddf25d3..7e5319837e 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokCreditsProxyFetcher.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokCreditsProxyFetcher.swift @@ -10,7 +10,6 @@ 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, @@ -69,7 +68,8 @@ public enum GrokCreditsProxyFetcher { resetsAt: resetsAt, windowMinutes: windowMinutes, subscriptionTier: subscriptionTier, - productUsage: Self.composingProducts(config.productUsage?.values ?? [], creditUsagePercent: percent)) + productUsage: GrokProductUsage.composing( + config.productUsage?.values ?? [], creditUsagePercent: percent)) } if let cap = config.onDemandCap?.val, @@ -95,16 +95,6 @@ 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, diff --git a/Sources/CodexBarCore/Providers/Grok/GrokProtobufField.swift b/Sources/CodexBarCore/Providers/Grok/GrokProtobufField.swift new file mode 100644 index 0000000000..3f1b194089 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Grok/GrokProtobufField.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Bounded protobuf wire reader shared by Grok billing and reset-coupon messages. +struct GrokProtobufField { + var number: UInt64 + var varint: UInt64? + var fixed32: Float? + var message: [UInt8]? + + static func fields(in bytes: [UInt8]) -> [Self]? { + var fields: [Self] = [] + var index = 0 + while index < bytes.count { + guard let field = self.read(bytes, index: &index) else { return nil } + fields.append(field) + } + return fields + } + + static func read(_ bytes: [UInt8], index: inout Int) -> Self? { + guard let key = self.readVarint(bytes, index: &index), + key >> 3 > 0, key >> 3 <= 536_870_911 else { return nil } + var field = Self(number: key >> 3) + switch key & 0x07 { + case 0: + guard let value = self.readVarint(bytes, index: &index) else { return nil } + field.varint = value + case 1: + guard bytes.count - index >= 8 else { return nil } + index += 8 + case 2: + guard let length = self.readVarint(bytes, index: &index), + length <= UInt64(bytes.count - index) else { return nil } + let end = index + Int(length) + field.message = Array(bytes[index..= 4 else { return nil } + let bits = UInt32(bytes[index]) | (UInt32(bytes[index + 1]) << 8) + | (UInt32(bytes[index + 2]) << 16) | (UInt32(bytes[index + 3]) << 24) + field.fixed32 = Float(bitPattern: bits) + index += 4 + default: + return nil + } + return field + } + + private static func readVarint(_ bytes: [UInt8], index: inout Int) -> UInt64? { + var value: UInt64 = 0 + var shift: UInt64 = 0 + while index < bytes.count, shift < 64 { + let byte = bytes[index] + index += 1 + if shift == 63, byte > 1 { return nil } + value |= UInt64(byte & 0x7F) << shift + if byte & 0x80 == 0 { return value } + shift += 7 + } + return nil + } +} diff --git a/Sources/CodexBarCore/Providers/Grok/GrokRemainingResetsFetcher.swift b/Sources/CodexBarCore/Providers/Grok/GrokRemainingResetsFetcher.swift index 4f4f7d3b0a..6eec15f2cd 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokRemainingResetsFetcher.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokRemainingResetsFetcher.swift @@ -365,151 +365,43 @@ enum GrokRemainingResetsFetcher { } private static func parseMessage(_ data: Data, now: Date) -> ParsedMessage? { - let bytes = [UInt8](data) + guard let fields = GrokProtobufField.fields(in: Array(data)) else { return nil } var tokens: [GrokRemainingReset] = [] var containsTokenRecord = false - var index = 0 - while index < bytes.count { - guard let key = Self.readVarint(bytes, index: &index), key != 0 else { return nil } - let fieldNumber = key >> 3 - let wireType = key & 0x07 - switch wireType { - case 0: - guard Self.readVarint(bytes, index: &index) != nil else { return nil } - case 1: - guard index + 8 <= bytes.count else { return nil } - index += 8 - case 2: - guard let length = Self.readVarint(bytes, index: &index), - length <= UInt64(bytes.count - index) - else { - return nil - } - let start = index - let end = index + Int(length) - if fieldNumber == 10 { - containsTokenRecord = true - guard let parsed = Self.parseToken(Data(bytes[start.. ParsedToken? { - let bytes = [UInt8](data) + guard let fields = GrokProtobufField.fields(in: Array(data)) else { return nil } var tokenID = "" var grantedAt: Date? var expiresAt: Date? - var index = 0 - while index < bytes.count { - guard let key = Self.readVarint(bytes, index: &index), key != 0 else { return nil } - let fieldNumber = key >> 3 - let wireType = key & 0x07 - switch wireType { - case 0: - guard Self.readVarint(bytes, index: &index) != nil else { return nil } - case 1: - guard index + 8 <= bytes.count else { return nil } - index += 8 - case 2: - guard let length = Self.readVarint(bytes, index: &index), - length <= UInt64(bytes.count - index) - else { - return nil - } - let start = index - let end = index + Int(length) - let payload = Data(bytes[start.. now else { return ParsedToken(token: nil) } - return ParsedToken(token: GrokRemainingReset( - tokenID: tokenID, - grantedAt: grantedAt, - expiresAt: expiresAt)) + return ParsedToken(token: GrokRemainingReset(tokenID: tokenID, grantedAt: grantedAt, expiresAt: expiresAt)) } private static func timestamp(from data: Data) -> Date? { - let bytes = [UInt8](data) - var index = 0 - while index < bytes.count { - let fieldStart = index - guard let key = Self.readVarint(bytes, index: &index), key != 0 else { - index = fieldStart + 1 - continue - } - let fieldNumber = key >> 3 - let wireType = key & 0x07 - switch wireType { - case 0: - if let value = Self.readVarint(bytes, index: &index), - fieldNumber == 1, - value >= 1_700_000_000, - value <= 2_100_000_000 - { - return Date(timeIntervalSince1970: TimeInterval(value)) - } - case 1: - guard index + 8 <= bytes.count else { return nil } - index += 8 - case 2: - guard let length = Self.readVarint(bytes, index: &index), - length <= UInt64(bytes.count - index) - else { - return nil - } - index += Int(length) - case 5: - guard index + 4 <= bytes.count else { return nil } - index += 4 - default: - return nil - } - } - return nil - } - - private static func readVarint(_ bytes: [UInt8], index: inout Int) -> UInt64? { - var value: UInt64 = 0 - var shift: UInt64 = 0 - while index < bytes.count { - let byte = bytes[index] - index += 1 - value |= UInt64(byte & 0x7F) << shift - if byte & 0x80 == 0 { - return value - } - shift += 7 - if shift > 63 { - return nil - } - } - return nil + guard let fields = GrokProtobufField.fields(in: Array(data)), + let seconds = fields.first(where: { + $0.number == 1 && $0.varint.map { (1_700_000_000...2_100_000_000).contains($0) } == true + })?.varint else { return nil } + return Date(timeIntervalSince1970: TimeInterval(seconds)) } } diff --git a/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift b/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift index b88992ab3b..d6f8e63b1c 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift @@ -14,6 +14,17 @@ public struct GrokProductUsage: Sendable, Equatable { } } +extension GrokProductUsage { + static let compositionTolerancePercent = 1.0 + + static func composing(_ 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.compositionTolerancePercent ? products : [] + } +} + public struct GrokWebBillingSnapshot: Sendable, Equatable { public let usedPercent: Double? public let resetsAt: Date? @@ -306,14 +317,14 @@ public enum GrokWebBillingFetcher { scan.merge(Self.scanProtobuf(payload, depth: 0)) } - let parsedPercent = scan.fixed32Fields + let parsedPercentField = scan.fixed32Fields .filter { field in field.path.last == 1 && field.value.isFinite && field.value >= 0 && field.value <= 100 } .min { lhs, rhs in lhs.path.count == rhs.path.count ? lhs.order < rhs.order : lhs.path.count < rhs.path.count } - .map { Double($0.value) } + let parsedPercent = parsedPercentField.map { Double($0.value) } let resetFields = scan.varintFields.compactMap { field -> (path: [UInt64], date: Date)? in let raw = field.value @@ -344,11 +355,20 @@ public enum GrokWebBillingFetcher { guard let percent = parsedPercent ?? (noUsageYet ? 0 : nil) else { throw GrokWebBillingError.parseFailed } + let productUsage: [GrokProductUsage] = if payloads.count == 1, scan.isComplete, + let parsedPercent, parsedPercentField?.path == [1, 1] + { + GrokProductUsage.composing( + Self.decodeProductUsage(payloads[0]), creditUsagePercent: parsedPercent) + } else { + [] + } return GrokWebBillingSnapshot( usedPercent: percent, resetsAt: reset, usedPercentIsWirePublished: parsedPercent != nil, - usedPercentIsImplicitZero: noUsageYet && payloads.count == 1 && scan.isComplete && hasActiveCurrentPeriod) + usedPercentIsImplicitZero: noUsageYet && payloads.count == 1 && scan.isComplete && hasActiveCurrentPeriod, + productUsage: productUsage) } static func looksLikeProtobufPayload(_ data: Data) -> Bool { @@ -365,6 +385,7 @@ public enum GrokWebBillingFetcher { while index < bytes.count { guard index + 5 <= bytes.count else { return [] } let flags = bytes[index] + guard flags == 0 || flags == 0x80 else { return [] } let length = (Int(bytes[index + 1]) << 24) | (Int(bytes[index + 2]) << 16) @@ -487,70 +508,21 @@ public enum GrokWebBillingFetcher { var nextOrder = order while index < bytes.count { - let fieldStart = index - guard let key = Self.readVarint(bytes, index: &index), key >> 3 > 0, key >> 3 <= 536_870_911 else { + guard let field = GrokProtobufField.read(bytes, index: &index) else { scan.isComplete = false - index = fieldStart + 1 - continue + return (scan, nextOrder) } - let fieldNumber = key >> 3 - let wireType = key & 0x07 - let fieldPath = path + [fieldNumber] - - switch wireType { - case 0: - if let value = Self.readVarint(bytes, index: &index) { - scan.varintFields.append(ProtobufScan.VarintField(path: fieldPath, value: value)) - } else { - scan.isComplete = false - index = fieldStart + 1 - } - case 1: - guard index + 8 <= bytes.count else { - scan.isComplete = false - return (scan, nextOrder) - } - index += 8 - case 2: - guard let length = Self.readVarint(bytes, index: &index), - length <= UInt64(bytes.count - index) - else { - scan.isComplete = false - index = fieldStart + 1 - continue - } - let start = index - let end = index + Int(length) - if depth < 4, Self.isKnownBillingMessage(path: fieldPath) { - let nested = Self.scanProtobuf( - Data(bytes[start.. UInt64? { - var value: UInt64 = 0 - var shift: UInt64 = 0 - while index < bytes.count, shift < 64 { - let byte = bytes[index] - index += 1 - if shift == 63, byte > 1 { return nil } - value |= UInt64(byte & 0x7F) << shift - if byte & 0x80 == 0 { - return value + private static func decodeProductUsage(_ payload: Data) -> [GrokProductUsage] { + guard let root = GrokProtobufField.fields(in: Array(payload)) else { return [] } + let configs = root.filter { $0.number == 1 } + guard configs.count == 1, let message = configs[0].message, + let config = GrokProtobufField.fields(in: message), + config.filter({ $0.number == 1 }).count == 1 else { return [] } + var products: [GrokProductUsage] = [] + var seenIDs: Set = [] + for field in config where field.number == 7 { + guard let entry = field.message, + let (id, percent) = Self.decodeProductEntry(entry), seenIDs.insert(id).inserted else { return [] } + // Only live-verified product IDs are named. + let name: String? = switch id { + case 2: "GrokBuild" + case 4: "GrokChat" + default: nil + } + if let name { + products.append(GrokProductUsage(product: name, usedPercent: percent)) + } else if percent > 0 { + return [] } - shift += 7 } - return nil + return products + } + + private static func decodeProductEntry(_ bytes: [UInt8]) -> (UInt64, Double)? { + guard let fields = GrokProtobufField.fields(in: bytes) else { return nil } + let ids = fields.filter { $0.number == 1 } + let percentages = fields.filter { $0.number == 2 } + guard ids.count == 1, let id = ids[0].varint, percentages.count <= 1, + percentages.isEmpty || percentages[0].fixed32 != nil else { return nil } + let percent = Double(percentages.first?.fixed32 ?? 0) + guard percent.isFinite, percent >= 0 else { return nil } + return (id, percent) } } diff --git a/Tests/CodexBarTests/GrokProtobufFieldTests.swift b/Tests/CodexBarTests/GrokProtobufFieldTests.swift new file mode 100644 index 0000000000..3e1644b714 --- /dev/null +++ b/Tests/CodexBarTests/GrokProtobufFieldTests.swift @@ -0,0 +1,26 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct GrokProtobufFieldTests { + @Test + func `wire reader bounds continuation and length reads`() { + let endless = [UInt8(0x08)] + Array(repeating: UInt8(0x80), count: 4096) + var index = 0 + #expect(GrokProtobufField.read(endless, index: &index) == nil) + #expect(index == 11) + let malformed: [[UInt8]] = [[0x00], [0x02, 0], [0x09, 0], [0x15, 0, 0], [0x12, 0x7F, 0], [0x08]] + for bytes in malformed { + #expect(GrokProtobufField.fields(in: bytes) == nil) + } + } + + @Test + func `wire reader accepts maximum integers and leaves opaque bytes uninterpreted`() throws { + let maximum = [UInt8(0x08)] + Array(repeating: UInt8(0xFF), count: 9) + [0x01] + let fields = try #require(GrokProtobufField.fields(in: maximum + [0x12, 0x01, 0xFF])) + #expect(fields.count == 2) + #expect(fields[0].varint == UInt64.max) + #expect(fields[1].message == [0xFF]) + } +} diff --git a/Tests/CodexBarTests/GrokWebBillingProductUsageTests.swift b/Tests/CodexBarTests/GrokWebBillingProductUsageTests.swift new file mode 100644 index 0000000000..5b8a720bc1 --- /dev/null +++ b/Tests/CodexBarTests/GrokWebBillingProductUsageTests.swift @@ -0,0 +1,348 @@ +import Foundation +import Testing +@testable import CodexBarCLI +@testable import CodexBarCore + +struct GrokWebBillingProductUsageTests { + private static let now = Date(timeIntervalSince1970: 1_790_456_400) + private static let periodStart: UInt64 = 1_789_929_765 + private static let periodEnd: UInt64 = 1_790_534_565 + + @Test + func `live grok dot com billing frame decodes the product breakdown`() throws { + let hex = """ + 000000005f0a5d0d0000c04012001a00220c08a5d2c0d5061088ccb580022a0c08a5c7e5d5061088ccb58002 + 3a07080415000080403a0708021500000040421e0802120c08a5d2c0d5061088ccb580021a0c08a5c7e5d506 + 1088ccb58002580162006801800000000f677270632d7374617475733a300d0a + """ + let data = try #require(Self.data(hex: hex)) + let parsed = try GrokWebBillingFetcher.parseGRPCWebResponse(data, now: Self.now) + + #expect(parsed.usedPercent == 6) + #expect(parsed.usedPercentIsWirePublished) + #expect(parsed.productUsage == [ + GrokProductUsage(product: "GrokChat", usedPercent: 4), + GrokProductUsage(product: "GrokBuild", usedPercent: 2), + ]) + + let usage = GrokUsageSnapshot( + billing: nil, + webBilling: parsed, + credentials: nil, + localSummary: nil, + cliVersion: nil, + updatedAt: Self.now).toUsageSnapshot() + let section = try #require(usage.details.first) + #expect(usage.primary?.usedPercent == 6) + #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.allSatisfy { $0.progress == nil }) + #expect(usage.secondary == nil) + #expect(usage.tertiary == nil) + + let payload = ProviderPayload( + provider: .grok, + account: nil, + version: nil, + source: "fixture", + status: nil, + usage: usage, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil) + let object = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(payload)) as? [String: Any]) + let encodedUsage = try #require(object["usage"] as? [String: Any]) + let details = try #require(encodedUsage["details"] as? [[String: Any]]) + let rows = try #require(details.first?["rows"] as? [[String: Any]]) + #expect(details.first?["title"] as? String == "Usage breakdown") + #expect(rows.compactMap { $0["label"] as? String } == ["Grok Chat", "Grok Build"]) + #expect(rows.compactMap { $0["value"] as? String } == ["4%", "2%"]) + } + + @Test + func `unnamed products keep only complete named shares`() throws { + let named = [Self.entry(id: 4, percent: 4), Self.entry(id: 2, percent: 2)] + let baseline = Self.frame(Self.payload(aggregate: 6)) + try Self.expectSameBilling( + Self.frame(Self.payload(aggregate: 6, entries: named + [Self.entry(id: 7, percent: 1)])), + baseline: baseline, + products: []) + try Self.expectSameBilling( + Self.frame(Self.payload(aggregate: 6, entries: [Self.entry(id: 7)] + named)), + baseline: baseline, + products: [ + GrokProductUsage(product: "GrokChat", usedPercent: 4), + GrokProductUsage(product: "GrokBuild", usedPercent: 2), + ]) + } + + @Test + func `missing duplicate and malformed product ids drop the breakdown`() throws { + let baseline = Self.frame(Self.payload(aggregate: 6)) + let invalidEntries: [[Data]] = [ + [Self.entry(id: nil, percent: 4)], + [Self.entry(id: 4, percent: 4), Self.entry(id: 4, percent: 2)], + [Data([0x08])], // Truncated field 1 varint inside an entry. + [Self.fixed32(1, 4), Self.entry(id: 2, percent: 6)], + [Self.entry(id: 4) + Self.varintField(2, 1), Self.entry(id: 2, percent: 6)], + ] + for entries in invalidEntries { + try Self.expectSameBilling( + Self.frame(Self.payload(aggregate: 6, entries: entries)), + baseline: baseline, + products: []) + } + try Self.expectSameBilling( + Self.frame(Self.payload(aggregate: 6, extra: Self.varintField(7, 4))), + baseline: baseline, + products: []) + } + + @Test + func `omitted percentages default to zero and unknown entry fields are skipped`() throws { + let baseline = Self.frame(Self.payload(aggregate: 6)) + var chat = Self.entry(id: 4, percent: 6) + chat.append(Self.varintField(3, 42)) + chat.append(Self.message(4, Data([0xFF]))) + chat.append(Self.fixed32(5, 3)) + chat.append(contentsOf: [0x31] + Array(repeating: 0, count: 8)) // Field 6, fixed64. + try Self.expectSameBilling( + Self.frame(Self.payload(aggregate: 6, entries: [chat, Self.entry(id: 2)])), + baseline: baseline, + products: [ + GrokProductUsage(product: "GrokChat", usedPercent: 6), + GrokProductUsage(product: "GrokBuild", usedPercent: 0), + ]) + } + + @Test + func `invalid or noncomposing percentages drop the breakdown`() throws { + let baseline = Self.frame(Self.payload(aggregate: 6)) + let cases: [[Data]] = [ + [Self.entry(id: 4, percent: -1), Self.entry(id: 2, percent: 7)], + [Self.entry(id: 4, percent: .nan), Self.entry(id: 2, percent: 2)], + [Self.entry(id: 2, percent: 2)], + ] + for entries in cases { + try Self.expectSameBilling( + Self.frame(Self.payload(aggregate: 6, entries: entries)), + baseline: baseline, + products: []) + } + } + + @Test + func `products require one complete payload with a published config aggregate`() throws { + let named = [Self.entry(id: 4, percent: 4), Self.entry(id: 2, percent: 2)] + let baseline = Self.frame(Self.payload(aggregate: 6)) + try Self.expectSameBilling(baseline, baseline: baseline, products: []) + + let implicitZero = Self.frame(Self.payload(aggregate: nil, entries: [Self.entry(id: 4)])) + let implicitBaseline = Self.frame(Self.payload(aggregate: nil)) + let implicitSnapshot = try GrokWebBillingFetcher.parseGRPCWebResponse(implicitBaseline, now: Self.now) + #expect(implicitSnapshot.usedPercent == 0) + #expect(implicitSnapshot.usedPercentIsImplicitZero) + try Self.expectSameBilling( + implicitZero, + baseline: implicitBaseline, + products: []) + + let twoFrames = Self.frame(Self.payload(aggregate: 6, entries: named)) + + Self.frame(Self.payload(aggregate: 6)) + let twoFramesBaseline = Self.frame(Self.payload(aggregate: 6)) + + Self.frame(Self.payload(aggregate: 6)) + try Self.expectSameBilling(twoFrames, baseline: twoFramesBaseline, products: []) + + let nestedAggregate = Self.message(2, Self.fixed32(1, 6)) + try Self.expectSameBilling( + Self.frame(Self.payload(aggregate: nil, entries: named, extra: nestedAggregate)), + baseline: Self.frame(Self.payload(aggregate: nil, extra: nestedAggregate)), + products: []) + + let malformedOtherField = Data([0x62, 0x02, 0x08]) + try Self.expectSameBilling( + Self.frame(Self.payload(aggregate: 6, entries: named, extra: malformedOtherField)), + baseline: Self.frame(Self.payload(aggregate: 6, extra: malformedOtherField)), + products: []) + } + + @Test + func `cookie fallback shows decoded product rows`() async throws { + let liveHex = """ + 000000005f0a5d0d0000c04012001a00220c08a5d2c0d5061088ccb580022a0c08a5c7e5d5061088ccb58002 + 3a07080415000080403a0708021500000040421e0802120c08a5d2c0d5061088ccb580021a0c08a5c7e5d506 + 1088ccb58002580162006801800000000f677270632d7374617475733a300d0a + """ + let live = try #require(Self.data(hex: liveHex)) + var strategy = GrokWebFetchStrategy() + strategy.loadCredentials = { _ in .failure(GrokWebBillingError.missingCredentials) } + strategy.localSummary = { _ in nil } + strategy.cliVersion = { _ in nil } + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBar-GrokProductUsage-\(UUID().uuidString)", isDirectory: true) + let browserDetection = BrowserDetection(cacheTTL: 0) + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .web, + includeCredits: true, + includeOptionalUsage: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: ["GROK_HOME": home.path], + settings: nil, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + let result = try await strategy.fetch( + context, + webBilling: { _ in + try GrokWebBillingResult( + snapshot: GrokWebBillingFetcher.parseGRPCWebResponse(live, now: Self.now), + sourceLabel: "Chrome", + authContext: .cookie("sso=test")) + }, + settingsTier: { _ in nil }, + remainingResets: { _, _, _ in .empty }) + + #expect(result.usage.primary?.usedPercent == 6) + #expect(result.usage.details.count == 1) + #expect(result.usage.details.first?.title == "Usage breakdown") + #expect(result.usage.details.first?.rows.map(\.label) == ["Grok Chat", "Grok Build"]) + #expect(result.usage.details.first?.rows.map(\.value) == ["4%", "2%"]) + } + + @Test + func `duplicate scalar fields cannot relabel or reweight product shares`() throws { + let baseline = Self.frame(Self.payload(aggregate: 6)) + let duplicatedID = Self.entry(id: 2, percent: 6) + Self.varintField(1, 4) + let duplicatedPercent = Self.entry(id: 4, percent: 1) + Self.fixed32(2, 6) + for entry in [duplicatedID, duplicatedPercent] { + try Self.expectSameBilling( + Self.frame(Self.payload(aggregate: 6, entries: [entry])), baseline: baseline, products: []) + } + let repeatedAggregate = Self.frame(Self.payload( + aggregate: 6, entries: [Self.entry(id: 4, percent: 6)], extra: Self.fixed32(1, 6))) + #expect(try GrokWebBillingFetcher.parseGRPCWebResponse(repeatedAggregate, now: Self.now).productUsage.isEmpty) + } + + @Test(arguments: [UInt8(1), 2, 3, 0x81]) + func `compressed or reserved frame flags fail closed`(flag: UInt8) { + var frame = Self.frame(Self.payload(aggregate: 6, entries: [Self.entry(id: 4, percent: 6)])) + frame[0] = flag + #expect(throws: GrokWebBillingError.self) { + try GrokWebBillingFetcher.parseGRPCWebResponse(frame, now: Self.now) + } + } + + @Test + func `truncated framing and overflowing product values cannot supply shares`() throws { + let valid = Self.frame(Self.payload(aggregate: 6, entries: [Self.entry(id: 4, percent: 6)])) + for suffix in [Data([0]), Data([0, 0xFF, 0xFF, 0xFF, 0xFF])] { + #expect(throws: GrokWebBillingError.self) { + try GrokWebBillingFetcher.parseGRPCWebResponse(valid + suffix, now: Self.now) + } + } + let overflow = Data([0x08] + Array(repeating: UInt8(0xFF), count: 9) + [0x02]) + let oversizedLength = Data([0x12]) + Self.varint(.max) + for entry in [overflow, oversizedLength] { + let parsed = try GrokWebBillingFetcher.parseGRPCWebResponse( + Self.frame(Self.payload(aggregate: 6, entries: [entry])), now: Self.now) + #expect(parsed.usedPercent == 6) + #expect(parsed.productUsage.isEmpty) + } + } + + private static func expectSameBilling( + _ data: Data, + baseline: Data, + products: [GrokProductUsage]) throws + { + let actual = try GrokWebBillingFetcher.parseGRPCWebResponse(data, now: Self.now) + let withoutProducts = try GrokWebBillingFetcher.parseGRPCWebResponse(baseline, now: Self.now) + #expect(actual.usedPercent == withoutProducts.usedPercent) + #expect(actual.resetsAt == withoutProducts.resetsAt) + #expect(actual.usedPercentIsWirePublished == withoutProducts.usedPercentIsWirePublished) + #expect(actual.usedPercentIsImplicitZero == withoutProducts.usedPercentIsImplicitZero) + #expect(actual.productUsage == products) + } + + private static func payload(aggregate: Float?, entries: [Data] = [], extra: Data = Data()) -> Data { + var config = Data() + if let aggregate { config.append(Self.fixed32(1, aggregate)) } + config.append(Self.message(5, Self.varintField(1, Self.periodEnd))) + var currentPeriod = Self.varintField(1, 2) + currentPeriod.append(Self.message(2, Self.varintField(1, Self.periodStart))) + currentPeriod.append(Self.message(3, Self.varintField(1, Self.periodEnd))) + config.append(Self.message(8, currentPeriod)) + for entry in entries { + config.append(Self.message(7, entry)) + } + config.append(extra) + return Self.message(1, config) + } + + private static func entry(id: UInt64?, percent: Float? = nil) -> Data { + var data = Data() + if let id { data.append(Self.varintField(1, id)) } + if let percent { data.append(Self.fixed32(2, percent)) } + return data + } + + private static func varintField(_ number: UInt64, _ value: UInt64) -> Data { + var data = Self.varint(number << 3) + data.append(Self.varint(value)) + return data + } + + private static func fixed32(_ number: UInt64, _ value: Float) -> Data { + var data = Self.varint((number << 3) | 5) + var bits = value.bitPattern.littleEndian + withUnsafeBytes(of: &bits) { data.append(contentsOf: $0) } + return data + } + + private static func message(_ number: UInt64, _ value: Data) -> Data { + var data = Self.varint((number << 3) | 2) + data.append(Self.varint(UInt64(value.count))) + data.append(value) + return data + } + + private static func varint(_ value: UInt64) -> Data { + var remaining = value + var bytes: [UInt8] = [] + repeat { + var byte = UInt8(remaining & 0x7F) + remaining >>= 7 + if remaining != 0 { byte |= 0x80 } + bytes.append(byte) + } while remaining != 0 + return Data(bytes) + } + + private static func frame(_ payload: Data) -> Data { + var data = Data([0]) + var length = UInt32(payload.count).bigEndian + withUnsafeBytes(of: &length) { data.append(contentsOf: $0) } + data.append(payload) + return data + } + + private static func data(hex: String) -> Data? { + let digits = hex.filter { !$0.isWhitespace } + guard digits.count.isMultiple(of: 2) else { return nil } + var data = Data() + var index = digits.startIndex + while index < digits.endIndex { + let next = digits.index(index, offsetBy: 2) + guard let byte = UInt8(digits[index..