this repo has no description
at main 486 lines 17 kB view raw
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}