this repo has no description
1import Foundation
2
3public struct RateWindow: Codable, Sendable {
4 public let usedPercent: Double
5 public let windowMinutes: Int?
6 public let resetsAt: Date?
7 /// Optional textual reset description (used by Claude CLI UI scrape).
8 public let resetDescription: String?
9
10 public init(usedPercent: Double, windowMinutes: Int?, resetsAt: Date?, resetDescription: String?) {
11 self.usedPercent = usedPercent
12 self.windowMinutes = windowMinutes
13 self.resetsAt = resetsAt
14 self.resetDescription = resetDescription
15 }
16
17 public var remainingPercent: Double {
18 max(0, 100 - self.usedPercent)
19 }
20}
21
22public struct UsageSnapshot: Codable, Sendable {
23 public let primary: RateWindow
24 public let secondary: RateWindow
25 public let tertiary: RateWindow?
26 public let updatedAt: Date
27 public let accountEmail: String?
28 public let accountOrganization: String?
29 public let loginMethod: String?
30
31 public init(
32 primary: RateWindow,
33 secondary: RateWindow,
34 tertiary: RateWindow? = nil,
35 updatedAt: Date,
36 accountEmail: String? = nil,
37 accountOrganization: String? = nil,
38 loginMethod: String? = nil)
39 {
40 self.primary = primary
41 self.secondary = secondary
42 self.tertiary = tertiary
43 self.updatedAt = updatedAt
44 self.accountEmail = accountEmail
45 self.accountOrganization = accountOrganization
46 self.loginMethod = loginMethod
47 }
48}
49
50public struct AccountInfo: Equatable, Sendable {
51 public let email: String?
52 public let plan: String?
53
54 public init(email: String?, plan: String?) {
55 self.email = email
56 self.plan = plan
57 }
58}
59
60public enum UsageError: LocalizedError, Sendable {
61 case noSessions
62 case noRateLimitsFound
63 case decodeFailed
64
65 public var errorDescription: String? {
66 switch self {
67 case .noSessions:
68 "No Codex sessions found yet. Run at least one Codex prompt first."
69 case .noRateLimitsFound:
70 "Found sessions, but no rate limit events yet."
71 case .decodeFailed:
72 "Could not parse Codex session log."
73 }
74 }
75}
76
77// MARK: - Codex RPC client (local process)
78
79private struct RPCAccountResponse: Decodable {
80 let account: RPCAccountDetails?
81 let requiresOpenaiAuth: Bool?
82}
83
84private enum RPCAccountDetails: Decodable {
85 case apiKey
86 case chatgpt(email: String, planType: String)
87
88 enum CodingKeys: String, CodingKey {
89 case type
90 case email
91 case planType
92 }
93
94 init(from decoder: Decoder) throws {
95 let container = try decoder.container(keyedBy: CodingKeys.self)
96 let type = try container.decode(String.self, forKey: .type)
97 switch type.lowercased() {
98 case "apikey":
99 self = .apiKey
100 case "chatgpt":
101 let email = try container.decodeIfPresent(String.self, forKey: .email) ?? "unknown"
102 let plan = try container.decodeIfPresent(String.self, forKey: .planType) ?? "unknown"
103 self = .chatgpt(email: email, planType: plan)
104 default:
105 throw DecodingError.dataCorruptedError(
106 forKey: .type,
107 in: container,
108 debugDescription: "Unknown account type \(type)")
109 }
110 }
111}
112
113private struct RPCRateLimitsResponse: Decodable, Encodable {
114 let rateLimits: RPCRateLimitSnapshot
115}
116
117private struct RPCRateLimitSnapshot: Decodable, Encodable {
118 let primary: RPCRateLimitWindow?
119 let secondary: RPCRateLimitWindow?
120 let credits: RPCCreditsSnapshot?
121}
122
123private struct RPCRateLimitWindow: Decodable, Encodable {
124 let usedPercent: Double
125 let windowDurationMins: Int?
126 let resetsAt: Int?
127}
128
129private struct RPCCreditsSnapshot: Decodable, Encodable {
130 let hasCredits: Bool
131 let unlimited: Bool
132 let balance: String?
133}
134
135private enum RPCWireError: Error, CustomStringConvertible {
136 case startFailed(String)
137 case requestFailed(String)
138 case malformed(String)
139
140 var description: String {
141 switch self {
142 case let .startFailed(message):
143 "Failed to start codex app-server: \(message)"
144 case let .requestFailed(message):
145 "RPC request failed: \(message)"
146 case let .malformed(message):
147 "Malformed response: \(message)"
148 }
149 }
150}
151
152// RPC helper used on background tasks; safe because we confine it to the owning task.
153private final class CodexRPCClient: @unchecked Sendable {
154 private let process = Process()
155 private let stdinPipe = Pipe()
156 private let stdoutPipe = Pipe()
157 private let stderrPipe = Pipe()
158 private var nextID = 1
159
160 init(
161 executable: String = "codex",
162 arguments: [String] = ["-s", "read-only", "-a", "untrusted", "app-server"]) throws
163 {
164 let resolvedExec = BinaryLocator.resolveCodexBinary()
165 ?? TTYCommandRunner.which(executable)
166 ?? executable
167 var env = ProcessInfo.processInfo.environment
168 env["PATH"] = PathBuilder.effectivePATH(
169 purposes: [.rpc, .nodeTooling],
170 env: env)
171
172 self.process.environment = env
173 self.process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
174 self.process.arguments = [resolvedExec] + arguments
175 self.process.standardInput = self.stdinPipe
176 self.process.standardOutput = self.stdoutPipe
177 self.process.standardError = self.stderrPipe
178
179 do {
180 try self.process.run()
181 } catch {
182 throw RPCWireError.startFailed(error.localizedDescription)
183 }
184
185 let stderrHandle = self.stderrPipe.fileHandleForReading
186 stderrHandle.readabilityHandler = { handle in
187 let data = handle.availableData
188 // When the child closes stderr, availableData returns empty and will keep re-firing; clear the handler
189 // to avoid a busy read loop on the file-descriptor monitoring queue.
190 if data.isEmpty {
191 handle.readabilityHandler = nil
192 return
193 }
194 guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { return }
195 for line in text.split(whereSeparator: \.isNewline) {
196 fputs("[codex stderr] \(line)\n", stderr)
197 }
198 }
199 }
200
201 func initialize(clientName: String, clientVersion: String) async throws {
202 _ = try await self.request(
203 method: "initialize",
204 params: ["clientInfo": ["name": clientName, "version": clientVersion]])
205 try self.sendNotification(method: "initialized")
206 }
207
208 func fetchAccount() async throws -> RPCAccountResponse {
209 let message = try await self.request(method: "account/read")
210 return try self.decodeResult(from: message)
211 }
212
213 func fetchRateLimits() async throws -> RPCRateLimitsResponse {
214 let message = try await self.request(method: "account/rateLimits/read")
215 return try self.decodeResult(from: message)
216 }
217
218 func shutdown() {
219 if self.process.isRunning {
220 self.process.terminate()
221 }
222 }
223
224 // MARK: - JSON-RPC helpers
225
226 private func request(method: String, params: [String: Any]? = nil) async throws -> [String: Any] {
227 let id = self.nextID
228 self.nextID += 1
229 try self.sendRequest(id: id, method: method, params: params)
230
231 while true {
232 let message = try await self.readNextMessage()
233
234 if message["id"] == nil, let methodName = message["method"] as? String {
235 fputs("[codex notify] \(methodName)\n", stderr)
236 continue
237 }
238
239 guard let messageID = self.jsonID(message["id"]), messageID == id else { continue }
240
241 if let error = message["error"] as? [String: Any], let messageText = error["message"] as? String {
242 throw RPCWireError.requestFailed(messageText)
243 }
244
245 return message
246 }
247 }
248
249 private func sendNotification(method: String, params: [String: Any]? = nil) throws {
250 let paramsValue: Any = params ?? [:]
251 try self.sendPayload(["method": method, "params": paramsValue])
252 }
253
254 private func sendRequest(id: Int, method: String, params: [String: Any]?) throws {
255 let paramsValue: Any = params ?? [:]
256 let payload: [String: Any] = ["id": id, "method": method, "params": paramsValue]
257 try self.sendPayload(payload)
258 }
259
260 private func sendPayload(_ payload: [String: Any]) throws {
261 let data = try JSONSerialization.data(withJSONObject: payload)
262 self.stdinPipe.fileHandleForWriting.write(data)
263 self.stdinPipe.fileHandleForWriting.write(Data([0x0A]))
264 }
265
266 private func readNextMessage() async throws -> [String: Any] {
267 for try await lineData in self.stdoutPipe.fileHandleForReading.bytes.lines {
268 if lineData.isEmpty { continue }
269 let line = String(lineData)
270 guard let data = line.data(using: .utf8) else { continue }
271 if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
272 return json
273 }
274 }
275 throw RPCWireError.malformed("codex app-server closed stdout")
276 }
277
278 private func decodeResult<T: Decodable>(from message: [String: Any]) throws -> T {
279 guard let result = message["result"] else {
280 throw RPCWireError.malformed("missing result field")
281 }
282 let data = try JSONSerialization.data(withJSONObject: result)
283 let decoder = JSONDecoder()
284 return try decoder.decode(T.self, from: data)
285 }
286
287 private func jsonID(_ value: Any?) -> Int? {
288 switch value {
289 case let int as Int:
290 int
291 case let number as NSNumber:
292 number.intValue
293 default:
294 nil
295 }
296 }
297}
298
299// MARK: - Public fetcher used by the app
300
301public struct UsageFetcher: Sendable {
302 private let environment: [String: String]
303
304 public init(environment: [String: String] = ProcessInfo.processInfo.environment) {
305 self.environment = environment
306 LoginShellPathCache.shared.captureOnce()
307 }
308
309 public func loadLatestUsage() async throws -> UsageSnapshot {
310 try await self.withFallback(primary: self.loadRPCUsage, secondary: self.loadTTYUsage)
311 }
312
313 private func loadRPCUsage() async throws -> UsageSnapshot {
314 let rpc = try CodexRPCClient()
315 defer { rpc.shutdown() }
316
317 try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4")
318 // The app-server answers on a single stdout stream, so keep requests
319 // serialized to avoid starving one reader when multiple awaiters race
320 // for the same pipe.
321 let limits = try await rpc.fetchRateLimits().rateLimits
322 let account = try? await rpc.fetchAccount()
323
324 guard let primary = Self.makeWindow(from: limits.primary),
325 let secondary = Self.makeWindow(from: limits.secondary)
326 else {
327 throw UsageError.noRateLimitsFound
328 }
329
330 return UsageSnapshot(
331 primary: primary,
332 secondary: secondary,
333 tertiary: nil,
334 updatedAt: Date(),
335 accountEmail: account?.account.flatMap { details in
336 if case let .chatgpt(email, _) = details { email } else { nil }
337 },
338 accountOrganization: nil,
339 loginMethod: account?.account.flatMap { details in
340 if case let .chatgpt(_, plan) = details { plan } else { nil }
341 })
342 }
343
344 private func loadTTYUsage() async throws -> UsageSnapshot {
345 let status = try await CodexStatusProbe().fetch()
346 guard let fiveLeft = status.fiveHourPercentLeft, let weekLeft = status.weeklyPercentLeft else {
347 throw UsageError.noRateLimitsFound
348 }
349
350 let primary = RateWindow(
351 usedPercent: max(0, 100 - Double(fiveLeft)),
352 windowMinutes: 300,
353 resetsAt: nil,
354 resetDescription: status.fiveHourResetDescription)
355 let secondary = RateWindow(
356 usedPercent: max(0, 100 - Double(weekLeft)),
357 windowMinutes: 10080,
358 resetsAt: nil,
359 resetDescription: status.weeklyResetDescription)
360
361 return UsageSnapshot(
362 primary: primary,
363 secondary: secondary,
364 tertiary: nil,
365 updatedAt: Date(),
366 accountEmail: nil,
367 accountOrganization: nil,
368 loginMethod: nil)
369 }
370
371 public func loadLatestCredits() async throws -> CreditsSnapshot {
372 try await self.withFallback(primary: self.loadRPCCredits, secondary: self.loadTTYCredits)
373 }
374
375 private func loadRPCCredits() async throws -> CreditsSnapshot {
376 let rpc = try CodexRPCClient()
377 defer { rpc.shutdown() }
378 try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4")
379 let limits = try await rpc.fetchRateLimits().rateLimits
380 guard let credits = limits.credits else { throw UsageError.noRateLimitsFound }
381 let remaining = Self.parseCredits(credits.balance)
382 return CreditsSnapshot(remaining: remaining, events: [], updatedAt: Date())
383 }
384
385 private func loadTTYCredits() async throws -> CreditsSnapshot {
386 let status = try await CodexStatusProbe().fetch()
387 guard let credits = status.credits else { throw UsageError.noRateLimitsFound }
388 return CreditsSnapshot(remaining: credits, events: [], updatedAt: Date())
389 }
390
391 private func withFallback<T>(
392 primary: @escaping () async throws -> T,
393 secondary: @escaping () async throws -> T) async throws -> T
394 {
395 do {
396 return try await primary()
397 } catch let primaryError {
398 do {
399 return try await secondary()
400 } catch {
401 // Preserve the original failure so callers see the primary path error.
402 throw primaryError
403 }
404 }
405 }
406
407 public func debugRawRateLimits() async -> String {
408 do {
409 let rpc = try CodexRPCClient()
410 defer { rpc.shutdown() }
411 try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4")
412 let limits = try await rpc.fetchRateLimits()
413 let data = try JSONEncoder().encode(limits)
414 return String(data: data, encoding: .utf8) ?? "<unprintable>"
415 } catch {
416 return "Codex RPC probe failed: \(error)"
417 }
418 }
419
420 public func loadAccountInfo() -> AccountInfo {
421 // Keep using auth.json for quick startup (non-blocking, no RPC spin-up required).
422 let authURL = URL(fileURLWithPath: self.environment["CODEX_HOME"] ?? "\(NSHomeDirectory())/.codex")
423 .appendingPathComponent("auth.json")
424 guard let data = try? Data(contentsOf: authURL),
425 let auth = try? JSONDecoder().decode(AuthFile.self, from: data),
426 let idToken = auth.tokens?.idToken
427 else {
428 return AccountInfo(email: nil, plan: nil)
429 }
430
431 guard let payload = UsageFetcher.parseJWT(idToken) else {
432 return AccountInfo(email: nil, plan: nil)
433 }
434
435 let authDict = payload["https://api.openai.com/auth"] as? [String: Any]
436 let profileDict = payload["https://api.openai.com/profile"] as? [String: Any]
437
438 let plan = (authDict?["chatgpt_plan_type"] as? String)
439 ?? (payload["chatgpt_plan_type"] as? String)
440
441 let email = (payload["email"] as? String)
442 ?? (profileDict?["email"] as? String)
443
444 return AccountInfo(email: email, plan: plan)
445 }
446
447 // MARK: - Helpers
448
449 private static func makeWindow(from rpc: RPCRateLimitWindow?) -> RateWindow? {
450 guard let rpc else { return nil }
451 let resetsAtDate = rpc.resetsAt.map { Date(timeIntervalSince1970: TimeInterval($0)) }
452 let resetDescription = resetsAtDate.map { UsageFormatter.resetDescription(from: $0) }
453 return RateWindow(
454 usedPercent: rpc.usedPercent,
455 windowMinutes: rpc.windowDurationMins,
456 resetsAt: resetsAtDate,
457 resetDescription: resetDescription)
458 }
459
460 private static func parseCredits(_ balance: String?) -> Double {
461 guard let balance, let val = Double(balance) else { return 0 }
462 return val
463 }
464
465 public static func parseJWT(_ token: String) -> [String: Any]? {
466 let parts = token.split(separator: ".")
467 guard parts.count >= 2 else { return nil }
468 let payloadPart = parts[1]
469
470 var padded = String(payloadPart)
471 .replacingOccurrences(of: "-", with: "+")
472 .replacingOccurrences(of: "_", with: "/")
473 while padded.count % 4 != 0 {
474 padded.append("=")
475 }
476 guard let data = Data(base64Encoded: padded) else { return nil }
477 guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
478 return json
479 }
480}
481
482// Minimal auth.json struct preserved from previous implementation
483private struct AuthFile: Decodable {
484 struct Tokens: Decodable { let idToken: String? }
485 let tokens: Tokens?
486}