codeburn/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift
Resham Joshi daa673449c
Some checks are pending
CI / semgrep (push) Waiting to run
Menubar and CLI hardening from multi-agent audit (#257)
Two passes of validators across CLI accuracy, dashboard UX, menubar Swift,
performance, security, and end-to-end smoke tests on real session data.

Data-correctness fixes:

- parseLocalDate rejects month/day overflow. JS Date silently rolled
  Feb 31 to Mar 3, so --from 2026-02-31 --to 2026-03-15 quietly dropped
  sessions on Feb 28 - Mar 2. Now throws "Invalid date" with a clear
  reason. Leap-day case covered (2024-02-29 valid, 2025-02-29 rejected).

- CSV/JSON exports use the active currency's natural decimal places. The
  previous round2 helper produced ¥412.37 in CSV while the dashboard
  rendered ¥412 — finance teams comparing the two surfaces saw a
  discrepancy. New roundForActiveCurrency consults Intl.NumberFormat for
  the right precision (0 for JPY/KRW/CLP, 2 for USD/EUR, etc).

- Copilot toolRequests is Array.isArray-guarded in both modern and legacy
  event branches. Previously a corrupt session with toolRequests=null or
  a string aborted the whole file's parse loop and silently dropped every
  legitimate call after it.

- Codex token_count dedup uses a null sentinel for prevCumulativeTotal so
  the first event is never confused with a duplicate. Sessions that emit
  only last_token_usage (no total_token_usage) report cumulativeTotal=0
  on every event; with the previous 0-initialized prev, the first event
  matched the dedup guard and was dropped.

- LiteLLM pricing values are clamped to [0, 1] per token via safePerTokenRate.
  Defense in depth against a tampered upstream JSON shipping negative or
  absurdly large per-token costs that would otherwise propagate into all
  cost totals.

Performance:

- Cursor SQLite parse no longer pegs at minutes on multi-GB DBs. Two
  changes: per-conversation user-message buffer uses an index pointer
  instead of Array.shift() (which was O(n) per call); and a real ROWID
  cutoff via subquery limits the scan to the most recent 250k bubbles
  with a stderr warning so power users get a partial report rather than
  a stalled CLI.

- Spawned codeburn CLI subprocesses are terminated when the calling Task
  is cancelled. Without this, rapid period/provider tab clicks in the
  menubar cancelled the Task but left the subprocess running to
  completion, piling up zombie processes.

UX:

- Dashboard period switch flips to loading and clears projects
  synchronously before reloadData runs, eliminating the frame where the
  new period label rendered over the old period's projects.

- Optimize findings tab paginates 3-at-a-time with j/k scroll. With 4
  new detectors plus 7 originals, 8-10 findings * 6 lines was scrolling
  the StatusBar off the alt buffer top.

- Custom --from/--to ranges hide the period tab strip and disable the
  1-5 / arrow keys so a stray period press no longer abandons the user's
  explicit range. A "Custom range: X to Y" banner replaces the tab strip.

- OpenCode storage-format warning is per-table-set, rate-limited to once
  per process, and points the user at OpenCode's migration step or the
  issue tracker. The previous all-or-nothing check fired the generic
  "format not recognized" string for any schema mismatch.

Menubar / OAuth:

- Both Claude and Codex bootstrap (Reconnect button) now honour the
  usageBlockedUntil 429 backoff that refreshIfBootstrapped respects.
  Spamming Reconnect during sustained rate-limit windows previously
  hammered the upstream endpoint on every click.

- Codex Retry-After HTTP header is parsed (delta-seconds plus IMF-fixdate
  fallback) so we don't over-back-off when ChatGPT tells us a shorter
  window than our 5-minute floor.

- Both credential cache files are written via SafeFile.write
  (O_CREAT | O_EXCL | O_NOFOLLOW with explicit 0600) so there is no race
  window where the temp file briefly exists at default umask, and a
  symlink at the destination cannot redirect the write. Reads now route
  through SafeFile.read with a 64 KiB cap, closing the symlink-follow gap
  on Data(contentsOf:).

CI signal:

- TypeScript strict typecheck (tsc --noEmit) is now zero errors. The
  six errors in src/providers/copilot.ts came from a discriminated-union
  catch-all branch whose `data: Record<string, unknown>` shape TS picked
  over the specific event branches when narrowing on `type`. Removed the
  catch-all; runtime falls through unknown event types via the existing
  if/else chain.

Tests added: 16 new (now 555 total)
- date-range-filter: month/day/year overflow rejection, leap-day correctness
- currency-rounding: convertCost no-rounding contract, roundForActiveCurrency
  for USD/JPY/KRW/EUR
- providers/copilot: malformed toolRequests does not abort the parse
- providers/cursor-bubble-dedup: re-parse after token mutation does not
  double-count, single parse yields one call per bubble
- providers/codex: first event with cumulativeTotal=0 not dropped,
  consecutive zero-cumulative duplicates still deduped
2026-05-06 22:15:11 -07:00

243 lines
10 KiB
Swift

import Foundation
/// Mirror of ClaudeSubscriptionService for Codex (ChatGPT-mode). Hits
/// /backend-api/wham/usage with the bearer token from CodexCredentialStore,
/// applies an independent 429 backoff, and surfaces terminal vs transient
/// failures to the UI.
enum CodexSubscriptionService {
private static let usageURL = URL(string: "https://chatgpt.com/backend-api/wham/usage")!
private static let usageBlockedUntilKey = "codeburn.codex.usage.blockedUntil"
enum FetchError: Error, LocalizedError {
case notBootstrapped
case bootstrapFailed(CodexCredentialStore.StoreError)
case rateLimited(retryAt: Date)
case usageHTTPError(Int, String?)
case usageDecodeFailed
case network(Error)
case credential(CodexCredentialStore.StoreError)
var errorDescription: String? {
switch self {
case .notBootstrapped:
return "Connect Codex in Settings to start tracking quota."
case let .bootstrapFailed(err): return err.errorDescription
case let .rateLimited(retryAt):
let f = RelativeDateTimeFormatter()
f.unitsStyle = .short
return "ChatGPT rate-limited the quota endpoint. Retrying \(f.localizedString(for: retryAt, relativeTo: Date()))."
case let .usageHTTPError(code, body):
return "Codex quota fetch failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")"
case .usageDecodeFailed: return "Codex quota response was malformed."
case let .network(err): return "Network error: \(err.localizedDescription)"
case let .credential(err): return err.errorDescription
}
}
var isTerminal: Bool {
if case let .credential(err) = self { return err.isTerminal }
if case let .bootstrapFailed(err) = self { return err.isTerminal }
return false
}
var rateLimitRetryAt: Date? {
if case let .rateLimited(retryAt) = self { return retryAt }
return nil
}
}
static func bootstrap() async throws -> CodexUsage {
// Honour the same 429 backoff that refreshIfBootstrapped respects.
// A user clicking Reconnect during a sustained ChatGPT rate-limit
// window would otherwise re-hit /wham/usage on every click and keep
// the backoff window pegged.
if let until = usageBlockedUntil(), until > Date() {
throw FetchError.rateLimited(retryAt: until)
}
let record: CodexCredentialStore.CredentialRecord
do {
record = try CodexCredentialStore.bootstrap()
} catch let err as CodexCredentialStore.StoreError {
throw FetchError.bootstrapFailed(err)
}
return try await fetchWithToken(record.accessToken, allowOne401Recovery: true)
}
static func refreshIfBootstrapped() async throws -> CodexUsage? {
guard CodexCredentialStore.isBootstrapCompleted else { return nil }
if let until = usageBlockedUntil(), until > Date() {
throw FetchError.rateLimited(retryAt: until)
}
do {
let token = try await CodexCredentialStore.freshAccessToken()
guard let token else { throw FetchError.notBootstrapped }
return try await fetchWithToken(token, allowOne401Recovery: true)
} catch let err as CodexCredentialStore.StoreError {
throw FetchError.credential(err)
}
}
static func disconnect() {
CodexCredentialStore.resetBootstrap()
clearUsageBlock()
}
private static func fetchWithToken(_ token: String, allowOne401Recovery: Bool) async throws -> CodexUsage {
var request = URLRequest(url: usageURL)
request.httpMethod = "GET"
request.timeoutInterval = 30
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("CodeBurn", forHTTPHeaderField: "User-Agent")
// chatgpt.com routes the rate_limit envelope per ChatGPT account. Without
// this header the response often comes back as a guest-shape document
// missing rate_limit entirely, which our decoder then fails on.
if let accountId = try? CodexCredentialStore.currentRecord()?.accountId, !accountId.isEmpty {
request.setValue(accountId, forHTTPHeaderField: "ChatGPT-Account-Id")
}
let data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(for: request)
} catch {
throw FetchError.network(error)
}
guard let http = response as? HTTPURLResponse else {
throw FetchError.usageHTTPError(-1, nil)
}
switch http.statusCode {
case 200:
clearUsageBlock()
do {
return try decodeUsage(data: data)
} catch {
// Do not log the response body it's user-account data from
// chatgpt.com and is readable by other local users via
// `log stream`. The decode error type alone is enough to
// bisect schema drift if needed.
NSLog("CodeBurn: codex usage decode failed: %@", String(describing: error))
throw FetchError.usageDecodeFailed
}
case 401:
if allowOne401Recovery {
let newToken = try await CodexCredentialStore.refreshAfter401()
return try await fetchWithToken(newToken, allowOne401Recovery: false)
}
throw FetchError.usageHTTPError(401, String(data: data, encoding: .utf8))
case 429:
// Honour the RFC Retry-After header when present ChatGPT's quota
// endpoint sometimes sets it to a window shorter than our 5-min
// floor, and ignoring it forced users to wait longer than the
// server actually wanted.
let retryAfter = parseRetryAfterHeader(http.value(forHTTPHeaderField: "Retry-After"))
let until = recordUsageRateLimit(retryAfterSeconds: retryAfter)
throw FetchError.rateLimited(retryAt: until)
default:
throw FetchError.usageHTTPError(http.statusCode, String(data: data, encoding: .utf8))
}
}
private struct UsageDTO: Decodable {
let plan_type: String?
let rate_limit: RateLimit?
let additional_rate_limits: [AdditionalLimitDTO]?
let credits: Credits?
struct RateLimit: Decodable {
let primary_window: WindowDTO?
let secondary_window: WindowDTO?
}
struct AdditionalLimitDTO: Decodable {
let limit_name: String?
let rate_limit: RateLimit?
}
struct WindowDTO: Decodable {
let used_percent: Double?
let reset_at: Int?
let limit_window_seconds: Int?
}
// chatgpt.com sometimes serializes balance as a Double ("balance": 0.0)
// and other times as a String ("balance": "0.00"). Mirror CodexBar's
// resilient decode so a schema drift on either shape doesn't blow up
// the whole quota fetch.
struct Credits: Decodable {
let balance: Double?
enum CodingKeys: String, CodingKey { case balance }
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
if let n = try? c.decode(Double.self, forKey: .balance) {
balance = n
} else if let s = try? c.decode(String.self, forKey: .balance), let n = Double(s) {
balance = n
} else {
balance = nil
}
}
}
}
private static func decodeUsage(data: Data) 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 }
return CodexUsage.AdditionalLimit(
name: name,
primary: makeWindow(dto.rate_limit?.primary_window),
secondary: makeWindow(dto.rate_limit?.secondary_window)
)
}
return CodexUsage(
plan: CodexUsage.planType(from: root.plan_type),
primary: makeWindow(root.rate_limit?.primary_window),
secondary: makeWindow(root.rate_limit?.secondary_window),
additionalLimits: additional,
creditsBalance: root.credits?.balance,
fetchedAt: Date()
)
}
private static func makeWindow(_ dto: UsageDTO.WindowDTO?) -> CodexUsage.Window? {
guard let dto, let used = dto.used_percent, let windowSeconds = dto.limit_window_seconds else {
return nil
}
let resetsAt = dto.reset_at.map { Date(timeIntervalSince1970: TimeInterval($0)) }
return CodexUsage.Window(usedPercent: used, resetsAt: resetsAt, limitWindowSeconds: windowSeconds)
}
// MARK: - 429 backoff
private static func usageBlockedUntil() -> Date? {
UserDefaults.standard.object(forKey: usageBlockedUntilKey) as? Date
}
private static func clearUsageBlock() {
UserDefaults.standard.removeObject(forKey: usageBlockedUntilKey)
}
@discardableResult
/// RFC 7231 says Retry-After is either a delta-seconds or an HTTP-date.
/// chatgpt.com appears to send delta-seconds today; we still parse both
/// shapes defensively so a future change to HTTP-date doesn't drop us
/// onto the silent 5-minute floor.
private static func parseRetryAfterHeader(_ value: String?) -> Int? {
guard let value = value?.trimmingCharacters(in: .whitespaces), !value.isEmpty else { return nil }
if let seconds = Int(value), seconds >= 0 { return seconds }
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(secondsFromGMT: 0)
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
if let date = f.date(from: value) {
return max(0, Int(date.timeIntervalSinceNow))
}
return nil
}
private static func recordUsageRateLimit(retryAfterSeconds: Int?) -> Date {
let seconds = max(retryAfterSeconds ?? 300, 60)
let until = Date().addingTimeInterval(TimeInterval(seconds))
UserDefaults.standard.set(until, forKey: usageBlockedUntilKey)
return until
}
}