Files
2026-07-13 12:22:33 +08:00

434 lines
16 KiB
Swift

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
public struct GrokWebBillingSnapshot: Sendable, Equatable {
public let usedPercent: Double?
public let resetsAt: Date?
public init(usedPercent: Double?, resetsAt: Date?) {
self.usedPercent = usedPercent
self.resetsAt = resetsAt
}
}
public enum GrokWebBillingError: LocalizedError, Sendable {
case missingCredentials
case emptyResponse
case invalidResponse
case requestFailed(Int, String)
case rpcFailed(Int, String)
case parseFailed
public var errorDescription: String? {
switch self {
case .missingCredentials:
"Grok web billing requires a signed-in grok.com browser session or `grok login`."
case .emptyResponse:
"Grok web billing returned no protobuf payload."
case .invalidResponse:
"Grok web billing returned an invalid response."
case let .requestFailed(status, body):
if status == 401 || status == 403 {
Self.reauthMessage
} else {
"Grok web billing request failed with HTTP \(status): \(body)"
}
case let .rpcFailed(status, message):
if Self.isAuthenticationFailure(status: status, message: message) {
Self.reauthMessage
} else {
"Grok web billing RPC failed with status \(status): \(message)"
}
case .parseFailed:
"Could not parse Grok web billing usage."
}
}
private static let reauthMessage =
"Grok web billing rejected credentials. Sign in to grok.com in Chrome or run `grok login` to refresh xAI auth."
static func isAuthenticationFailure(status: Int, message: String) -> Bool {
if status == 16 { return true }
guard status == 7 else { return false }
let lower = message.lowercased()
return lower.contains("bad-credentials") ||
lower.contains("unauthenticated") ||
(lower.contains("oauth2") && lower.contains("could not be validated")) ||
(lower.contains("access token") &&
(lower.contains("invalid") ||
lower.contains("expired") ||
lower.contains("could not be validated")))
}
}
public enum GrokWebBillingFetcher {
public static let defaultEndpoint =
URL(string: "https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")!
private static let requestTimeoutSeconds: TimeInterval = 15
public static func fetch(
credentials: GrokCredentials,
session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared,
endpoint: URL = Self.defaultEndpoint) async throws -> GrokWebBillingSnapshot
{
try await self.fetch(
authorizationHeader: "Bearer \(credentials.accessToken)",
cookieHeader: nil,
transport: transport,
endpoint: endpoint)
}
public static func fetch(
cookieHeader: String,
session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared,
endpoint: URL = Self.defaultEndpoint) async throws -> GrokWebBillingSnapshot
{
try await self.fetch(
cookieHeader: cookieHeader,
credentials: nil,
session: transport,
endpoint: endpoint)
}
public static func fetch(
cookieHeader: String,
credentials: GrokCredentials?,
session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared,
endpoint: URL = Self.defaultEndpoint) async throws -> GrokWebBillingSnapshot
{
let authorizationHeader = credentials.flatMap { credential in
credential.isExpired ? nil : "Bearer \(credential.accessToken)"
}
return try await self.fetch(
authorizationHeader: authorizationHeader,
cookieHeader: cookieHeader,
transport: transport,
endpoint: endpoint)
}
private static func fetch(
authorizationHeader: String?,
cookieHeader: String?,
transport: any ProviderHTTPTransport,
endpoint: URL) async throws -> GrokWebBillingSnapshot
{
do {
return try await self.fetchOnce(
authorizationHeader: authorizationHeader,
cookieHeader: cookieHeader,
transport: transport,
endpoint: endpoint)
} catch where self.shouldRetry(error) {
return try await self.fetchOnce(
authorizationHeader: authorizationHeader,
cookieHeader: cookieHeader,
transport: transport,
endpoint: endpoint)
}
}
private static func fetchOnce(
authorizationHeader: String?,
cookieHeader: String?,
transport: any ProviderHTTPTransport,
endpoint: URL) async throws -> GrokWebBillingSnapshot
{
var request = URLRequest(url: endpoint)
request.httpMethod = "POST"
request.timeoutInterval = Self.requestTimeoutSeconds
request.httpBody = Data([0x00, 0x00, 0x00, 0x00, 0x00])
if let authorizationHeader {
request.setValue(authorizationHeader, forHTTPHeaderField: "Authorization")
}
if let cookieHeader {
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
}
request.setValue("https://grok.com", forHTTPHeaderField: "Origin")
request.setValue("https://grok.com/?_s=usage", forHTTPHeaderField: "Referer")
request.setValue("*/*", forHTTPHeaderField: "Accept")
request.setValue("application/grpc-web+proto", forHTTPHeaderField: "Content-Type")
request.setValue("1", forHTTPHeaderField: "x-grpc-web")
request.setValue("connect-es/2.1.1", forHTTPHeaderField: "x-user-agent")
request.setValue("CodexBar", forHTTPHeaderField: "User-Agent")
let response: ProviderHTTPResponse
do {
response = try await transport.response(for: request)
} catch let error as URLError where error.code == .badServerResponse {
throw GrokWebBillingError.invalidResponse
} catch {
throw error
}
guard response.statusCode == 200 else {
let body = String(data: response.data.prefix(400), encoding: .utf8) ?? ""
throw GrokWebBillingError.requestFailed(response.statusCode, body)
}
try Self.validateGRPCStatusFields(Self.grpcHeaderFields(from: response.response.allHeaderFields))
try Self.validateGRPCWebTrailers(response.data)
return try Self.parseGRPCWebResponse(response.data)
}
private static func shouldRetry(_ error: Error) -> Bool {
if let urlError = error as? URLError {
return urlError.code == .timedOut || urlError.code == .networkConnectionLost
}
if case let GrokWebBillingError.requestFailed(status, body) = error {
if [408, 502, 503, 504].contains(status) { return true }
return body.localizedCaseInsensitiveContains("timeout")
|| body.localizedCaseInsensitiveContains("deadline")
}
guard case let GrokWebBillingError.rpcFailed(status, message) = error else { return false }
if status == 4 { return true }
guard status == 1 else { return false }
return message.localizedCaseInsensitiveContains("timeout")
|| message.localizedCaseInsensitiveContains("deadline")
|| message.localizedCaseInsensitiveContains("expired")
}
static func parseGRPCWebResponse(_ data: Data, now: Date = Date()) throws -> GrokWebBillingSnapshot {
var payloads = Self.grpcWebDataFrames(from: data)
if payloads.isEmpty, Self.looksLikeProtobufPayload(data) {
payloads = [data]
}
guard !payloads.isEmpty else { throw GrokWebBillingError.emptyResponse }
var scan = ProtobufScan()
for payload in payloads {
scan.merge(Self.scanProtobuf(payload, depth: 0))
}
let parsedPercent = 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 resetFields = scan.varintFields.compactMap { field -> (path: [UInt64], date: Date)? in
let raw = field.value
guard raw >= 1_700_000_000, raw <= 2_100_000_000 else { return nil }
return (field.path, Date(timeIntervalSince1970: TimeInterval(raw)))
}
let futureResetFields = resetFields.filter { $0.date > now }
let reset = futureResetFields
.filter { $0.path == [1, 5, 1] }
.map(\.date)
.min() ?? futureResetFields
.map(\.date)
.min()
let hasUsagePeriod = scan.varintFields.contains { field in
field.path.starts(with: [1, 6]) ||
(field.path == [1, 8, 1] && (field.value == 1 || field.value == 2))
}
let noUsageYet = parsedPercent == nil &&
scan.fixed32Fields.isEmpty &&
reset != nil &&
hasUsagePeriod
guard let percent = parsedPercent ?? (noUsageYet ? 0 : nil) else {
throw GrokWebBillingError.parseFailed
}
return GrokWebBillingSnapshot(usedPercent: percent, resetsAt: reset)
}
static func looksLikeProtobufPayload(_ data: Data) -> Bool {
guard let first = data.first else { return false }
let fieldNumber = first >> 3
let wireType = first & 0x07
return fieldNumber > 0 && (wireType == 0 || wireType == 1 || wireType == 2 || wireType == 5)
}
static func grpcWebDataFrames(from data: Data) -> [Data] {
let bytes = [UInt8](data)
var frames: [Data] = []
var index = 0
while index < bytes.count {
guard index + 5 <= bytes.count else { return [] }
let flags = bytes[index]
let length = (Int(bytes[index + 1]) << 24)
| (Int(bytes[index + 2]) << 16)
| (Int(bytes[index + 3]) << 8)
| Int(bytes[index + 4])
let start = index + 5
let end = start + length
guard length >= 0, end <= bytes.count else { return [] }
if flags & 0x80 == 0 {
frames.append(Data(bytes[start..<end]))
}
index = end
}
return frames
}
static func validateGRPCWebTrailers(_ data: Data) throws {
try self.validateGRPCStatusFields(self.grpcWebTrailerFields(from: data))
}
static func validateGRPCStatusFields(_ fields: [String: String]) throws {
guard let rawStatus = fields["grpc-status"],
let status = Int(rawStatus),
status != 0
else {
return
}
throw GrokWebBillingError.rpcFailed(status, fields["grpc-message"] ?? "")
}
static func grpcHeaderFields(from headers: [AnyHashable: Any]) -> [String: String] {
var fields: [String: String] = [:]
for (key, value) in headers {
let normalizedKey = String(describing: key)
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
guard normalizedKey.hasPrefix("grpc-") else { continue }
fields[normalizedKey] = String(describing: value)
.trimmingCharacters(in: .whitespacesAndNewlines)
.removingPercentEncoding ?? ""
}
return fields
}
static func grpcWebTrailerFields(from data: Data) -> [String: String] {
let bytes = [UInt8](data)
var fields: [String: String] = [:]
var index = 0
while index + 5 <= bytes.count {
let flags = bytes[index]
let length = (Int(bytes[index + 1]) << 24)
| (Int(bytes[index + 2]) << 16)
| (Int(bytes[index + 3]) << 8)
| Int(bytes[index + 4])
let start = index + 5
let end = start + length
guard length >= 0, end <= bytes.count else { break }
if flags & 0x80 != 0, let text = String(data: Data(bytes[start..<end]), encoding: .utf8) {
for line in text.components(separatedBy: .newlines) where !line.isEmpty {
guard let separator = line.firstIndex(of: ":") else { continue }
let key = line[..<separator]
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
let value = line[line.index(after: separator)...]
.trimmingCharacters(in: .whitespacesAndNewlines)
.removingPercentEncoding ?? ""
fields[key] = value
}
}
index = end
}
return fields
}
private struct ProtobufScan {
struct Fixed32Field {
var path: [UInt64]
var value: Float
var order: Int
}
struct VarintField {
var path: [UInt64]
var value: UInt64
}
var fixed32Fields: [Fixed32Field] = []
var varintFields: [VarintField] = []
mutating func merge(_ other: ProtobufScan) {
self.fixed32Fields.append(contentsOf: other.fixed32Fields)
self.varintFields.append(contentsOf: other.varintFields)
}
}
private static func scanProtobuf(_ data: Data, depth: Int) -> ProtobufScan {
self.scanProtobuf(data, depth: depth, path: [], order: 0).scan
}
private static func scanProtobuf(
_ data: Data,
depth: Int,
path: [UInt64],
order: Int) -> (scan: ProtobufScan, order: Int)
{
let bytes = [UInt8](data)
var scan = ProtobufScan()
var index = 0
var nextOrder = order
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
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 {
index = fieldStart + 1
}
case 1:
guard index + 8 <= bytes.count else { return (scan, nextOrder) }
index += 8
case 2:
guard let length = Self.readVarint(bytes, index: &index),
length <= UInt64(bytes.count - index)
else {
index = fieldStart + 1
continue
}
let start = index
let end = index + Int(length)
if depth < 4 {
let nested = Self.scanProtobuf(
Data(bytes[start..<end]),
depth: depth + 1,
path: fieldPath,
order: nextOrder)
scan.merge(nested.scan)
nextOrder = nested.order
}
index = end
case 5:
guard index + 4 <= bytes.count else { return (scan, nextOrder) }
let bitPattern = UInt32(bytes[index])
| (UInt32(bytes[index + 1]) << 8)
| (UInt32(bytes[index + 2]) << 16)
| (UInt32(bytes[index + 3]) << 24)
scan.fixed32Fields.append(ProtobufScan.Fixed32Field(
path: fieldPath,
value: Float(bitPattern: bitPattern),
order: nextOrder))
nextOrder += 1
index += 4
default:
index = fieldStart + 1
}
}
return (scan, nextOrder)
}
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
value |= UInt64(byte & 0x7F) << shift
if byte & 0x80 == 0 { return value }
shift += 7
}
return nil
}
}