mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-26 00:29:38 +00:00
An omitted creditUsagePercent inside an open billing window was read as 0% for every Grok account. That contract only holds for unified-billing users; for anyone else it now stays unknown instead of showing a fabricated 0%. The wireframe mockup still carried the one device name #1250 replaced everywhere else.
398 lines
15 KiB
Swift
398 lines
15 KiB
Swift
import Foundation
|
|
|
|
/// Read-only access to the login that Grok Build owns in `$GROK_HOME/auth.json`.
|
|
/// CodeBurn keeps the bearer in memory only and never copies it into Keychain,
|
|
/// UserDefaults, logs, or its support directory.
|
|
enum GrokBuildCredentialStore {
|
|
struct Credential: Equatable, Sendable {
|
|
let accessToken: String
|
|
let authMode: String?
|
|
let expiresAt: Date?
|
|
|
|
func isExpired(at date: Date) -> Bool {
|
|
guard let expiresAt else { return false }
|
|
return expiresAt <= date
|
|
}
|
|
}
|
|
|
|
enum ReadError: Error, Equatable {
|
|
case malformed
|
|
case missingToken
|
|
}
|
|
|
|
private struct Entry: Decodable {
|
|
let key: String?
|
|
let authMode: String?
|
|
let expiresAt: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case key
|
|
case authMode = "auth_mode"
|
|
case expiresAt = "expires_at"
|
|
}
|
|
}
|
|
|
|
static func load(
|
|
environment: [String: String] = ProcessInfo.processInfo.environment,
|
|
home: URL = FileManager.default.homeDirectoryForCurrentUser
|
|
) throws -> Credential? {
|
|
let grokHome: URL
|
|
if let configured = cleaned(environment["GROK_HOME"]) {
|
|
grokHome = URL(
|
|
fileURLWithPath: (configured as NSString).expandingTildeInPath,
|
|
isDirectory: true
|
|
)
|
|
} else {
|
|
grokHome = home.appendingPathComponent(".grok", isDirectory: true)
|
|
}
|
|
let authURL = grokHome.appendingPathComponent("auth.json", isDirectory: false)
|
|
guard FileManager.default.fileExists(atPath: authURL.path) else { return nil }
|
|
return try decode(SafeFile.read(from: authURL.path, maxBytes: 128 * 1024))
|
|
}
|
|
|
|
static func decode(_ data: Data) throws -> Credential {
|
|
let entries: [String: Entry]
|
|
do {
|
|
entries = try JSONDecoder().decode([String: Entry].self, from: data)
|
|
} catch {
|
|
throw ReadError.malformed
|
|
}
|
|
|
|
let usable = entries.compactMap { scope, entry -> (scope: String, entry: Entry, token: String)? in
|
|
guard let token = cleaned(entry.key) else { return nil }
|
|
return (scope, entry, token)
|
|
}
|
|
let selected = usable.sorted { lhs, rhs in
|
|
let lhsRank = credentialRank(scope: lhs.scope)
|
|
let rhsRank = credentialRank(scope: rhs.scope)
|
|
return lhsRank == rhsRank ? lhs.scope < rhs.scope : lhsRank < rhsRank
|
|
}.first
|
|
guard let selected else { throw ReadError.missingToken }
|
|
|
|
return Credential(
|
|
accessToken: selected.token,
|
|
authMode: cleaned(selected.entry.authMode),
|
|
expiresAt: parseDate(selected.entry.expiresAt)
|
|
)
|
|
}
|
|
|
|
private static func credentialRank(scope: String) -> Int {
|
|
if scope.hasPrefix("https://auth.x.ai::") { return 0 }
|
|
if scope.contains("/sign-in") { return 1 }
|
|
return 2
|
|
}
|
|
|
|
private static func cleaned(_ raw: String?) -> String? {
|
|
guard let value = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!value.isEmpty else { return nil }
|
|
return value
|
|
}
|
|
|
|
private static func parseDate(_ raw: String?) -> Date? {
|
|
guard let raw = cleaned(raw) else { return nil }
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
if let date = formatter.date(from: raw) { return date }
|
|
formatter.formatOptions = [.withInternetDateTime]
|
|
return formatter.date(from: raw)
|
|
}
|
|
}
|
|
|
|
/// Live Grok Build subscription usage obtained with the OAuth login already
|
|
/// managed by the Grok CLI. Requests use an ephemeral, cookie-free session and
|
|
/// the bearer is released after each refresh.
|
|
enum GrokBuildSubscriptionService {
|
|
static let billingURL = URL(string: "https://cli-chat-proxy.grok.com/v1/billing?format=credits")!
|
|
static let settingsURL = URL(string: "https://cli-chat-proxy.grok.com/v1/settings")!
|
|
private static let timeoutSeconds: TimeInterval = 15
|
|
|
|
enum FetchError: Error, Equatable, LocalizedError, Sendable {
|
|
case noCredentials
|
|
case expiredSession
|
|
case credentialDataUnreadable
|
|
case authenticationRejected
|
|
case rateLimited
|
|
case providerUnavailable
|
|
case parseFailure
|
|
case network
|
|
|
|
enum Classification: Equatable, Sendable {
|
|
case terminalAuth
|
|
case transient
|
|
case parseFailure
|
|
}
|
|
|
|
var classification: Classification {
|
|
switch self {
|
|
case .noCredentials, .expiredSession, .credentialDataUnreadable, .authenticationRejected:
|
|
.terminalAuth
|
|
case .rateLimited, .providerUnavailable, .network:
|
|
.transient
|
|
case .parseFailure:
|
|
.parseFailure
|
|
}
|
|
}
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .noCredentials:
|
|
"Run `grok login`, then click Retry."
|
|
case .expiredSession:
|
|
"The Grok Build login has expired. Run `grok login`, then click Retry."
|
|
case .credentialDataUnreadable:
|
|
"Could not read Grok Build's local login. Run `grok login` again, then click Retry."
|
|
case .authenticationRejected:
|
|
"Grok rejected the current Grok Build login. Run `grok login`, then click Retry."
|
|
case .rateLimited:
|
|
"Grok rate-limited the quota request."
|
|
case .providerUnavailable:
|
|
"Grok quota is temporarily unavailable."
|
|
case .parseFailure:
|
|
"Grok returned an unrecognized quota response."
|
|
case .network:
|
|
"Network error fetching Grok quota."
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Deps: Sendable {
|
|
var loadCredential: @Sendable () throws -> GrokBuildCredentialStore.Credential?
|
|
var fetch: @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse)
|
|
|
|
static let live = Deps(
|
|
loadCredential: { try GrokBuildCredentialStore.load() },
|
|
fetch: { request in
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.httpCookieStorage = nil
|
|
configuration.httpShouldSetCookies = false
|
|
configuration.urlCache = nil
|
|
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
|
let session = URLSession(configuration: configuration)
|
|
defer { session.invalidateAndCancel() }
|
|
let (data, response) = try await session.data(for: request)
|
|
guard let http = response as? HTTPURLResponse else { throw FetchError.network }
|
|
return (data, http)
|
|
}
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
static func refresh(deps: Deps = .live, now: Date = Date()) async throws -> QuotaSummary {
|
|
let loadCredential = deps.loadCredential
|
|
let credential: GrokBuildCredentialStore.Credential
|
|
do {
|
|
guard let loaded = try await Task.detached(operation: loadCredential).value else {
|
|
throw FetchError.noCredentials
|
|
}
|
|
credential = loaded
|
|
} catch let error as FetchError {
|
|
throw error
|
|
} catch let error as GrokBuildCredentialStore.ReadError {
|
|
switch error {
|
|
case .missingToken: throw FetchError.noCredentials
|
|
case .malformed: throw FetchError.credentialDataUnreadable
|
|
}
|
|
} catch {
|
|
throw FetchError.credentialDataUnreadable
|
|
}
|
|
guard !credential.isExpired(at: now) else { throw FetchError.expiredSession }
|
|
|
|
let billingData = try await fetchRequired(
|
|
url: billingURL,
|
|
credential: credential,
|
|
deps: deps
|
|
)
|
|
let billing = try decodeBilling(billingData, now: now)
|
|
let settingsPlan = try await fetchPlan(credential: credential, deps: deps)
|
|
let plan = normalizedPlan(settingsPlan) ?? normalizedPlan(billing.subscriptionTier)
|
|
let window = billing.usedPercent.map { percent in
|
|
QuotaSummary.Window(
|
|
label: windowLabel(resetsAt: billing.resetsAt, now: now),
|
|
percent: percent,
|
|
resetsAt: billing.resetsAt
|
|
)
|
|
}
|
|
return QuotaSummary(
|
|
providerFilter: .grok,
|
|
connection: .connected,
|
|
primary: window,
|
|
details: window.map { [$0] } ?? [],
|
|
planLabel: plan,
|
|
footerLines: ["Source: Grok Build"]
|
|
)
|
|
}
|
|
|
|
private struct BillingSnapshot {
|
|
let usedPercent: Double?
|
|
let resetsAt: Date?
|
|
let subscriptionTier: String?
|
|
}
|
|
|
|
private struct BillingEnvelope: Decodable {
|
|
let config: BillingConfig?
|
|
let subscriptionTier: String?
|
|
}
|
|
|
|
private struct BillingConfig: Decodable {
|
|
let creditUsagePercent: Double?
|
|
let currentPeriod: CurrentPeriod?
|
|
let billingPeriodEnd: String?
|
|
let onDemandCap: Amount?
|
|
let onDemandUsed: Amount?
|
|
let used: Amount?
|
|
let monthlyLimit: Amount?
|
|
let subscriptionTier: String?
|
|
let isUnifiedBillingUser: Bool?
|
|
}
|
|
|
|
private struct CurrentPeriod: Decodable {
|
|
let start: String?
|
|
let end: String?
|
|
}
|
|
private struct Amount: Decodable { let val: Double? }
|
|
private struct SettingsEnvelope: Decodable {
|
|
let subscriptionTierDisplay: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case subscriptionTierDisplay = "subscription_tier_display"
|
|
}
|
|
}
|
|
|
|
private static func decodeBilling(_ data: Data, now: Date) throws -> BillingSnapshot {
|
|
let envelope: BillingEnvelope
|
|
do {
|
|
envelope = try JSONDecoder().decode(BillingEnvelope.self, from: data)
|
|
} catch {
|
|
throw FetchError.parseFailure
|
|
}
|
|
guard let config = envelope.config else { throw FetchError.parseFailure }
|
|
|
|
let rawPercent: Double?
|
|
if let percent = config.creditUsagePercent, percent.isFinite {
|
|
rawPercent = percent
|
|
} else if let used = config.onDemandUsed?.val,
|
|
let cap = config.onDemandCap?.val,
|
|
used.isFinite, cap.isFinite, used >= 0, cap > 0 {
|
|
rawPercent = used / cap * 100
|
|
} else if let used = config.used?.val,
|
|
let cap = config.monthlyLimit?.val,
|
|
used.isFinite, cap.isFinite, used >= 0, cap > 0 {
|
|
rawPercent = used / cap * 100
|
|
} else if config.isUnifiedBillingUser == true,
|
|
isActiveBillingPeriod(config.currentPeriod, now: now) {
|
|
// Grok's web client and proto3 both treat an omitted
|
|
// creditUsagePercent as 0 during an active window. After a weekly
|
|
// reset the field is simply absent — that is 0% used, not unknown.
|
|
// Only unified-billing accounts carry that contract; for anyone
|
|
// else an omitted field stays unknown rather than a fabricated 0%.
|
|
rawPercent = 0
|
|
} else {
|
|
rawPercent = nil
|
|
}
|
|
|
|
let reset = parseDate(config.currentPeriod?.end ?? config.billingPeriodEnd)
|
|
if rawPercent == nil, reset == nil, config.isUnifiedBillingUser != true {
|
|
throw FetchError.parseFailure
|
|
}
|
|
|
|
return BillingSnapshot(
|
|
usedPercent: rawPercent.map { min(1, max(0, $0 / 100)) },
|
|
resetsAt: reset,
|
|
subscriptionTier: config.subscriptionTier ?? envelope.subscriptionTier
|
|
)
|
|
}
|
|
|
|
private static func isActiveBillingPeriod(_ period: CurrentPeriod?, now: Date) -> Bool {
|
|
guard let end = parseDate(period?.end), end > now else { return false }
|
|
if let start = parseDate(period?.start) {
|
|
return start <= now
|
|
}
|
|
return true
|
|
}
|
|
|
|
private static func fetchRequired(
|
|
url: URL,
|
|
credential: GrokBuildCredentialStore.Credential,
|
|
deps: Deps
|
|
) async throws -> Data {
|
|
let request = authorizedRequest(url: url, credential: credential)
|
|
let data: Data
|
|
let response: HTTPURLResponse
|
|
do {
|
|
(data, response) = try await deps.fetch(request)
|
|
} catch let error as FetchError {
|
|
throw error
|
|
} catch is CancellationError {
|
|
throw CancellationError()
|
|
} catch {
|
|
throw FetchError.network
|
|
}
|
|
switch response.statusCode {
|
|
case 200: return data
|
|
case 401, 403: throw FetchError.authenticationRejected
|
|
case 429: throw FetchError.rateLimited
|
|
case 500...599: throw FetchError.providerUnavailable
|
|
default: throw FetchError.parseFailure
|
|
}
|
|
}
|
|
|
|
private static func fetchPlan(
|
|
credential: GrokBuildCredentialStore.Credential,
|
|
deps: Deps
|
|
) async throws -> String? {
|
|
let request = authorizedRequest(url: settingsURL, credential: credential)
|
|
let data: Data
|
|
let response: HTTPURLResponse
|
|
do {
|
|
(data, response) = try await deps.fetch(request)
|
|
} catch is CancellationError {
|
|
throw CancellationError()
|
|
} catch {
|
|
return nil
|
|
}
|
|
guard response.statusCode == 200,
|
|
let settings = try? JSONDecoder().decode(SettingsEnvelope.self, from: data) else { return nil }
|
|
return settings.subscriptionTierDisplay
|
|
}
|
|
|
|
private static func authorizedRequest(
|
|
url: URL,
|
|
credential: GrokBuildCredentialStore.Credential
|
|
) -> URLRequest {
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "GET"
|
|
request.timeoutInterval = timeoutSeconds
|
|
request.setValue("Bearer \(credential.accessToken)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("xai-grok-cli", forHTTPHeaderField: "x-xai-token-auth")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.setValue("CodeBurn", forHTTPHeaderField: "User-Agent")
|
|
return request
|
|
}
|
|
|
|
private static func parseDate(_ raw: String?) -> Date? {
|
|
guard let raw, !raw.isEmpty else { return nil }
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
if let date = formatter.date(from: raw) { return date }
|
|
formatter.formatOptions = [.withInternetDateTime]
|
|
return formatter.date(from: raw)
|
|
}
|
|
|
|
private static func normalizedPlan(_ raw: String?) -> String? {
|
|
guard let value = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!value.isEmpty else { return nil }
|
|
switch value.lowercased().filter(\.isLetter) {
|
|
case "supergrokheavy", "heavy": return "SuperGrok Heavy"
|
|
case "supergrok": return "SuperGrok"
|
|
default: return value
|
|
}
|
|
}
|
|
|
|
private static func windowLabel(resetsAt: Date?, now: Date) -> String {
|
|
guard let resetsAt else { return "Credits" }
|
|
let days = Int((resetsAt.timeIntervalSince(now) / 86_400).rounded())
|
|
if (4...12).contains(days) { return "Weekly" }
|
|
if (20...45).contains(days) { return "Monthly" }
|
|
return "Credits"
|
|
}
|
|
}
|