ae394370bf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
110 lines
4.9 KiB
Swift
110 lines
4.9 KiB
Swift
import Foundation
|
||
|
||
// MARK: - ChatGPT (Codex) plan limits
|
||
//
|
||
// The ChatGPT/Codex app writes its plan limits into every session log
|
||
// (~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl) as
|
||
// "rate_limits":{"primary":{"used_percent":23.0,"window_minutes":10080,"resets_at":1791634947},"secondary":…}
|
||
// We read the newest logs once a minute and keep the weekly window (10080 min).
|
||
// Nothing is sent anywhere; the numbers are only as fresh as the last Codex turn.
|
||
|
||
enum CodexUsage {
|
||
static let weekMinutes = 10080
|
||
|
||
/// Weekly window from the most recent `rate_limits` found in the newest session logs.
|
||
static func readWeekly(home: URL = FileManager.default.homeDirectoryForCurrentUser) -> PlanWindow? {
|
||
let root = home.appendingPathComponent(".codex/sessions")
|
||
for file in newestLogs(in: root, limit: 4) {
|
||
if let w = lastWeekly(in: file) { return w }
|
||
}
|
||
return nil
|
||
}
|
||
|
||
/// Newest *.jsonl files under the date folders, newest first.
|
||
static func newestLogs(in root: URL, limit: Int) -> [URL] {
|
||
let fm = FileManager.default
|
||
guard let e = fm.enumerator(at: root, includingPropertiesForKeys: [.contentModificationDateKey],
|
||
options: [.skipsHiddenFiles]) else { return [] }
|
||
var files: [(URL, Date)] = []
|
||
for case let url as URL in e where url.pathExtension == "jsonl" {
|
||
let d = (try? url.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast
|
||
files.append((url, d))
|
||
}
|
||
return files.sorted { $0.1 > $1.1 }.prefix(limit).map(\.0)
|
||
}
|
||
|
||
/// Reads the tail of a log and returns the weekly window of its last `rate_limits`.
|
||
static func lastWeekly(in file: URL) -> PlanWindow? {
|
||
guard let h = try? FileHandle(forReadingFrom: file) else { return nil }
|
||
defer { try? h.close() }
|
||
let size = (try? h.seekToEnd()) ?? 0
|
||
let chunk: UInt64 = 512 * 1024
|
||
try? h.seek(toOffset: size > chunk ? size - chunk : 0)
|
||
guard let data = try? h.readToEnd(), let text = String(data: data, encoding: .utf8) else { return nil }
|
||
for line in text.split(separator: "\n").reversed() where line.contains("\"rate_limits\"") {
|
||
if let w = parseLine(String(line)) { return w }
|
||
}
|
||
return nil
|
||
}
|
||
|
||
static func parseLine(_ line: String) -> PlanWindow? {
|
||
guard let obj = try? JSONSerialization.jsonObject(with: Data(line.utf8)),
|
||
let limits = findRateLimits(obj) else { return nil }
|
||
let windows = [limits["primary"], limits["secondary"]].compactMap { $0 as? [String: Any] }
|
||
let weekly = windows.first { ($0["window_minutes"] as? NSNumber)?.intValue == weekMinutes }
|
||
guard let w = weekly,
|
||
let pct = (w["used_percent"] as? NSNumber)?.doubleValue,
|
||
let resets = (w["resets_at"] as? NSNumber)?.doubleValue, resets > 0 else { return nil }
|
||
return PlanWindow(usedPct: min(100, max(0, pct)), resetsAt: Date(timeIntervalSince1970: resets))
|
||
}
|
||
|
||
/// `rate_limits` can sit at any depth of the event (payload.info…), so search for it.
|
||
private static func findRateLimits(_ obj: Any) -> [String: Any]? {
|
||
if let d = obj as? [String: Any] {
|
||
if let r = d["rate_limits"] as? [String: Any] { return r }
|
||
for v in d.values { if let r = findRateLimits(v) { return r } }
|
||
} else if let a = obj as? [Any] {
|
||
for v in a { if let r = findRateLimits(v) { return r } }
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
final class CodexUsageMonitor {
|
||
static let shared = CodexUsageMonitor()
|
||
private var task: Task<Void, Never>?
|
||
|
||
func start() {
|
||
guard task == nil else { return }
|
||
task = Task {
|
||
while !Task.isCancelled {
|
||
let w = await Task.detached(priority: .utility) { CodexUsage.readWeekly() }.value
|
||
AppState.shared.codexWeekly = w
|
||
try? await Task.sleep(for: .seconds(60))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Reset time wording
|
||
|
||
enum PlanTime {
|
||
/// "3 ч 20 мин", "2 д 5 ч", "12 мин", "<1 мин".
|
||
static func untilReset(_ date: Date, now: Date = Date()) -> String {
|
||
let s = max(0, Int(date.timeIntervalSince(now)))
|
||
let d = s / 86400, h = (s % 86400) / 3600, m = (s % 3600) / 60
|
||
if d > 0 { return h > 0 ? "\(d) д \(h) ч" : "\(d) д" }
|
||
if h > 0 { return m > 0 ? "\(h) ч \(m) мин" : "\(h) ч" }
|
||
return m > 0 ? "\(m) мин" : "<1 мин"
|
||
}
|
||
|
||
/// "пт 14:00" within a week, "12 окт 14:00" beyond.
|
||
static func resetMoment(_ date: Date, now: Date = Date()) -> String {
|
||
let f = DateFormatter()
|
||
f.locale = Locale(identifier: "ru_RU")
|
||
f.dateFormat = date.timeIntervalSince(now) < 6 * 86400 ? "EE HH:mm" : "d MMM HH:mm"
|
||
return f.string(from: date).replacingOccurrences(of: ".", with: "").lowercased()
|
||
}
|
||
}
|