diff --git a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift index 0c944e0f13..c608feffc1 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift @@ -38,10 +38,14 @@ public enum GrokProviderDescriptor { supportsTokenCost: false, noDataMessage: { "Grok cost summary is not supported yet." }), pace: ProviderPaceCapability(resetWindowPace: .custom { window, now in - guard Self.primaryLabel(window: window, now: now) == "Weekly", + guard let label = Self.primaryLabel(window: window, now: now), + label == "Weekly" || label == "Monthly", let resetsAt = window.resetsAt else { return false } - let windowMinutes = window.windowMinutes ?? 7 * 24 * 60 + // SuperGrok included usage is a weekly pool; monthly only appears when a measured + // billing-period length is present (legacy CLI / older payloads). + let defaultMinutes = label == "Weekly" ? 7 * 24 * 60 : 30 * 24 * 60 + let windowMinutes = window.windowMinutes ?? defaultMinutes let timeUntilReset = resetsAt.timeIntervalSince(now) return windowMinutes > 0 && timeUntilReset > 0 @@ -69,28 +73,41 @@ public enum GrokProviderDescriptor { } /// Returns a contextual label for Grok's primary usage bar ("Weekly" or "Monthly"). - /// Prefer the billing period duration when available; fall back to reset distance for - /// web billing payloads that expose only a reset timestamp. + /// Prefer a measured billing-period duration when available. For reset-only web payloads, + /// treat remaining time within a week-sized horizon as Weekly — SuperGrok's included usage + /// pool resets weekly, so late-cycle windows must not fall back to "Credits". public static func primaryLabel(window: RateWindow?, now: Date = .now) -> String? { if let minutes = window?.windowMinutes { - return self.primaryLabel(duration: TimeInterval(minutes) * 60) + return self.primaryLabel(duration: TimeInterval(minutes) * 60, fromMeasuredDuration: true) } return self.primaryLabel(resetsAt: window?.resetsAt, now: now) } public static func primaryLabel(resetsAt: Date?, now: Date = .now) -> String? { guard let resetsAt else { return nil } - return self.primaryLabel(duration: resetsAt.timeIntervalSince(now)) + return self.primaryLabel( + duration: resetsAt.timeIntervalSince(now), + fromMeasuredDuration: false) } - private static func primaryLabel(duration seconds: TimeInterval) -> String? { + private static func primaryLabel(duration seconds: TimeInterval, fromMeasuredDuration: Bool) -> String? { guard seconds > 3600 else { return nil } - let days = Int((seconds / 86400).rounded(.toNearestOrAwayFromZero)) - if (4...12).contains(days) { - return "Weekly" + if fromMeasuredDuration { + let days = Int((seconds / 86400).rounded(.toNearestOrAwayFromZero)) + if (4...12).contains(days) { + return "Weekly" + } + if (20...45).contains(days) { + return "Monthly" + } + return nil } - if (20...45).contains(days) { - return "Monthly" + // Reset-distance only: SuperGrok usage is weekly. Compare the remaining interval directly + // against a week-sized horizon so late-cycle partial-day remainders (1–12h) stay Weekly + // instead of rounding to 0 days and falling back to Credits. + let weekSizedHorizonSeconds: TimeInterval = 12 * 86400 + if seconds <= weekSizedHorizonSeconds { + return "Weekly" } return nil } diff --git a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift index a56f801a2a..f1a68f3e98 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift @@ -44,7 +44,7 @@ public struct GrokUsageSnapshot: Sendable { { primary = RateWindow( usedPercent: percent, - windowMinutes: nil, + windowMinutes: webBilling.windowMinutes, resetsAt: webBilling.resetsAt, resetDescription: nil) } diff --git a/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift b/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift index fef62a741a..87c7a1bee3 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift @@ -6,10 +6,14 @@ import FoundationNetworking public struct GrokWebBillingSnapshot: Sendable, Equatable { public let usedPercent: Double? public let resetsAt: Date? + /// Billing-period length in minutes when the protobuf exposes both period start and end. + /// Late-cycle web windows need this so Weekly/Monthly labeling and pace can still resolve. + public let windowMinutes: Int? - public init(usedPercent: Double?, resetsAt: Date?) { + public init(usedPercent: Double?, resetsAt: Date?, windowMinutes: Int? = nil) { self.usedPercent = usedPercent self.resetsAt = resetsAt + self.windowMinutes = windowMinutes } } @@ -233,18 +237,20 @@ public enum GrokWebBillingFetcher { } .map { Double($0.value) } - let resetFields = scan.varintFields.compactMap { field -> (path: [UInt64], date: Date)? in + let timestampFields = 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 futureResetFields = timestampFields.filter { $0.date > now } let reset = futureResetFields .filter { $0.path == [1, 5, 1] } .map(\.date) .min() ?? futureResetFields .map(\.date) .min() + let periodStart = Self.billingPeriodStart(from: timestampFields, resetsAt: reset, now: now) + let windowMinutes = Self.billingWindowMinutes(from: periodStart, to: reset) let hasUsagePeriod = scan.varintFields.contains { field in field.path.starts(with: [1, 6]) || @@ -257,7 +263,43 @@ public enum GrokWebBillingFetcher { guard let percent = parsedPercent ?? (noUsageYet ? 0 : nil) else { throw GrokWebBillingError.parseFailed } - return GrokWebBillingSnapshot(usedPercent: percent, resetsAt: reset) + return GrokWebBillingSnapshot( + usedPercent: percent, + resetsAt: reset, + windowMinutes: windowMinutes) + } + + /// Prefers the protobuf period-start siblings (`[1,4,1]` / `[1,8,2,1]`), then any past timestamp + /// that precedes the chosen reset. + static func billingPeriodStart( + from timestampFields: [(path: [UInt64], date: Date)], + resetsAt: Date?, + now: Date) -> Date? + { + guard let resetsAt else { return nil } + + let preferredStarts = timestampFields + .filter { field in + field.path == [1, 4, 1] || field.path == [1, 8, 2, 1] + } + .map(\.date) + .filter { $0 <= now && $0 < resetsAt } + if let preferred = preferredStarts.max() { + return preferred + } + + return timestampFields + .map(\.date) + .filter { $0 <= now && $0 < resetsAt } + .max() + } + + static func billingWindowMinutes(from start: Date?, to end: Date?) -> Int? { + guard let start, let end else { return nil } + let minutes = end.timeIntervalSince(start) / 60 + // Accept common Grok credit cycles (~daily through ~monthly); reject noise. + guard minutes.isFinite, minutes >= 24 * 60, minutes <= 45 * 24 * 60 else { return nil } + return Int(minutes.rounded()) } static func looksLikeProtobufPayload(_ data: Data) -> Bool { diff --git a/Tests/CodexBarTests/GrokMenuCardModelTests.swift b/Tests/CodexBarTests/GrokMenuCardModelTests.swift index 009ef45e8d..b52fb8e2c3 100644 --- a/Tests/CodexBarTests/GrokMenuCardModelTests.swift +++ b/Tests/CodexBarTests/GrokMenuCardModelTests.swift @@ -61,7 +61,7 @@ struct GrokMenuCardModelTests { } @Test - func `monthly quota does not show weekly projection`() throws { + func `monthly quota shows projection and pace marker`() throws { let now = Date(timeIntervalSince1970: 0) let model = try Self.model( now: now, @@ -73,20 +73,78 @@ struct GrokMenuCardModelTests { let metric = try #require(model.metrics.first { $0.id == "primary" }) #expect(metric.title == "Monthly") - #expect(metric.detailLeftText == nil) - #expect(metric.detailRightText == nil) - #expect(metric.pacePercent == nil) + #expect(metric.detailLeftText == "17% in deficit") + #expect(metric.detailRightText == "Runs out in 10d") + #expect(metric.pacePercent != nil) + #expect(metric.paceOnTop == false) } @Test - func `unclassified quota does not show weekly projection`() throws { + func `late cycle web weekly quota keeps label and projection`() throws { + let now = Date(timeIntervalSince1970: 0) + let model = try Self.model( + now: now, + window: RateWindow( + usedPercent: 37, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval((2 * 24 + 9) * 3600), + resetDescription: nil)) + + let metric = try #require(model.metrics.first { $0.id == "primary" }) + #expect(metric.title == "Weekly") + #expect(metric.detailLeftText == "29% in reserve") + #expect(metric.detailRightText == "Lasts until reset") + #expect(metric.pacePercent != nil) + #expect(metric.paceOnTop == true) + } + + @Test + func `late cycle web monthly quota keeps label and projection`() throws { + let now = Date(timeIntervalSince1970: 0) + let model = try Self.model( + now: now, + window: RateWindow( + usedPercent: 37, + windowMinutes: 30 * 24 * 60, + resetsAt: now.addingTimeInterval((2 * 24 + 9) * 3600), + resetDescription: nil)) + + let metric = try #require(model.metrics.first { $0.id == "primary" }) + #expect(metric.title == "Monthly") + #expect(metric.detailLeftText == "55% in reserve") + #expect(metric.detailRightText == "Lasts until reset") + #expect(metric.pacePercent != nil) + #expect(metric.paceOnTop == true) + } + + @Test + func `late cycle reset-only web quota defaults to weekly projection`() throws { + let now = Date(timeIntervalSince1970: 0) + let model = try Self.model( + now: now, + window: RateWindow( + usedPercent: 37, + windowMinutes: nil, + resetsAt: now.addingTimeInterval((2 * 24 + 9) * 3600), + resetDescription: nil)) + + let metric = try #require(model.metrics.first { $0.id == "primary" }) + #expect(metric.title == "Weekly") + #expect(metric.detailLeftText == "29% in reserve") + #expect(metric.detailRightText == "Lasts until reset") + #expect(metric.pacePercent != nil) + #expect(metric.paceOnTop == true) + } + + @Test + func `far reset without measured duration does not invent weekly projection`() throws { let now = Date(timeIntervalSince1970: 0) let model = try Self.model( now: now, window: RateWindow( usedPercent: 50, windowMinutes: nil, - resetsAt: now.addingTimeInterval(2 * 24 * 3600), + resetsAt: now.addingTimeInterval(20 * 24 * 3600), resetDescription: nil)) let metric = try #require(model.metrics.first { $0.id == "primary" }) diff --git a/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift b/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift index 73009016d2..2b7a1c55df 100644 --- a/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift +++ b/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift @@ -36,8 +36,9 @@ struct GrokWebBillingFetcherTests { } @Test - func `primaryLabel derives Weekly or Monthly from resetsAt`() { + func `primaryLabel prefers Weekly for reset-only SuperGrok usage windows`() { let now = Date() + let in2Days = now.addingTimeInterval(2 * 86400) let in6Days = now.addingTimeInterval(6 * 86400) let in30Days = now.addingTimeInterval(30 * 86400) let in90Days = now.addingTimeInterval(90 * 86400) @@ -46,11 +47,21 @@ struct GrokWebBillingFetcherTests { windowMinutes: 7 * 24 * 60, resetsAt: now.addingTimeInterval(86400), resetDescription: nil) + let measuredMonthlyWindow = RateWindow( + usedPercent: 25, + windowMinutes: 30 * 24 * 60, + resetsAt: now.addingTimeInterval(86400), + resetDescription: nil) + + let in6Hours = now.addingTimeInterval(6 * 3600) + #expect(GrokProviderDescriptor.primaryLabel(resetsAt: in2Days, now: now) == "Weekly") #expect(GrokProviderDescriptor.primaryLabel(resetsAt: in6Days, now: now) == "Weekly") - #expect(GrokProviderDescriptor.primaryLabel(resetsAt: in30Days, now: now) == "Monthly") + #expect(GrokProviderDescriptor.primaryLabel(resetsAt: in6Hours, now: now) == "Weekly") + #expect(GrokProviderDescriptor.primaryLabel(resetsAt: in30Days, now: now) == nil) #expect(GrokProviderDescriptor.primaryLabel(resetsAt: in90Days, now: now) == nil) #expect(GrokProviderDescriptor.primaryLabel(window: lateWeeklyWindow, now: now) == "Weekly") + #expect(GrokProviderDescriptor.primaryLabel(window: measuredMonthlyWindow, now: now) == "Monthly") #expect(GrokProviderDescriptor.primaryLabel(resetsAt: nil) == nil) } @@ -111,6 +122,7 @@ struct GrokWebBillingFetcherTests { #expect(snapshot.usedPercent == 42.5) #expect(snapshot.resetsAt == Date(timeIntervalSince1970: TimeInterval(reset))) + #expect(snapshot.windowMinutes == nil) } @Test @@ -122,10 +134,11 @@ struct GrokWebBillingFetcherTests { let snapshot = try GrokWebBillingFetcher.parseGRPCWebResponse( data, - now: Date(timeIntervalSince1970: 1_780_000_000)) + now: Date(timeIntervalSince1970: 1_781_000_000)) #expect(snapshot.usedPercent == 1.222000002861023) #expect(snapshot.resetsAt == Date(timeIntervalSince1970: 1_782_864_000)) + #expect(snapshot.windowMinutes == 30 * 24 * 60) } @Test @@ -563,10 +576,11 @@ struct GrokWebBillingFetcherTests { let snapshot = try GrokWebBillingFetcher.parseGRPCWebResponse( data, - now: Date(timeIntervalSince1970: 1_768_000_000)) + now: Date(timeIntervalSince1970: 1_778_500_000)) #expect(snapshot.usedPercent == 0) #expect(snapshot.resetsAt == Date(timeIntervalSince1970: 1_780_272_000)) + #expect(snapshot.windowMinutes == 31 * 24 * 60) } @Test @@ -589,6 +603,7 @@ struct GrokWebBillingFetcherTests { #expect(snapshot.usedPercent == 0) #expect(snapshot.resetsAt == Date(timeIntervalSince1970: 1_782_864_000)) + #expect(snapshot.windowMinutes == 30 * 24 * 60) } @Test @@ -626,6 +641,37 @@ struct GrokWebBillingFetcherTests { now: Date(timeIntervalSince1970: TimeInterval(recentStart + 1800))) #expect(snapshot.resetsAt == Date(timeIntervalSince1970: TimeInterval(billingEnd))) + #expect(snapshot.windowMinutes == Int((TimeInterval(billingEnd) - TimeInterval(recentStart)) / 60)) + } + + @Test + func `rejects future preferred period start when deriving window length`() throws { + let nowEpoch = UInt64(1_800_000_000) + let futureStart = nowEpoch + 86400 + let billingEnd = nowEpoch + 8 * 86400 + var payload = Data() + payload.append(0x0A) // field 1, length-delimited billing message + var inner = Data() + inner.append(0x0D) // field 1, fixed32 usage percent + var percentBits = Float(40).bitPattern.littleEndian + withUnsafeBytes(of: &percentBits) { inner.append(contentsOf: $0) } + inner.append(0x22) // field 4, nested period start + inner.append(0x06) + inner.append(0x08) // nested field 1 varint + inner.append(contentsOf: Self.varint(futureStart)) + inner.append(0x2A) // field 5, nested period end + inner.append(0x06) + inner.append(0x08) + inner.append(contentsOf: Self.varint(billingEnd)) + payload.append(UInt8(inner.count)) + payload.append(inner) + + let snapshot = try GrokWebBillingFetcher.parseGRPCWebResponse( + Self.grpcFrame(payload), + now: Date(timeIntervalSince1970: TimeInterval(nowEpoch))) + + #expect(snapshot.resetsAt == Date(timeIntervalSince1970: TimeInterval(billingEnd))) + #expect(snapshot.windowMinutes == nil) } @Test @@ -914,7 +960,8 @@ extension GrokWebBillingFetcherTests { billing: nil, webBilling: GrokWebBillingSnapshot( usedPercent: 67.25, - resetsAt: Date(timeIntervalSince1970: 1_800_000_003)), + resetsAt: Date(timeIntervalSince1970: 1_800_000_003), + windowMinutes: 7 * 24 * 60), credentials: Self.credentials, localSummary: nil, cliVersion: nil, @@ -923,7 +970,7 @@ extension GrokWebBillingFetcherTests { let usage = snapshot.toUsageSnapshot() #expect(usage.primary?.usedPercent == 67.25) - #expect(usage.primary?.windowMinutes == nil) + #expect(usage.primary?.windowMinutes == 7 * 24 * 60) #expect(usage.primary?.resetsAt == Date(timeIntervalSince1970: 1_800_000_003)) #expect(usage.accountEmail(for: .grok) == "grok@example.com") #expect(usage.loginMethod(for: .grok) == "SuperGrok") diff --git a/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift b/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift index 4d34c3175d..15a1373122 100644 --- a/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift +++ b/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift @@ -69,10 +69,12 @@ struct ProviderPaceCapabilityTests { case .cursor: return window.windowMinutes != nil case .grok: - guard GrokProviderDescriptor.primaryLabel(window: window, now: now) == "Weekly", + guard let label = GrokProviderDescriptor.primaryLabel(window: window, now: now), + label == "Weekly" || label == "Monthly", let resetsAt = window.resetsAt else { return false } - let windowMinutes = window.windowMinutes ?? self.weeklyWindowMinutes + let defaultMinutes = label == "Weekly" ? self.weeklyWindowMinutes : self.monthlyWindowSentinelMinutes + let windowMinutes = window.windowMinutes ?? defaultMinutes let timeUntilReset = resetsAt.timeIntervalSince(now) return windowMinutes > 0 && timeUntilReset > 0 diff --git a/TestsLinux/GrokWebBillingPaceLinuxTests.swift b/TestsLinux/GrokWebBillingPaceLinuxTests.swift new file mode 100644 index 0000000000..7ca88281b7 --- /dev/null +++ b/TestsLinux/GrokWebBillingPaceLinuxTests.swift @@ -0,0 +1,121 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct GrokWebBillingPaceLinuxTests { + @Test + func `web billing parses period length from start and end timestamps`() throws { + let hex = + "0a3f0d7f6a9c3f12001a002206088097f3d0062a060880b191d2063a07080215a9389b3f3a07080115d6ea183c" + + "421208011206088097f3d0061a060880b191d206" + let data = try #require(Self.data(hexString: hex)) + + let snapshot = try GrokWebBillingFetcher.parseGRPCWebResponse( + data, + now: Date(timeIntervalSince1970: 1_781_000_000)) + + #expect(snapshot.resetsAt == Date(timeIntervalSince1970: 1_782_864_000)) + #expect(snapshot.windowMinutes == 30 * 24 * 60) + } + + @Test + func `rejects future preferred period start when deriving window length`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let futureStart = now.addingTimeInterval(86400) + let resetsAt = now.addingTimeInterval(8 * 86400) + let start = GrokWebBillingFetcher.billingPeriodStart( + from: [ + (path: [1, 4, 1], date: futureStart), + (path: [1, 5, 1], date: resetsAt), + ], + resetsAt: resetsAt, + now: now) + + #expect(start == nil) + #expect(GrokWebBillingFetcher.billingWindowMinutes(from: start, to: resetsAt) == nil) + } + + @Test + func `late cycle monthly window supports reset pace when duration is known`() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 37, + windowMinutes: 30 * 24 * 60, + resetsAt: now.addingTimeInterval((2 * 24 + 9) * 3600), + resetDescription: nil) + let capability = GrokProviderDescriptor.descriptor.pace + + #expect(GrokProviderDescriptor.primaryLabel(window: window, now: now) == "Monthly") + #expect(capability.supportsResetWindowPace(window: window, now: now)) + } + + @Test + func `late cycle weekly window supports reset pace when duration is known`() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 37, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval((2 * 24 + 9) * 3600), + resetDescription: nil) + let capability = GrokProviderDescriptor.descriptor.pace + + #expect(GrokProviderDescriptor.primaryLabel(window: window, now: now) == "Weekly") + #expect(capability.supportsResetWindowPace(window: window, now: now)) + } + + @Test + func `late cycle reset-only window defaults to weekly pace`() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 37, + windowMinutes: nil, + resetsAt: now.addingTimeInterval((2 * 24 + 9) * 3600), + resetDescription: nil) + let capability = GrokProviderDescriptor.descriptor.pace + + #expect(GrokProviderDescriptor.primaryLabel(window: window, now: now) == "Weekly") + #expect(capability.supportsResetWindowPace(window: window, now: now)) + } + + @Test + func `far reset without measured duration still skips pace`() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 37, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(20 * 24 * 3600), + resetDescription: nil) + let capability = GrokProviderDescriptor.descriptor.pace + + #expect(GrokProviderDescriptor.primaryLabel(window: window, now: now) == nil) + #expect(!capability.supportsResetWindowPace(window: window, now: now)) + } + + @Test + func `final half-day reset-only window keeps weekly pace`() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 88, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(6 * 3600), + resetDescription: nil) + let capability = GrokProviderDescriptor.descriptor.pace + + #expect(GrokProviderDescriptor.primaryLabel(window: window, now: now) == "Weekly") + #expect(capability.supportsResetWindowPace(window: window, now: now)) + } + + private static func data(hexString: String) -> Data? { + var data = Data() + var index = hexString.startIndex + while index < hexString.endIndex { + let next = hexString.index(index, offsetBy: 2, limitedBy: hexString.endIndex) ?? hexString.endIndex + guard next > index, + let byte = UInt8(hexString[index..