import Foundation public struct RateWindow: Codable, Equatable, Sendable { public let usedPercent: Double public let windowMinutes: Int? public let resetsAt: Date? /// Optional textual reset description (used by Claude CLI UI scrape). public let resetDescription: String? /// Optional percent restored on the next regeneration tick for providers with rolling recovery. public let nextRegenPercent: Double? public init( usedPercent: Double, windowMinutes: Int?, resetsAt: Date?, resetDescription: String?, nextRegenPercent: Double? = nil) { self.usedPercent = usedPercent self.windowMinutes = windowMinutes self.resetsAt = resetsAt self.resetDescription = resetDescription self.nextRegenPercent = nextRegenPercent } public var remainingPercent: Double { max(0, 100 - self.usedPercent) } public func backfillingResetTime(from cached: RateWindow?, now: Date = .init()) -> RateWindow { if self.resetsAt != nil { return self } guard let cachedReset = cached?.resetsAt, cachedReset > now else { return self } let windowMinutes = if let windowMinutes = self.windowMinutes, windowMinutes > 0 { windowMinutes } else { cached?.windowMinutes } return RateWindow( usedPercent: self.usedPercent, windowMinutes: windowMinutes, resetsAt: cachedReset, resetDescription: self.resetDescription ?? cached?.resetDescription, nextRegenPercent: self.nextRegenPercent) } } public struct NamedRateWindow: Codable, Equatable, Sendable { public let id: String public let title: String public let window: RateWindow /// Whether `window.usedPercent` reflects known quota usage. /// /// Some providers expose reset metadata for a named quota window before /// they expose remaining usage. Keep those windows visible for reset/debug /// context, but mark them so clients do not render `usedPercent` as a real /// exhausted quota. Missing values decode as `true` for older cached payloads. public let usageKnown: Bool public init(id: String, title: String, window: RateWindow, usageKnown: Bool = true) { self.id = id self.title = title self.window = window self.usageKnown = usageKnown } private enum CodingKeys: String, CodingKey { case id case title case window case usageKnown } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.id = try container.decode(String.self, forKey: .id) self.title = try container.decode(String.self, forKey: .title) self.window = try container.decode(RateWindow.self, forKey: .window) self.usageKnown = try container.decodeIfPresent(Bool.self, forKey: .usageKnown) ?? true } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(self.id, forKey: .id) try container.encode(self.title, forKey: .title) try container.encode(self.window, forKey: .window) if !self.usageKnown { try container.encode(false, forKey: .usageKnown) } } } public struct ProviderIdentitySnapshot: Codable, Sendable { public let providerID: UsageProvider? public let accountEmail: String? public let accountOrganization: String? public let loginMethod: String? public init( providerID: UsageProvider?, accountEmail: String?, accountOrganization: String?, loginMethod: String?) { self.providerID = providerID self.accountEmail = accountEmail self.accountOrganization = accountOrganization self.loginMethod = loginMethod } public func scoped(to provider: UsageProvider) -> ProviderIdentitySnapshot { if self.providerID == provider { return self } return ProviderIdentitySnapshot( providerID: provider, accountEmail: self.accountEmail, accountOrganization: self.accountOrganization, loginMethod: self.loginMethod) } } public enum UsageDataConfidence: String, Codable, Equatable, Sendable { case exact case estimated case percentOnly case unknown } public struct UsageSnapshot: Codable, Sendable { public let primary: RateWindow? public let secondary: RateWindow? public let tertiary: RateWindow? public let extraRateWindows: [NamedRateWindow]? public let providerCost: ProviderCostSnapshot? public let kiroUsage: KiroUsageDetails? public let ampUsage: AmpUsageDetails? public let zaiUsage: ZaiUsageSnapshot? public let minimaxUsage: MiniMaxUsageSnapshot? public let deepseekUsage: DeepSeekUsageSummary? public let mimoUsage: MiMoUsageSnapshot? public let openRouterUsage: OpenRouterUsageSnapshot? public let openAIAPIUsage: OpenAIAPIUsageSnapshot? public let codexResetCredits: CodexRateLimitResetCreditsSnapshot? public let claudeAdminAPIUsage: ClaudeAdminAPIUsageSnapshot? public let mistralUsage: MistralUsageSnapshot? public let deepgramUsage: DeepgramUsageSnapshot? public let poeUsage: PoeUsageHistorySnapshot? public let cursorRequests: CursorRequestUsage? /// Live-only marker for optional Command Code subscription lookup failure. public let commandCodeSubscriptionEnrichmentUnavailable: Bool /// Live-only marker that Command Code returned a recognized subscription plan. public let commandCodeHasSubscriptionPlan: Bool /// Live-only marker that Command Code's monthly grant has no remaining credits. public let commandCodeMonthlyGrantDepleted: Bool public let subscriptionExpiresAt: Date? public let subscriptionRenewsAt: Date? public let updatedAt: Date public let identity: ProviderIdentitySnapshot? public let dataConfidence: UsageDataConfidence private enum CodingKeys: String, CodingKey { case primary case secondary case tertiary case extraRateWindows case providerCost case kiroUsage case ampUsage case mimoUsage case openRouterUsage case openAIAPIUsage case codexResetCredits case claudeAdminAPIUsage case mistralUsage case deepgramUsage case poeUsage case subscriptionExpiresAt case subscriptionRenewsAt case updatedAt case identity case dataConfidence case accountEmail case accountOrganization case loginMethod } public init( primary: RateWindow?, secondary: RateWindow?, tertiary: RateWindow? = nil, extraRateWindows: [NamedRateWindow]? = nil, kiroUsage: KiroUsageDetails? = nil, ampUsage: AmpUsageDetails? = nil, providerCost: ProviderCostSnapshot? = nil, zaiUsage: ZaiUsageSnapshot? = nil, minimaxUsage: MiniMaxUsageSnapshot? = nil, deepseekUsage: DeepSeekUsageSummary? = nil, mimoUsage: MiMoUsageSnapshot? = nil, openRouterUsage: OpenRouterUsageSnapshot? = nil, openAIAPIUsage: OpenAIAPIUsageSnapshot? = nil, codexResetCredits: CodexRateLimitResetCreditsSnapshot? = nil, claudeAdminAPIUsage: ClaudeAdminAPIUsageSnapshot? = nil, mistralUsage: MistralUsageSnapshot? = nil, deepgramUsage: DeepgramUsageSnapshot? = nil, poeUsage: PoeUsageHistorySnapshot? = nil, cursorRequests: CursorRequestUsage? = nil, commandCodeSubscriptionEnrichmentUnavailable: Bool = false, commandCodeHasSubscriptionPlan: Bool = false, commandCodeMonthlyGrantDepleted: Bool = false, subscriptionExpiresAt: Date? = nil, subscriptionRenewsAt: Date? = nil, updatedAt: Date, identity: ProviderIdentitySnapshot? = nil, dataConfidence: UsageDataConfidence = .unknown) { self.primary = primary self.secondary = secondary self.tertiary = tertiary self.extraRateWindows = extraRateWindows self.kiroUsage = kiroUsage self.ampUsage = ampUsage self.providerCost = providerCost self.zaiUsage = zaiUsage self.minimaxUsage = minimaxUsage self.deepseekUsage = deepseekUsage self.mimoUsage = mimoUsage self.openRouterUsage = openRouterUsage self.openAIAPIUsage = openAIAPIUsage self.codexResetCredits = codexResetCredits self.claudeAdminAPIUsage = claudeAdminAPIUsage self.mistralUsage = mistralUsage self.deepgramUsage = deepgramUsage self.poeUsage = poeUsage self.cursorRequests = cursorRequests self.commandCodeSubscriptionEnrichmentUnavailable = commandCodeSubscriptionEnrichmentUnavailable self.commandCodeHasSubscriptionPlan = commandCodeHasSubscriptionPlan self.commandCodeMonthlyGrantDepleted = commandCodeMonthlyGrantDepleted self.subscriptionExpiresAt = subscriptionExpiresAt self.subscriptionRenewsAt = subscriptionRenewsAt self.updatedAt = updatedAt self.identity = identity self.dataConfidence = dataConfidence } public func with(extraRateWindows: [NamedRateWindow]?) -> UsageSnapshot { self.replacing(extraRateWindows: .value(extraRateWindows)) } public func withCodexResetCredits(_ resetCredits: CodexRateLimitResetCreditsSnapshot?) -> UsageSnapshot { self.replacing(codexResetCredits: .value(resetCredits)) } public func with(primary: RateWindow?, secondary: RateWindow?) -> UsageSnapshot { self.replacing( primary: .value(primary), secondary: .value(secondary)) } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.primary = try container.decodeIfPresent(RateWindow.self, forKey: .primary) self.secondary = try container.decodeIfPresent(RateWindow.self, forKey: .secondary) self.tertiary = try container.decodeIfPresent(RateWindow.self, forKey: .tertiary) self.extraRateWindows = try container.decodeIfPresent([NamedRateWindow].self, forKey: .extraRateWindows) self.providerCost = try container.decodeIfPresent(ProviderCostSnapshot.self, forKey: .providerCost) self.kiroUsage = try container.decodeIfPresent(KiroUsageDetails.self, forKey: .kiroUsage) self.ampUsage = try container.decodeIfPresent(AmpUsageDetails.self, forKey: .ampUsage) self.zaiUsage = nil // Not persisted, fetched fresh each time self.minimaxUsage = nil // Not persisted, fetched fresh each time self.deepseekUsage = nil // Not persisted, fetched fresh each time self.mimoUsage = try container.decodeIfPresent(MiMoUsageSnapshot.self, forKey: .mimoUsage) self.openRouterUsage = try container.decodeIfPresent(OpenRouterUsageSnapshot.self, forKey: .openRouterUsage) self.openAIAPIUsage = try container.decodeIfPresent(OpenAIAPIUsageSnapshot.self, forKey: .openAIAPIUsage) self.codexResetCredits = try container.decodeIfPresent( CodexRateLimitResetCreditsSnapshot.self, forKey: .codexResetCredits) self.claudeAdminAPIUsage = try container.decodeIfPresent( ClaudeAdminAPIUsageSnapshot.self, forKey: .claudeAdminAPIUsage) self.mistralUsage = try container.decodeIfPresent(MistralUsageSnapshot.self, forKey: .mistralUsage) self.deepgramUsage = try container.decodeIfPresent(DeepgramUsageSnapshot.self, forKey: .deepgramUsage) self.poeUsage = try container.decodeIfPresent(PoeUsageHistorySnapshot.self, forKey: .poeUsage) self.cursorRequests = nil // Not persisted, fetched fresh each time self.commandCodeSubscriptionEnrichmentUnavailable = false // Live-only fetch state self.commandCodeHasSubscriptionPlan = false // Live-only fetch state self.commandCodeMonthlyGrantDepleted = false // Live-only fetch state self.subscriptionExpiresAt = try container.decodeIfPresent(Date.self, forKey: .subscriptionExpiresAt) self.subscriptionRenewsAt = try container.decodeIfPresent(Date.self, forKey: .subscriptionRenewsAt) self.updatedAt = try container.decode(Date.self, forKey: .updatedAt) if let dataConfidence = try container.decodeIfPresent(String.self, forKey: .dataConfidence) { self.dataConfidence = UsageDataConfidence(rawValue: dataConfidence) ?? .unknown } else { self.dataConfidence = .unknown } if let identity = try container.decodeIfPresent(ProviderIdentitySnapshot.self, forKey: .identity) { self.identity = identity } else { let email = try container.decodeIfPresent(String.self, forKey: .accountEmail) let organization = try container.decodeIfPresent(String.self, forKey: .accountOrganization) let loginMethod = try container.decodeIfPresent(String.self, forKey: .loginMethod) if email != nil || organization != nil || loginMethod != nil { self.identity = ProviderIdentitySnapshot( providerID: nil, accountEmail: email, accountOrganization: organization, loginMethod: loginMethod) } else { self.identity = nil } } } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) // Stable JSON schema: keep window keys present (encode `nil` as `null`). try container.encode(self.primary, forKey: .primary) try container.encode(self.secondary, forKey: .secondary) try container.encode(self.tertiary, forKey: .tertiary) try container.encodeIfPresent(self.extraRateWindows, forKey: .extraRateWindows) try container.encodeIfPresent(self.providerCost, forKey: .providerCost) try container.encodeIfPresent(self.kiroUsage, forKey: .kiroUsage) try container.encodeIfPresent(self.ampUsage, forKey: .ampUsage) try container.encodeIfPresent(self.mimoUsage, forKey: .mimoUsage) try container.encodeIfPresent(self.openRouterUsage, forKey: .openRouterUsage) try container.encodeIfPresent(self.openAIAPIUsage, forKey: .openAIAPIUsage) try container.encodeIfPresent(self.codexResetCredits, forKey: .codexResetCredits) try container.encodeIfPresent(self.claudeAdminAPIUsage, forKey: .claudeAdminAPIUsage) try container.encodeIfPresent(self.mistralUsage, forKey: .mistralUsage) try container.encodeIfPresent(self.deepgramUsage, forKey: .deepgramUsage) try container.encodeIfPresent(self.poeUsage, forKey: .poeUsage) try container.encodeIfPresent(self.subscriptionExpiresAt, forKey: .subscriptionExpiresAt) try container.encodeIfPresent(self.subscriptionRenewsAt, forKey: .subscriptionRenewsAt) try container.encode(self.updatedAt, forKey: .updatedAt) try container.encodeIfPresent(self.identity, forKey: .identity) if self.dataConfidence != .unknown { try container.encode(self.dataConfidence, forKey: .dataConfidence) } try container.encodeIfPresent(self.identity?.accountEmail, forKey: .accountEmail) try container.encodeIfPresent(self.identity?.accountOrganization, forKey: .accountOrganization) try container.encodeIfPresent(self.identity?.loginMethod, forKey: .loginMethod) } public func identity(for provider: UsageProvider) -> ProviderIdentitySnapshot? { guard let identity, identity.providerID == provider else { return nil } return identity } public func automaticPerplexityWindow() -> RateWindow? { let fallbackWindows = self.orderedPerplexityFallbackWindows() guard let primary = self.primary else { return fallbackWindows.first } if primary.remainingPercent > 0 || fallbackWindows.isEmpty { return primary } return fallbackWindows.first } public func orderedPerplexityDisplayWindows() -> [RateWindow] { let fallbackWindows = self.orderedPerplexityFallbackWindows() guard let primary = self.primary else { return fallbackWindows } if primary.remainingPercent > 0 || fallbackWindows.isEmpty { return [primary] + fallbackWindows } return fallbackWindows + [primary] } public func switcherWeeklyWindow(for provider: UsageProvider, showUsed: Bool) -> RateWindow? { switch provider { case .factory: // Factory prefers secondary window return self.secondary ?? self.primary case .perplexity: return self.automaticPerplexityWindow() case .cursor: // Cursor: fall back to on-demand budget when the included plan is exhausted (only in // "show remaining" mode). The secondary/tertiary lanes are Total/Auto/API breakdowns, // not extra capacity, so they should not replace the remaining paid quota indicator. if !showUsed, let primary = self.primary, primary.remainingPercent <= 0, let providerCost = self.providerCost, providerCost.limit > 0 { let usedPercent = max(0, min(100, (providerCost.used / providerCost.limit) * 100)) return RateWindow( usedPercent: usedPercent, windowMinutes: nil, resetsAt: providerCost.resetsAt, resetDescription: nil) } return self.primary ?? self.secondary default: return self.primary ?? self.secondary } } public func accountEmail(for provider: UsageProvider) -> String? { self.identity(for: provider)?.accountEmail } public func accountOrganization(for provider: UsageProvider) -> String? { self.identity(for: provider)?.accountOrganization } public func loginMethod(for provider: UsageProvider) -> String? { self.identity(for: provider)?.loginMethod } public var hasRateLimitWindows: Bool { self.primary != nil || self.secondary != nil || self.tertiary != nil || !(self.extraRateWindows?.isEmpty ?? true) } public func rateLimitsUnavailable(for provider: UsageProvider) -> Bool { UsageLimitsAvailability.resolve(provider: provider, snapshot: self).isUnavailable } public func withIdentity(_ identity: ProviderIdentitySnapshot?) -> UsageSnapshot { self.replacing(identity: .value(identity)) } public func withDataConfidence(_ dataConfidence: UsageDataConfidence) -> UsageSnapshot { self.replacing(dataConfidence: .value(dataConfidence)) } public func scoped(to provider: UsageProvider) -> UsageSnapshot { guard let identity else { return self } let scopedIdentity = identity.scoped(to: provider) if scopedIdentity.providerID == identity.providerID { return self } return self.withIdentity(scopedIdentity) } public func backfillingResetTimes(from cached: UsageSnapshot?, now: Date = .init()) -> UsageSnapshot { guard let cached else { return self } guard Self.identitiesMatch(self.identity, cached.identity) else { return self } let primary = self.primary?.backfillingResetTime(from: cached.primary, now: now) let secondary = self.secondary?.backfillingResetTime(from: cached.secondary, now: now) let tertiary = self.tertiary?.backfillingResetTime(from: cached.tertiary, now: now) if primary == self.primary, secondary == self.secondary, tertiary == self.tertiary { return self } return self.replacing( primary: .value(primary), secondary: .value(secondary), tertiary: .value(tertiary)) } private func orderedPerplexityFallbackWindows() -> [RateWindow] { let fallbackWindows = [self.tertiary, self.secondary].compactMap(\.self) let usableFallback = fallbackWindows.filter { $0.remainingPercent > 0 } let exhaustedFallback = fallbackWindows.filter { $0.remainingPercent <= 0 } return usableFallback + exhaustedFallback } private static func identitiesMatch(_ lhs: ProviderIdentitySnapshot?, _ rhs: ProviderIdentitySnapshot?) -> Bool { if lhs == nil, rhs == nil { return true } guard let lhs, let rhs else { return false } let lhsEmail = lhs.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) let rhsEmail = rhs.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) if let lhsEmail, let rhsEmail, !lhsEmail.isEmpty, !rhsEmail.isEmpty { return lhsEmail == rhsEmail } return true } private enum Replacement { case unchanged case value(Value) func resolving(_ current: Value) -> Value { switch self { case .unchanged: current case let .value(value): value } } } private func replacing( primary: Replacement = .unchanged, secondary: Replacement = .unchanged, tertiary: Replacement = .unchanged, extraRateWindows: Replacement<[NamedRateWindow]?> = .unchanged, codexResetCredits: Replacement = .unchanged, identity: Replacement = .unchanged, dataConfidence: Replacement = .unchanged) -> UsageSnapshot { UsageSnapshot( primary: primary.resolving(self.primary), secondary: secondary.resolving(self.secondary), tertiary: tertiary.resolving(self.tertiary), extraRateWindows: extraRateWindows.resolving(self.extraRateWindows), kiroUsage: self.kiroUsage, ampUsage: self.ampUsage, providerCost: self.providerCost, zaiUsage: self.zaiUsage, minimaxUsage: self.minimaxUsage, deepseekUsage: self.deepseekUsage, mimoUsage: self.mimoUsage, openRouterUsage: self.openRouterUsage, openAIAPIUsage: self.openAIAPIUsage, codexResetCredits: codexResetCredits.resolving(self.codexResetCredits), claudeAdminAPIUsage: self.claudeAdminAPIUsage, mistralUsage: self.mistralUsage, deepgramUsage: self.deepgramUsage, poeUsage: self.poeUsage, cursorRequests: self.cursorRequests, commandCodeSubscriptionEnrichmentUnavailable: self.commandCodeSubscriptionEnrichmentUnavailable, commandCodeHasSubscriptionPlan: self.commandCodeHasSubscriptionPlan, commandCodeMonthlyGrantDepleted: self.commandCodeMonthlyGrantDepleted, subscriptionExpiresAt: self.subscriptionExpiresAt, subscriptionRenewsAt: self.subscriptionRenewsAt, updatedAt: self.updatedAt, identity: identity.resolving(self.identity), dataConfidence: dataConfidence.resolving(self.dataConfidence)) } } public struct AccountInfo: Equatable, Sendable { public let email: String? public let plan: String? public var hasIdentity: Bool { self.email?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false || self.plan?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false } public init(email: String?, plan: String?) { self.email = email self.plan = plan } } public struct CodexCLIAccountSnapshot: Sendable { public let usage: UsageSnapshot? public let credits: CreditsSnapshot? public init(usage: UsageSnapshot?, credits: CreditsSnapshot?) { self.usage = usage self.credits = credits } } public enum UsageError: LocalizedError, Sendable { case noSessions case noRateLimitsFound case decodeFailed public var errorDescription: String? { switch self { case .noSessions: "No Codex sessions found yet. Run at least one Codex prompt first." case .noRateLimitsFound: "Found sessions, but no rate limit events yet." case .decodeFailed: "Could not parse Codex session log." } } public static func isNoRateLimitsFoundDescription(_ text: String?) -> Bool { text?.trimmingCharacters(in: .whitespacesAndNewlines) == UsageError.noRateLimitsFound.errorDescription } } public enum UsageLimitsAvailability: Equatable, Sendable { case available case unavailable public var isUnavailable: Bool { self == .unavailable } public static func resolve( provider: UsageProvider, snapshot: UsageSnapshot?, account: AccountInfo? = nil, lastErrorDescription: String? = nil) -> Self { if provider == .doubao { guard let snapshot, snapshot.identity(for: provider) != nil else { return .available } return snapshot.hasRateLimitWindows ? .available : .unavailable } guard provider == .codex else { return .available } if let snapshot { guard snapshot.identity(for: provider) != nil else { return .available } return snapshot.hasRateLimitWindows ? .available : .unavailable } guard UsageError.isNoRateLimitsFoundDescription(lastErrorDescription), account?.hasIdentity == true else { return .available } return .unavailable } } // MARK: - Codex RPC client (local process) private struct RPCAccountResponse: Decodable { let account: RPCAccountDetails? let requiresOpenaiAuth: Bool? } private enum RPCAccountDetails: Decodable { case apiKey case chatgpt(email: String, planType: String) enum CodingKeys: String, CodingKey { case type case email case planType } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(String.self, forKey: .type) switch type.lowercased() { case "apikey": self = .apiKey case "chatgpt": let email = try container.decodeIfPresent(String.self, forKey: .email) ?? "unknown" let plan = try container.decodeIfPresent(String.self, forKey: .planType) ?? "unknown" self = .chatgpt(email: email, planType: plan) default: throw DecodingError.dataCorruptedError( forKey: .type, in: container, debugDescription: "Unknown account type \(type)") } } } private struct RPCRateLimitsResponse: Decodable, Encodable { let rateLimits: RPCRateLimitSnapshot } private struct RPCRateLimitSnapshot: Decodable, Encodable { let primary: RPCRateLimitWindow? let secondary: RPCRateLimitWindow? let credits: RPCCreditsSnapshot? let planType: String? } private struct RPCRateLimitWindow: Decodable, Encodable { let usedPercent: Double let windowDurationMins: Int? let resetsAt: Int? } private struct RPCCreditsSnapshot: Decodable, Encodable { let hasCredits: Bool let unlimited: Bool let balance: String? } private struct RPCRateLimitsErrorBody: Decodable { let email: String? let planType: String? let rateLimit: CodexUsageResponse.RateLimitDetails? let credits: CodexUsageResponse.CreditDetails? enum CodingKeys: String, CodingKey { case email case planType = "plan_type" case rateLimit = "rate_limit" case credits } } enum RPCWireError: Error, LocalizedError { case startFailed(String) case requestFailed(String) case malformed(String) case timeout(method: String) var errorDescription: String? { switch self { case let .startFailed(message): "Codex not running. Try running a Codex command first. (\(message))" case let .requestFailed(message): "Codex connection failed: \(message)" case let .malformed(message): "Codex returned invalid data: \(message)" case let .timeout(method): "Codex RPC timed out waiting for `\(method)` reply." } } } typealias CodexExecutableResolver = @Sendable (_ environment: [String: String], _ executable: String) -> String? let defaultCodexExecutableResolver: CodexExecutableResolver = { environment, executable in BinaryLocator.resolveCodexBinary(env: environment) ?? TTYCommandRunner.which(executable) } /// RPC helper used on background tasks; safe because we confine it to the owning task. private final class CodexRPCClient: @unchecked Sendable { private static let log = CodexBarLog.logger(LogCategories.codexRPC) private let process = Process() private let stdinPipe = Pipe() private let stdoutPipe = Pipe() private let stderrPipe = Pipe() private let stdoutLineStream: AsyncStream private let stdoutLineContinuation: AsyncStream.Continuation private var nextID = 1 private let initializeTimeoutSeconds: TimeInterval private let requestTimeoutSeconds: TimeInterval private final class LineBuffer: @unchecked Sendable { private let lock = NSLock() private var buffer = Data() func appendAndDrainLines(_ data: Data) -> [Data] { self.lock.lock() defer { self.lock.unlock() } self.buffer.append(data) var out: [Data] = [] while let newline = self.buffer.firstIndex(of: 0x0A) { let lineData = Data(self.buffer[...Continuation! self.stdoutLineStream = AsyncStream { continuation in stdoutContinuation = continuation } self.stdoutLineContinuation = stdoutContinuation let resolvedExec = resolveExecutable(environment, executable) guard let resolvedExec else { Self.log.warning("Codex RPC binary not found", metadata: ["binary": executable]) throw CodexStatusProbeError.codexNotInstalled } var env = environment env["PATH"] = PathBuilder.effectivePATH( purposes: [.rpc, .nodeTooling], env: env) self.process.environment = env self.process.executableURL = URL(fileURLWithPath: "/usr/bin/env") self.process.arguments = [resolvedExec] + arguments self.process.standardInput = self.stdinPipe self.process.standardOutput = self.stdoutPipe self.process.standardError = self.stderrPipe if let message = CodexCLILaunchGate.shared.backgroundSkipMessage(binary: resolvedExec) { Self.log.warning("Codex RPC launch skipped after recent launch failure", metadata: ["binary": resolvedExec]) throw RPCWireError.startFailed(message) } do { try self.process.run() Self.log.debug("Codex RPC started", metadata: ["binary": resolvedExec]) } catch { let message = error.localizedDescription let throttled = CodexCLILaunchGate.shared.recordLaunchFailure(binary: resolvedExec, message: message) Self.log.warning("Codex RPC failed to start", metadata: ["error": message]) throw RPCWireError.startFailed(throttled ?? message) } let stdoutHandle = self.stdoutPipe.fileHandleForReading let stdoutLineContinuation = self.stdoutLineContinuation let stdoutBuffer = LineBuffer() stdoutHandle.readabilityHandler = { handle in let data = handle.availableData if data.isEmpty { handle.readabilityHandler = nil stdoutLineContinuation.finish() return } let lines = stdoutBuffer.appendAndDrainLines(data) for lineData in lines { stdoutLineContinuation.yield(lineData) } } let stderrHandle = self.stderrPipe.fileHandleForReading stderrHandle.readabilityHandler = { handle in let data = handle.availableData // When the child closes stderr, availableData returns empty and will keep re-firing; clear the handler // to avoid a busy read loop on the file-descriptor monitoring queue. if data.isEmpty { handle.readabilityHandler = nil return } guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { return } for line in text.split(whereSeparator: \.isNewline) { Self.debugWriteStderr("[codex stderr] \(line)\n") } } } func initialize(clientName: String, clientVersion: String) async throws { _ = try await self.request( method: "initialize", params: ["clientInfo": ["name": clientName, "version": clientVersion]], timeout: self.initializeTimeoutSeconds) try self.sendNotification(method: "initialized") } func fetchAccount() async throws -> RPCAccountResponse { let message = try await self.request(method: "account/read") return try self.decodeResult(from: message) } func fetchRateLimits() async throws -> RPCRateLimitsResponse { let message = try await self.request(method: "account/rateLimits/read") return try self.decodeResult(from: message) } func shutdown() { if self.process.isRunning { Self.log.debug("Codex RPC stopping") self.process.terminate() } } // MARK: - JSON-RPC helpers private struct SendableJSONMessage: @unchecked Sendable { let value: [String: Any] } private func request( method: String, params: [String: Any]? = nil, timeout: TimeInterval? = nil) async throws -> [String: Any] { let id = self.nextID self.nextID += 1 try self.sendRequest(id: id, method: method, params: params) let resolvedTimeout = timeout ?? self.requestTimeoutSeconds let wrapped = try await self.withTimeout(seconds: resolvedTimeout, method: method) { while true { let message = try await self.readNextMessage() if message["id"] == nil, let methodName = message["method"] as? String { Self.debugWriteStderr("[codex notify] \(methodName)\n") continue } guard let messageID = self.jsonID(message["id"]), messageID == id else { continue } if let error = message["error"] as? [String: Any], let messageText = error["message"] as? String { throw RPCWireError.requestFailed(messageText) } return SendableJSONMessage(value: message) } } return wrapped.value } private func withTimeout( seconds: TimeInterval, method: String, body: @escaping @Sendable () async throws -> T) async throws -> T { try await withThrowingTaskGroup(of: T.self) { group in group.addTask { try await body() } group.addTask { [weak self] in try await Task.sleep(for: .seconds(seconds)) self?.terminateProcessForTimeout(method: method) throw RPCWireError.timeout(method: method) } do { guard let result = try await group.next() else { throw RPCWireError.timeout(method: method) } group.cancelAll() return result } catch { group.cancelAll() throw error } } } private func terminateProcessForTimeout(method: String) { if self.process.isRunning { Self.log.warning("Codex RPC timed out on `\(method)`; terminating process") self.process.terminate() } } private func sendNotification(method: String, params: [String: Any]? = nil) throws { let paramsValue: Any = params ?? [:] try self.sendPayload(["method": method, "params": paramsValue]) } private func sendRequest(id: Int, method: String, params: [String: Any]?) throws { let paramsValue: Any = params ?? [:] let payload: [String: Any] = ["id": id, "method": method, "params": paramsValue] try self.sendPayload(payload) } private func sendPayload(_ payload: [String: Any]) throws { let data = try JSONSerialization.data(withJSONObject: payload) self.stdinPipe.fileHandleForWriting.write(data) self.stdinPipe.fileHandleForWriting.write(Data([0x0A])) } private func readNextMessage() async throws -> [String: Any] { for await lineData in self.stdoutLineStream { if lineData.isEmpty { continue } if let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] { return json } } throw RPCWireError.malformed("codex app-server closed stdout") } private func decodeResult(from message: [String: Any]) throws -> T { guard let result = message["result"] else { throw RPCWireError.malformed("missing result field") } let data = try JSONSerialization.data(withJSONObject: result) let decoder = JSONDecoder() return try decoder.decode(T.self, from: data) } private func jsonID(_ value: Any?) -> Int? { switch value { case let int as Int: int case let number as NSNumber: number.intValue default: nil } } } // MARK: - Public fetcher used by the app public struct UsageFetcher: Sendable { private let environment: [String: String] private let initializeTimeoutSeconds: TimeInterval private let requestTimeoutSeconds: TimeInterval private let codexExecutableResolver: CodexExecutableResolver private let codexArguments: [String] public init(environment: [String: String] = ProcessInfo.processInfo.environment) { self.environment = environment self.initializeTimeoutSeconds = 8.0 self.requestTimeoutSeconds = 3.0 self.codexExecutableResolver = defaultCodexExecutableResolver self.codexArguments = ["-s", "read-only", "-a", "untrusted", "app-server"] LoginShellPathCache.shared.captureOnce() } init( environment: [String: String], initializeTimeoutSeconds: TimeInterval, requestTimeoutSeconds: TimeInterval, codexArguments: [String] = ["-s", "read-only", "-a", "untrusted", "app-server"], codexExecutableResolver: @escaping CodexExecutableResolver = defaultCodexExecutableResolver) { self.environment = environment self.initializeTimeoutSeconds = initializeTimeoutSeconds self.requestTimeoutSeconds = requestTimeoutSeconds self.codexExecutableResolver = codexExecutableResolver self.codexArguments = codexArguments LoginShellPathCache.shared.captureOnce() } public func loadLatestUsage(keepCLISessionsAlive: Bool = false) async throws -> UsageSnapshot { _ = keepCLISessionsAlive guard let usage = try await self.loadLatestCLIAccountSnapshot().usage else { throw UsageError.noRateLimitsFound } return usage } public func loadLatestCLIAccountSnapshot() async throws -> CodexCLIAccountSnapshot { let rpc = try CodexRPCClient( arguments: self.codexArguments, environment: self.environment, initializeTimeoutSeconds: self.initializeTimeoutSeconds, requestTimeoutSeconds: self.requestTimeoutSeconds, resolveExecutable: self.codexExecutableResolver) defer { rpc.shutdown() } do { try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") // The app-server answers on a single stdout stream, so keep requests // serialized to avoid starving one reader when multiple awaiters race // for the same pipe. let limits = try await rpc.fetchRateLimits().rateLimits let account = try? await rpc.fetchAccount() let rateLimitsPlan = Self.normalizedCodexAccountField(limits.planType) let identity = ProviderIdentitySnapshot( providerID: .codex, accountEmail: account?.account.flatMap { details in if case let .chatgpt(email, _) = details { email } else { nil } }, accountOrganization: nil, loginMethod: account?.account.flatMap { details in if case let .chatgpt(_, plan) = details { plan } else { nil } } ?? rateLimitsPlan) let credits = Self.makeCredits(from: limits.credits) let shouldReturnUnavailableUsage = credits == nil || rateLimitsPlan != nil let usage = CodexReconciledState.fromCLI( primary: Self.makeWindow(from: limits.primary), secondary: Self.makeWindow(from: limits.secondary), identity: identity)? .toUsageSnapshot() ?? (shouldReturnUnavailableUsage ? Self.emptyCodexUsageSnapshotIfIdentified(identity: identity) : nil) guard usage != nil || credits != nil else { throw UsageError.noRateLimitsFound } return CodexCLIAccountSnapshot( usage: usage, credits: credits) } catch { let usage = Self.recoverUsageFromRPCError(error) let credits = Self.recoverCreditsFromRPCError(error) if usage != nil || credits != nil { return CodexCLIAccountSnapshot( usage: usage, credits: credits) } throw error } } public func loadLatestCredits(keepCLISessionsAlive: Bool = false) async throws -> CreditsSnapshot { _ = keepCLISessionsAlive guard let credits = try await self.loadLatestCLIAccountSnapshot().credits else { throw UsageError.noRateLimitsFound } return credits } public func debugRawRateLimits() async -> String { do { let rpc = try CodexRPCClient( arguments: self.codexArguments, environment: self.environment, initializeTimeoutSeconds: self.initializeTimeoutSeconds, requestTimeoutSeconds: self.requestTimeoutSeconds, resolveExecutable: self.codexExecutableResolver) defer { rpc.shutdown() } try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") let limits = try await rpc.fetchRateLimits() let data = try JSONEncoder().encode(limits) return String(data: data, encoding: .utf8) ?? "" } catch { return "Codex RPC probe failed: \(error)" } } public func loadAccountInfo() -> AccountInfo { let account = self.loadAuthBackedCodexAccount() return AccountInfo(email: account.email, plan: account.plan) } public func loadAuthBackedCodexAccount() -> CodexAuthBackedAccount { guard let credentials = try? CodexOAuthCredentialsStore.load(env: self.environment) else { return CodexAuthBackedAccount(identity: .unresolved, email: nil, plan: nil) } let payload = credentials.idToken.flatMap(Self.parseJWT) let authDict = payload?["https://api.openai.com/auth"] as? [String: Any] let profileDict = payload?["https://api.openai.com/profile"] as? [String: Any] let email = Self.normalizedCodexAccountField( (payload?["email"] as? String) ?? (profileDict?["email"] as? String)) let plan = Self.normalizedCodexAccountField( (authDict?["chatgpt_plan_type"] as? String) ?? (payload?["chatgpt_plan_type"] as? String)) let accountId = Self.normalizedCodexAccountField( credentials.accountId ?? (authDict?["chatgpt_account_id"] as? String) ?? (payload?["chatgpt_account_id"] as? String)) let identity = CodexIdentityResolver.resolve(accountId: accountId, email: email) return CodexAuthBackedAccount(identity: identity, email: email, plan: plan) } // MARK: - Helpers private static func makeWindow(from rpc: RPCRateLimitWindow?) -> RateWindow? { guard let rpc else { return nil } let resetsAtDate = rpc.resetsAt.map { Date(timeIntervalSince1970: TimeInterval($0)) } let resetDescription = resetsAtDate.map { UsageFormatter.resetDescription(from: $0) } return RateWindow( usedPercent: rpc.usedPercent, windowMinutes: rpc.windowDurationMins, resetsAt: resetsAtDate, resetDescription: resetDescription) } private static func makeWindow(from response: CodexUsageResponse.WindowSnapshot?) -> RateWindow? { guard let response else { return nil } let resetsAtDate = Date(timeIntervalSince1970: TimeInterval(response.resetAt)) return RateWindow( usedPercent: Double(response.usedPercent), windowMinutes: response.limitWindowSeconds / 60, resetsAt: resetsAtDate, resetDescription: UsageFormatter.resetDescription(from: resetsAtDate)) } private static func makeTTYWindow( percentLeft: Int?, windowMinutes: Int, resetsAt: Date?, resetDescription: String?) -> RateWindow? { guard let percentLeft else { return nil } return RateWindow( usedPercent: max(0, 100 - Double(percentLeft)), windowMinutes: windowMinutes, resetsAt: resetsAt, resetDescription: resetDescription) } private static func parseCredits(_ balance: String?) -> Double { guard let balance, let val = Double(balance) else { return 0 } return val } private static func makeCredits(from rpc: RPCCreditsSnapshot?) -> CreditsSnapshot? { guard let rpc else { return nil } return CreditsSnapshot(remaining: self.parseCredits(rpc.balance), events: [], updatedAt: Date()) } private static func emptyCodexUsageSnapshotIfIdentified(identity: ProviderIdentitySnapshot) -> UsageSnapshot? { guard identity.accountEmail != nil || identity.loginMethod != nil else { return nil } return UsageSnapshot( primary: nil, secondary: nil, tertiary: nil, updatedAt: Date(), identity: identity) } private static func recoverUsageFromRPCError(_ error: Error) -> UsageSnapshot? { guard let body = self.decodeRateLimitsErrorBody(from: error) else { return nil } let identity = ProviderIdentitySnapshot( providerID: .codex, accountEmail: self.normalizedCodexAccountField(body.email), accountOrganization: nil, loginMethod: self.normalizedCodexAccountField(body.planType)) guard let state = CodexReconciledState.fromCLI( primary: self.makeWindow(from: body.rateLimit?.primaryWindow), secondary: self.makeWindow(from: body.rateLimit?.secondaryWindow), identity: identity) else { return nil } if body.rateLimit?.hasWindowDecodeFailure == true, state.session == nil { return nil } return state.toUsageSnapshot() } private static func recoverCreditsFromRPCError(_ error: Error) -> CreditsSnapshot? { guard let credits = self.decodeRateLimitsErrorBody(from: error)?.credits else { return nil } guard let remaining = credits.balance else { return nil } return CreditsSnapshot(remaining: remaining, events: [], updatedAt: Date()) } private static func decodeRateLimitsErrorBody(from error: Error) -> RPCRateLimitsErrorBody? { guard case let RPCWireError.requestFailed(message) = error else { return nil } guard let json = self.extractJSONObject(after: "body=", in: message) else { return nil } guard let data = json.data(using: .utf8) else { return nil } return try? JSONDecoder().decode(RPCRateLimitsErrorBody.self, from: data) } private static func extractJSONObject(after marker: String, in text: String) -> String? { guard let markerRange = text.range(of: marker) else { return nil } let suffix = text[markerRange.upperBound...] guard let start = suffix.firstIndex(of: "{") else { return nil } var depth = 0 var inString = false var isEscaped = false for index in suffix[start...].indices { let character = suffix[index] if inString { if isEscaped { isEscaped = false } else if character == "\\" { isEscaped = true } else if character == "\"" { inString = false } continue } switch character { case "\"": inString = true case "{": depth += 1 case "}": depth -= 1 if depth == 0 { return String(suffix[start...index]) } default: break } } return nil } private static func normalizedCodexAccountField(_ value: String?) -> String? { guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil } return value } public static func parseJWT(_ token: String) -> [String: Any]? { let parts = token.split(separator: ".") guard parts.count >= 2 else { return nil } let payloadPart = parts[1] var padded = String(payloadPart) .replacingOccurrences(of: "-", with: "+") .replacingOccurrences(of: "_", with: "/") while padded.count % 4 != 0 { padded.append("=") } guard let data = Data(base64Encoded: padded) else { return nil } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } return json } } #if DEBUG extension UsageFetcher { static func _mapCodexRPCLimitsForTesting( primary: (usedPercent: Double, windowMinutes: Int, resetsAt: Int?)?, secondary: (usedPercent: Double, windowMinutes: Int, resetsAt: Int?)?, planType: String? = nil) throws -> UsageSnapshot { let identity = ProviderIdentitySnapshot( providerID: .codex, accountEmail: nil, accountOrganization: nil, loginMethod: self.normalizedCodexAccountField(planType)) guard let state = CodexReconciledState.fromCLI( primary: primary.map(self.makeTestingWindow), secondary: secondary.map(self.makeTestingWindow), identity: identity) else { if let usage = self.emptyCodexUsageSnapshotIfIdentified(identity: identity) { return usage } throw UsageError.noRateLimitsFound } return state.toUsageSnapshot() } static func _mapCodexStatusForTesting(_ status: CodexStatusSnapshot) throws -> UsageSnapshot { guard let state = CodexReconciledState.fromCLI( primary: self.makeTTYWindow( percentLeft: status.fiveHourPercentLeft, windowMinutes: 300, resetsAt: status.fiveHourResetsAt, resetDescription: status.fiveHourResetDescription), secondary: self.makeTTYWindow( percentLeft: status.weeklyPercentLeft, windowMinutes: 10080, resetsAt: status.weeklyResetsAt, resetDescription: status.weeklyResetDescription), identity: nil) else { throw UsageError.noRateLimitsFound } return state.toUsageSnapshot() } public static func _recoverCodexRPCUsageFromErrorForTesting(_ message: String) -> UsageSnapshot? { self.recoverUsageFromRPCError(RPCWireError.requestFailed(message)) } public static func _recoverCodexRPCCreditsFromErrorForTesting(_ message: String) -> CreditsSnapshot? { self.recoverCreditsFromRPCError(RPCWireError.requestFailed(message)) } private static func makeTestingWindow( _ value: (usedPercent: Double, windowMinutes: Int, resetsAt: Int?)) -> RateWindow { let resetsAt = value.resetsAt.map { Date(timeIntervalSince1970: TimeInterval($0)) } return RateWindow( usedPercent: value.usedPercent, windowMinutes: value.windowMinutes, resetsAt: resetsAt, resetDescription: resetsAt.map { UsageFormatter.resetDescription(from: $0) }) } } #endif