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
66 changes: 64 additions & 2 deletions mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand All @@ -194,6 +255,7 @@ enum CodexSubscriptionService {
secondary: makeWindow(root.rate_limit?.secondary_window),
additionalLimits: additional,
creditsBalance: root.credits?.balance,
resetCredits: resetCredits,
fetchedAt: Date()
)
}
Expand Down
9 changes: 9 additions & 0 deletions mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
19 changes: 19 additions & 0 deletions mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions mac/Tests/CodeBurnMenubarTests/CodexResetCreditsTests.swift
Original file line number Diff line number Diff line change
@@ -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": []}"#))
}
}