Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.3.0</string>
<string>0.3.1</string>
<key>CFBundleVersion</key>
<string>10</string>
<string>11</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.developer-tools</string>
<key>LSMinimumSystemVersion</key>
Expand Down
10 changes: 6 additions & 4 deletions Sources/CodexLimits/CodexLimitsApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -219,11 +219,13 @@ struct CodexLimitsApp: App {
} ?? "unavailable"
return "\(integrations.menuBarMetric.displayName), \(value), \(freshness)"
case .grokCurrentPeriodUsageRemaining:
let value = grok.currentSnapshot.map {
$0.remainingPercent.formatted(.number.precision(.fractionLength(0 ... 2)))
+ " percent remaining, \($0.period.rawValue)"
let value = grok.currentSnapshot.flatMap { snapshot in
snapshot.remainingPercent.map {
$0.formatted(.number.precision(.fractionLength(0 ... 2)))
+ " percent remaining, \(snapshot.period.rawValue)"
}
} ?? "unavailable"
let freshness = grok.currentSnapshot == nil ? "unavailable" : (grok.isStale ? "stale" : "fresh")
let freshness = grok.currentSnapshot?.remainingPercent == nil ? "unavailable" : (grok.isStale ? "stale" : "fresh")
return "\(integrations.menuBarMetric.displayName), \(value), \(freshness)"
}
}
Expand Down
24 changes: 13 additions & 11 deletions Sources/CodexLimits/GrokBillingClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ enum GrokBillingError: Error, LocalizedError, Equatable {
case .notFound: "Grok Build could not be found."
case .authenticationRequired: "Sign in with grok login to read your usage."
case .unsupported: "This version of Grok Build does not support usage reads."
case .missingAllowance: "Grok has not provided an allowance for this account."
case .missingAllowance: "Grok did not provide usable usage data."
case .unknownPeriod: "Grok returned an unsupported usage period."
case .invalidReset: "Grok did not provide a valid reset date."
case .invalidResponse: "Grok returned usage data that could not be read."
Expand All @@ -24,7 +24,7 @@ enum GrokBillingError: Error, LocalizedError, Equatable {
struct GrokAllowanceSnapshot: Codable, Equatable, Sendable {
enum Period: String, Codable, Sendable { case weekly, monthly }

let reportedUsedPercent: Double
let reportedUsedPercent: Double?
let period: Period
let resetsAt: Date
let observedAt: Date
Expand All @@ -38,7 +38,7 @@ struct GrokAllowanceSnapshot: Codable, Equatable, Sendable {
let startsAt: Date?

init(
reportedUsedPercent: Double,
reportedUsedPercent: Double?,
period: Period,
resetsAt: Date,
observedAt: Date,
Expand All @@ -65,12 +65,12 @@ struct GrokAllowanceSnapshot: Codable, Equatable, Sendable {
self.startsAt = startsAt
}

var remainingPercent: Double {
100 - min(100, max(0, reportedUsedPercent))
var remainingPercent: Double? {
reportedUsedPercent.map { 100 - min(100, max(0, $0)) }
}

var isValid: Bool {
reportedUsedPercent.isFinite
(reportedUsedPercent.map(\.isFinite) ?? (measurementSource == "creditUsagePercent"))
&& Self.isSupportedDate(observedAt)
&& Self.isSupportedDate(resetsAt)
&& resetsAt.timeIntervalSince1970 > 0
Expand All @@ -96,17 +96,18 @@ struct GrokAllowanceSnapshot: Codable, Equatable, Sendable {
guard let config = result["config"] as? [String: Any] else {
throw GrokBillingError.missingAllowance
}
let used: Double
let used: Double?
let period: Period
let reset: Date
let start: Date?
let source: String
if config.keys.contains("creditUsagePercent")
|| config.keys.contains("currentPeriod") {
guard let value = number(config["creditUsagePercent"]) else {
// A period-only reply still carries current facts; absence is not a zero reading.
used = number(config["creditUsagePercent"])
guard !config.keys.contains("creditUsagePercent") || used != nil else {
throw GrokBillingError.missingAllowance
}
used = value
guard let current = config["currentPeriod"] as? [String: Any] else {
throw GrokBillingError.unknownPeriod
}
Expand All @@ -122,8 +123,9 @@ struct GrokAllowanceSnapshot: Codable, Equatable, Sendable {
let value = cents(config["used"]) else {
throw GrokBillingError.missingAllowance
}
used = value / limit * 100
guard used.isFinite else { throw GrokBillingError.invalidResponse }
let percent = value / limit * 100
guard percent.isFinite else { throw GrokBillingError.invalidResponse }
used = percent
period = .monthly
(start, reset) = try periodDates(
end: config["billingPeriodEnd"], start: config["billingPeriodStart"]
Expand Down
7 changes: 4 additions & 3 deletions Sources/CodexLimits/GrokIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -167,12 +167,13 @@ final class GrokIntegrationStore: ObservableObject {
}

var menuBarText: String {
currentSnapshot.map { "\(Int($0.remainingPercent.rounded()))%" } ?? "—"
currentSnapshot?.remainingPercent.map { "\(Int($0.rounded()))%" } ?? "—"
}

var statusText: String {
if isRefreshing { return "Checking" }
if let error { return error.localizedDescription }
if let currentSnapshot, currentSnapshot.remainingPercent == nil { return "Usage percentage unavailable" }
if snapshot != nil, currentSnapshot == nil { return "New usage observation needed" }
return snapshot == nil ? "Ready to check" : (isStale ? "Stale" : "Ready")
}
Expand Down Expand Up @@ -351,8 +352,8 @@ final class GrokIntegrationStore: ObservableObject {
self.historyLoaded = true
}
} else if self.historyVisible {
let observation = snapshot.historyObservation
if observation.isValid, !self.history.contains(observation) {
if let observation = snapshot.historyObservation,
observation.isValid, !self.history.contains(observation) {
self.history.append(observation)
}
}
Expand Down
9 changes: 6 additions & 3 deletions Sources/CodexLimits/IntegrationAllowanceChart.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import ClaudeIntegrationCore
import Foundation

extension GrokAllowanceSnapshot {
var historyObservation: AllowanceObservation {
AllowanceObservation(
metric: period == .weekly ? "grok-weekly" : "grok-monthly",
var historyMetric: String { period == .weekly ? "grok-weekly" : "grok-monthly" }

var historyObservation: AllowanceObservation? {
guard let remainingPercent else { return nil }
return AllowanceObservation(
metric: historyMetric,
observedAt: observedAt,
remainingPercent: remainingPercent,
resetsAt: resetsAt,
Expand Down
16 changes: 11 additions & 5 deletions Sources/CodexLimits/MenuContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,8 @@ struct MenuContentView: View {

private var grokOverviewPrimary: String {
if let snapshot = grok.currentSnapshot {
return "Usage remaining · \(Int(snapshot.remainingPercent.rounded()))%"
return snapshot.remainingPercent.map { "Usage remaining · \(Int($0.rounded()))%" }
?? "Usage percentage unavailable"
}
if grok.snapshot != nil { return "New usage observation needed" }
return grok.isRefreshing ? "Checking" : "Usage is not available"
Expand All @@ -347,17 +348,21 @@ struct MenuContentView: View {
Text("Shared across Grok products.")
.font(.callout).foregroundStyle(.secondary)
}
if snapshot.remainingPercent == nil {
Text("Grok hasn’t reported a usage percentage for this period.")
.font(.callout).foregroundStyle(.secondary)
}
}
IntegrationUsageRemainingView(
title: "Usage remaining",
metric: grok.snapshot?.historyObservation.metric ?? grok.history.last?.metric ?? "grok-weekly",
metric: grok.snapshot?.historyMetric ?? grok.history.last?.metric ?? "grok-weekly",
observations: grok.history,
current: grok.snapshot?.historyObservation,
now: grok.displayNow,
isStale: grok.isStale,
defaults: chartDefaults
)
.id(grok.snapshot?.historyObservation.metric ?? grok.history.last?.metric)
.id(grok.snapshot?.historyMetric ?? grok.history.last?.metric)
if let snapshot = grok.snapshot {
if let plan = snapshot.subscriptionTier {
LabeledContent("Plan", value: plan)
Expand All @@ -373,7 +378,7 @@ struct MenuContentView: View {
}
Divider()
VStack(alignment: .leading, spacing: 6) {
Text("Last checked \(snapshot.observedAt.formatted(.relative(presentation: .named)))")
Text("Last received \(snapshot.observedAt.formatted(.relative(presentation: .named)))")
if grok.isStale {
Label("Stale", systemImage: "clock.badge.exclamationmark")
}
Expand Down Expand Up @@ -2721,7 +2726,8 @@ private struct IntegrationUsageRemainingView: View {
Spacer()
Picker("Range", selection: Binding(get: { store.state.timeRange }, set: store.selectTimeRange)) {
ForEach(AnalyticsTimeRange.allCases.filter { $0.isPreset || store.state.timeRange == .selected }) {
Text($0.rawValue).tag($0)
Text($0 == .currentWindow && (current?.resetsAt ?? .distantPast) <= now
? "Last recorded" : $0.rawValue).tag($0)
}
}
.frame(maxWidth: 200)
Expand Down
14 changes: 14 additions & 0 deletions Tests/CodexLimitsTests/GrokBillingClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ final class GrokBillingClientTests: XCTestCase {
private let observedAt = Date(timeIntervalSince1970: 1_700_000_000)
private let current = #"{"config":{"creditUsagePercent":25.5,"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","start":"2030-01-01T00:00:00+00:00","end":"2030-01-08T00:00:00.123456+00:00"},"prepaidBalance":{},"onDemandUsed":{"val":125},"onDemandCap":{"val":2500},"isUnifiedBillingUser":true},"subscription_tier":"Super\u0007Grok","private":"do-not-retain"}"#

func testPeriodWithoutPercentageRetainsFactsWithoutInventingUsage() throws {
let partial = try decode(current.replacingOccurrences(of: #""creditUsagePercent":25.5,"#, with: ""))
XCTAssertNil(partial.reportedUsedPercent)
XCTAssertNil(partial.remainingPercent)
XCTAssertNil(partial.historyObservation)
XCTAssertTrue(partial.isValid)
XCTAssertEqual(partial.period, .weekly)
XCTAssertEqual(partial.resetsAt.timeIntervalSince1970, 1_894_060_800.123456, accuracy: 0.001)
XCTAssertEqual(partial.subscriptionTier, "SuperGrok")
XCTAssertEqual(partial.prepaidBalanceUSD, 0)
XCTAssertEqual(try JSONDecoder().decode(GrokAllowanceSnapshot.self, from: JSONEncoder().encode(partial)), partial)
XCTAssertEqual(try decode(current.replacingOccurrences(of: "25.5", with: "0")).remainingPercent, 100)
}

func testCurrentAndLegacyAllowancesKeepOnlyValidatedFacts() throws {
let decoded = try decode(current)
XCTAssertEqual(decoded.remainingPercent, 74.5)
Expand Down
65 changes: 59 additions & 6 deletions Tests/CodexLimitsTests/GrokIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,71 @@ import ClaudeIntegrationCore

@MainActor
final class GrokIntegrationTests: XCTestCase {
func testPeriodOnlyRefreshAfterResetSurvivesRelaunchAndRecoversWithoutInventedHistory() async throws {
let clock = GrokTestClock()
let first = fixture(at: clock.now())
let source = GrokFetchProbe(snapshot: first)
let cache = temporaryDirectory().appendingPathComponent("snapshot.json")
let store = makeStore(clock: clock, source: source, cache: cache)
await store.setVisible(true)
let originalHistory = store.history
clock.advance(3_601)
let formatter = ISO8601DateFormatter()
let reset = clock.now().addingTimeInterval(7 * 86_400)
let reply = """
{"config":{"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY",\
"start":"\(formatter.string(from: clock.now()))","end":"\(formatter.string(from: reset))"},\
"prepaidBalance":{}},"subscription_tier":"Example plan"}
"""
let partial = try GrokAllowanceSnapshot.decode(Data(reply.utf8), observedAt: clock.now(), sourceVersion: "1.2.3")
await source.setSnapshot(partial)
await store.refresh()
XCTAssertNil(store.error)
XCTAssertEqual(store.currentSnapshot?.resetsAt, reset)
XCTAssertEqual(store.snapshot?.observedAt, clock.now())
XCTAssertEqual(store.snapshot?.subscriptionTier, "Example plan")
XCTAssertEqual(store.statusText, "Usage percentage unavailable")
XCTAssertEqual(store.menuBarText, "—")
XCTAssertEqual(store.history, originalHistory)
await store.setVisible(true, includeHistory: false)
XCTAssertNil(store.overview, "An old period must not remain the current thumbnail")
await store.setEnabled(false)

let restored = makeStore(clock: clock, source: source, cache: cache)
await restored.setVisible(true)
XCTAssertEqual(restored.snapshot, partial)
XCTAssertEqual(restored.history, originalHistory)
XCTAssertNil(restored.currentSnapshot?.remainingPercent)

clock.advance(600)
let zeroReply = reply.replacingOccurrences(of: #""config":{"#, with: #""config":{"creditUsagePercent":0,"#)
let zero = try GrokAllowanceSnapshot.decode(Data(zeroReply.utf8), observedAt: clock.now(), sourceVersion: "1.2.3")
await source.setSnapshot(zero)
await restored.refresh()
XCTAssertNil(restored.error)
XCTAssertEqual(restored.menuBarText, "100%")
XCTAssertEqual(restored.history, originalHistory + [try XCTUnwrap(zero.historyObservation)])
let chart = try XCTUnwrap(IntegrationAllowanceChart(
metric: zero.historyMetric, observations: restored.history, current: zero.historyObservation,
now: clock.now(), isStale: restored.isStale, safetyBuffer: 3
))
XCTAssertTrue(chart.chart.currentProjection.isEmpty, "An estimate cannot cross the reset or missing reading")
await restored.deleteData()
}

func testOverviewReadsCurrentPeriodWithoutRetainingDetailAndFallsBackToLatest() async throws {
let clock = GrokTestClock()
let current = fixture(at: clock.now())
let currentObservation = try XCTUnwrap(current.historyObservation)
let source = GrokFetchProbe(snapshot: current)
let cache = temporaryDirectory().appendingPathComponent("snapshot.json")
let historyDirectory = cache.deletingLastPathComponent().appendingPathComponent("History")
let previous = AllowanceObservation(
metric: current.historyObservation.metric, observedAt: current.observedAt.addingTimeInterval(-600),
metric: currentObservation.metric, observedAt: current.observedAt.addingTimeInterval(-600),
remainingPercent: 80, resetsAt: current.resetsAt,
startsAt: current.historyObservation.startsAt, source: current.measurementSource
startsAt: currentObservation.startsAt, source: current.measurementSource
)
let old = fixture(at: clock.now().addingTimeInterval(-20 * 86_400)).historyObservation
let old = try XCTUnwrap(fixture(at: clock.now().addingTimeInterval(-20 * 86_400)).historyObservation)
try AllowanceHistory.append([old, previous], in: historyDirectory)
try JSONEncoder().encode(current).write(to: cache)
let files = try FileManager.default.contentsOfDirectory(at: historyDirectory, includingPropertiesForKeys: nil)
Expand Down Expand Up @@ -83,16 +136,16 @@ final class GrokIntegrationTests: XCTestCase {
try JSONEncoder().encode(first).write(to: cache)
let store = makeStore(clock: clock, source: source, cache: cache)
await store.setVisible(true)
XCTAssertEqual(store.history, [first.historyObservation], "Migrate one real cached observation only")
XCTAssertEqual(store.history, [try XCTUnwrap(first.historyObservation)], "Migrate one real cached observation only")
clock.advance(600)
let second = fixture(at: clock.now())
await source.setSnapshot(second)
await store.refresh()
XCTAssertEqual(store.history, [first.historyObservation, second.historyObservation])
XCTAssertEqual(store.history, [first, second].compactMap(\.historyObservation))
await store.setEnabled(false)
let restored = makeStore(clock: clock, source: source, cache: cache)
await restored.setVisible(true)
XCTAssertEqual(restored.history, [first.historyObservation, second.historyObservation])
XCTAssertEqual(restored.history, [first, second].compactMap(\.historyObservation))
await restored.setVisible(false)
XCTAssertTrue(restored.history.isEmpty, "Hidden detail releases resident history")
await restored.setVisible(true)
Expand Down
1 change: 1 addition & 0 deletions docs/MEASUREMENT-CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ An Integration may publish Account Allowance only when the source identifies the
- A percentage must be finite and source-valid. Missing, null, malformed, or semantically unknown values are unavailable, never zero.
- Claude Code `used_percentage` is valid only inside `0...100`; convert it to `remaining = 100 - used`. Reject an out-of-range value.
- Grok `creditUsagePercent` is a used percentage. Preserve the finite original as `reportedUsedPercent`, apply the official `0...100` clamp, and convert it to remaining. Record `measurementSource` as `creditUsagePercent` or `legacyCredits`, with the CLI version when available. Use legacy `100 * used.val / monthlyLimit.val` only when both current fields are absent; a zero or missing legacy limit is unavailable. A present empty Cent object means zero, while a missing monetary field remains unavailable. The collector sends `_x.ai/billing` on the ACP wire; the internal handler name omits the underscore.
- A valid Grok `currentPeriod` without `creditUsagePercent` is a successful partial observation: retain its period, reset, observation time, and available account facts, but leave the percentage unavailable. It replaces the latest snapshot without adding an allowance-history point or forecast. Never substitute zero, a previous period’s percentage, or legacy credits for the omitted value. A present null or malformed percentage remains an invalid response. Historic charts identify their range as the last recorded window when no current allowance observation exists; `Last received` dates the latest accepted response rather than the latest refresh attempt.
- A direct remaining percentage is validated without changing its orientation.
- Every allowance window carries its provider window ID or period type, observed time, reset time, and duration when known.
- Weekly and monthly Grok periods remain distinct. Retain a supplied `currentPeriod.start` or legacy `billingPeriodStart` only after supported finite-date and start-before-reset validation. A missing start remains absent; never invent a monthly start from a fixed duration. An unknown period type is unavailable rather than relabeled as weekly.
Expand Down
Loading