diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift b/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift index e83bd32e..0eea71f2 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift @@ -6,6 +6,7 @@ import Foundation /// failures to the UI. enum CodexSubscriptionService { private static let usageURL = URL(string: "https://chatgpt.com/backend-api/wham/usage")! + private static let resetCreditsURL = URL(string: "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits")! private static let usageBlockedUntilKey = "codeburn.codex.usage.blockedUntil" enum FetchError: Error, LocalizedError { @@ -110,8 +111,12 @@ enum CodexSubscriptionService { switch http.statusCode { case 200: clearUsageBlock() + // Companion fetch, strictly best-effort: any failure yields nil and + // the Plan view simply omits the row. This endpoint must never be + // able to break the quota display. + let resetCredits = await fetchResetCredits(token: token) do { - return try decodeUsage(data: data) + return try decodeUsage(data: data, resetCredits: resetCredits) } catch { // Do not log the response body โ€” it's user-account data from // chatgpt.com and is readable by other local users via @@ -178,7 +183,63 @@ enum CodexSubscriptionService { } } - private static func decodeUsage(data: Data) throws -> CodexUsage { + private static func fetchResetCredits(token: String) async -> CodexUsage.ResetCredits? { + var request = URLRequest(url: resetCreditsURL) + request.httpMethod = "GET" + request.timeoutInterval = 4 + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("CodeBurn", forHTTPHeaderField: "User-Agent") + // The Codex desktop clients send this beta gate header on this + // endpoint; keep parity so chatgpt.com routes us the same response + // shape. If the endpoint ever rejects us anyway, the row just hides. + request.setValue("codex-1", forHTTPHeaderField: "OpenAI-Beta") + if let accountId = try? CodexCredentialStore.currentRecord()?.accountId, !accountId.isEmpty { + request.setValue(accountId, forHTTPHeaderField: "ChatGPT-Account-Id") + } + guard let (data, response) = try? await URLSession.shared.data(for: request), + let http = response as? HTTPURLResponse, http.statusCode == 200 else { + return nil + } + return parseResetCredits(data: data) + } + + /// Internal (not private) so tests can drive it with fixture payloads. + /// Returns nil on any unexpected shape โ€” the caller treats nil as + /// "feature unavailable", never as an error. + static func parseResetCredits(data: Data, now: Date = Date()) -> CodexUsage.ResetCredits? { + struct CreditDTO: Decodable { + let status: String? + let expires_at: String? + } + struct ResponseDTO: Decodable { + let credits: [CreditDTO]? + let available_count: Int? + } + guard let root = try? JSONDecoder().decode(ResponseDTO.self, from: data), + let count = root.available_count, count >= 0 else { + return nil + } + let nextExpiry = (root.credits ?? []) + .filter { ($0.status ?? "").lowercased() == "available" } + .compactMap { $0.expires_at.flatMap(parseISO8601) } + .filter { $0 > now } + .min() + return CodexUsage.ResetCredits(availableCount: count, nextExpiresAt: nextExpiry) + } + + /// chatgpt.com serializes these timestamps as ISO-8601, sometimes with + /// fractional seconds and sometimes without; accept both. + private static func parseISO8601(_ raw: String) -> Date? { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: raw) { return date } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + return plain.date(from: raw) + } + + private static func decodeUsage(data: Data, resetCredits: CodexUsage.ResetCredits? = nil) throws -> CodexUsage { let root = try JSONDecoder().decode(UsageDTO.self, from: data) let additional: [CodexUsage.AdditionalLimit] = (root.additional_rate_limits ?? []).compactMap { dto in guard let name = dto.limit_name, !name.isEmpty else { return nil } @@ -194,6 +255,7 @@ enum CodexSubscriptionService { secondary: makeWindow(root.rate_limit?.secondary_window), additionalLimits: additional, creditsBalance: root.credits?.balance, + resetCredits: resetCredits, fetchedAt: Date() ) } diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift index 719b1176..3a5d814d 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift @@ -68,11 +68,20 @@ struct CodexUsage: Sendable, Equatable { let secondary: Window? } + /// Account-level limit-reset credits: grants that restore a rate-limit + /// window early, each with its own expiry. Only what the popover renders โ€” + /// the available count and the soonest expiry among available credits. + struct ResetCredits: Sendable, Equatable { + let availableCount: Int + let nextExpiresAt: Date? + } + let plan: PlanType let primary: Window? let secondary: Window? let additionalLimits: [AdditionalLimit] let creditsBalance: Double? + let resetCredits: ResetCredits? let fetchedAt: Date static func planType(from raw: String?) -> PlanType { diff --git a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift index a7e252cf..c9a2b915 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift @@ -2107,12 +2107,31 @@ private struct CodexPlanInsight: View { ) } } + // Limit-reset credits the account is holding. Hidden at zero so + // plans that never receive these grants see no extra row. + if let resets = usage.resetCredits, resets.availableCount > 0 { + HStack(alignment: .firstTextBaseline) { + Text("Limit resets") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + Spacer() + Text(resetCreditsLabel(resets)) + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + } + } } .padding(.horizontal, 14) .padding(.top, 4) .padding(.bottom, 8) } + private func resetCreditsLabel(_ resets: CodexUsage.ResetCredits) -> String { + let count = "\(resets.availableCount) available" + guard let next = resets.nextExpiresAt else { return count } + return "\(count) ยท next expires \(relativeReset(next))" + } + private func relativeReset(_ date: Date) -> String { let f = RelativeDateTimeFormatter() f.unitsStyle = .short diff --git a/mac/Tests/CodeBurnMenubarTests/CodexResetCreditsTests.swift b/mac/Tests/CodeBurnMenubarTests/CodexResetCreditsTests.swift new file mode 100644 index 00000000..48b8ca7b --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/CodexResetCreditsTests.swift @@ -0,0 +1,76 @@ +import Foundation +import XCTest +@testable import CodeBurnMenubar + +final class CodexResetCreditsTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + private func parse(_ json: String) -> CodexUsage.ResetCredits? { + CodexSubscriptionService.parseResetCredits(data: Data(json.utf8), now: now) + } + + func testFullPayloadPicksSoonestAvailableExpiry() { + let result = parse(#""" + { + "credits": [ + {"id": "c1", "reset_type": "weekly", "status": "available", + "granted_at": "2026-07-01T00:00:00Z", "expires_at": "2027-01-25T12:00:00Z"}, + {"id": "c2", "reset_type": "weekly", "status": "available", + "granted_at": "2026-07-02T00:00:00Z", "expires_at": "2027-01-16T08:30:00.500Z"}, + {"id": "c3", "reset_type": "weekly", "status": "redeemed", + "granted_at": "2026-06-01T00:00:00Z", "expires_at": "2027-01-10T00:00:00Z", + "redeemed_at": "2026-06-05T00:00:00Z"} + ], + "available_count": 2 + } + """#) + XCTAssertEqual(result?.availableCount, 2) + // c2 (fractional-seconds timestamp) is soonest among *available* credits; + // redeemed c3 must not win despite expiring earlier. + XCTAssertEqual( + result?.nextExpiresAt, + ISO8601DateFormatter().date(from: "2027-01-16T08:30:00Z")?.addingTimeInterval(0.5) + ) + } + + func testMissingExpiryStillReportsCount() { + let result = parse(#"{"credits": [{"id": "c1", "status": "available"}], "available_count": 1}"#) + XCTAssertEqual(result?.availableCount, 1) + XCTAssertNil(result?.nextExpiresAt) + } + + func testUnknownStatusIsIgnoredForExpiryButCountIsServerAuthoritative() { + let result = parse(#""" + { + "credits": [{"id": "c1", "status": "pending_grant", "expires_at": "2027-01-16T08:30:00Z"}], + "available_count": 1 + } + """#) + XCTAssertEqual(result?.availableCount, 1) + XCTAssertNil(result?.nextExpiresAt) + } + + func testAlreadyExpiredCreditDoesNotSurfaceAsNextExpiry() { + let result = parse(#""" + { + "credits": [{"id": "c1", "status": "available", "expires_at": "2020-01-01T00:00:00Z"}], + "available_count": 1 + } + """#) + XCTAssertEqual(result?.availableCount, 1) + XCTAssertNil(result?.nextExpiresAt) + } + + func testEmptyCreditsZeroCount() { + let result = parse(#"{"credits": [], "available_count": 0}"#) + XCTAssertEqual(result?.availableCount, 0) + XCTAssertNil(result?.nextExpiresAt) + } + + func testMalformedDocumentReturnsNil() { + XCTAssertNil(parse("not json")) + XCTAssertNil(parse(#"{"credits": "wrong-shape"}"#)) + XCTAssertNil(parse(#"{"credits": []}"#)) + XCTAssertNil(parse(#"{"available_count": -1, "credits": []}"#)) + } +}