1670 lines
68 KiB
Swift
1670 lines
68 KiB
Swift
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?
|
|
/// Whether this window was synthesized to stand in for a quota lane the provider did not actually
|
|
/// report, rather than being a real zero-usage window.
|
|
///
|
|
/// Claude web returns a `0%` five-hour window when `five_hour` is `null` (an account with no live
|
|
/// session but a real weekly lane). Lane classifiers — e.g. the combined "Session + Weekly" menu-bar
|
|
/// metric — must treat such a window as "no session lane present" instead of surfacing a phantom
|
|
/// `5h 0%`/`5h 100%` session. A genuine session, even one freshly reset to 0%, is NOT a placeholder.
|
|
/// Missing values decode as `false` for older cached payloads.
|
|
public let isSyntheticPlaceholder: Bool
|
|
|
|
public init(
|
|
usedPercent: Double,
|
|
windowMinutes: Int?,
|
|
resetsAt: Date?,
|
|
resetDescription: String?,
|
|
nextRegenPercent: Double? = nil,
|
|
isSyntheticPlaceholder: Bool = false)
|
|
{
|
|
self.usedPercent = usedPercent
|
|
self.windowMinutes = windowMinutes
|
|
self.resetsAt = resetsAt
|
|
self.resetDescription = resetDescription
|
|
self.nextRegenPercent = nextRegenPercent
|
|
self.isSyntheticPlaceholder = isSyntheticPlaceholder
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case usedPercent
|
|
case windowMinutes
|
|
case resetsAt
|
|
case resetDescription
|
|
case nextRegenPercent
|
|
case isSyntheticPlaceholder
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.usedPercent = try container.decode(Double.self, forKey: .usedPercent)
|
|
self.windowMinutes = try container.decodeIfPresent(Int.self, forKey: .windowMinutes)
|
|
self.resetsAt = try container.decodeIfPresent(Date.self, forKey: .resetsAt)
|
|
self.resetDescription = try container.decodeIfPresent(String.self, forKey: .resetDescription)
|
|
self.nextRegenPercent = try container.decodeIfPresent(Double.self, forKey: .nextRegenPercent)
|
|
self.isSyntheticPlaceholder =
|
|
try container.decodeIfPresent(Bool.self, forKey: .isSyntheticPlaceholder) ?? false
|
|
}
|
|
|
|
public func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(self.usedPercent, forKey: .usedPercent)
|
|
try container.encodeIfPresent(self.windowMinutes, forKey: .windowMinutes)
|
|
try container.encodeIfPresent(self.resetsAt, forKey: .resetsAt)
|
|
try container.encodeIfPresent(self.resetDescription, forKey: .resetDescription)
|
|
try container.encodeIfPresent(self.nextRegenPercent, forKey: .nextRegenPercent)
|
|
// Only persist the flag when set, keeping payloads identical for the common (real-window) case.
|
|
if self.isSyntheticPlaceholder {
|
|
try container.encode(true, forKey: .isSyntheticPlaceholder)
|
|
}
|
|
}
|
|
|
|
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,
|
|
// Preserve the placeholder marker: backfilling a stale reset onto Claude web's null-session
|
|
// placeholder must not let it masquerade as a real session lane.
|
|
isSyntheticPlaceholder: self.isSyntheticPlaceholder)
|
|
}
|
|
}
|
|
|
|
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 sakanaPayAsYouGo: SakanaPayAsYouGoSnapshot?
|
|
public let crossModelUsage: CrossModelUsageSnapshot?
|
|
public let clawRouterUsage: ClawRouterUsageSnapshot?
|
|
public let sub2APIUsage: Sub2APIUsageDetails?
|
|
public let wayfinderUsage: WayfinderUsageSnapshot?
|
|
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 sakanaPayAsYouGo
|
|
case crossModelUsage
|
|
case clawRouterUsage
|
|
case sub2APIUsage
|
|
case wayfinderUsage
|
|
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,
|
|
sakanaPayAsYouGo: SakanaPayAsYouGoSnapshot? = nil,
|
|
crossModelUsage: CrossModelUsageSnapshot? = nil,
|
|
clawRouterUsage: ClawRouterUsageSnapshot? = nil,
|
|
sub2APIUsage: Sub2APIUsageDetails? = nil,
|
|
wayfinderUsage: WayfinderUsageSnapshot? = 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.sakanaPayAsYouGo = sakanaPayAsYouGo
|
|
self.crossModelUsage = crossModelUsage
|
|
self.clawRouterUsage = clawRouterUsage
|
|
self.sub2APIUsage = sub2APIUsage
|
|
self.wayfinderUsage = wayfinderUsage
|
|
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.sakanaPayAsYouGo = try container.decodeIfPresent(
|
|
SakanaPayAsYouGoSnapshot.self,
|
|
forKey: .sakanaPayAsYouGo)
|
|
self.crossModelUsage = try container.decodeIfPresent(CrossModelUsageSnapshot.self, forKey: .crossModelUsage)
|
|
self.clawRouterUsage = try container.decodeIfPresent(ClawRouterUsageSnapshot.self, forKey: .clawRouterUsage)
|
|
self.sub2APIUsage = try container.decodeIfPresent(Sub2APIUsageDetails.self, forKey: .sub2APIUsage)
|
|
self.wayfinderUsage = try container.decodeIfPresent(WayfinderUsageSnapshot.self, forKey: .wayfinderUsage)
|
|
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.sakanaPayAsYouGo, forKey: .sakanaPayAsYouGo)
|
|
try container.encodeIfPresent(self.crossModelUsage, forKey: .crossModelUsage)
|
|
try container.encodeIfPresent(self.clawRouterUsage, forKey: .clawRouterUsage)
|
|
try container.encodeIfPresent(self.sub2APIUsage, forKey: .sub2APIUsage)
|
|
try container.encodeIfPresent(self.wayfinderUsage, forKey: .wayfinderUsage)
|
|
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 }
|
|
// Amp's percentage-based daily quota supersedes the legacy rolling-replenishment cadence. Do not attach
|
|
// that older exact reset to the new daily window; other providers retain the shared backfill behavior.
|
|
let cachedPrimary: RateWindow? = if self.identity?.providerID == .amp,
|
|
self.primary?.resetDescription == "resets daily"
|
|
{
|
|
nil
|
|
} else {
|
|
cached.primary
|
|
}
|
|
let primary = self.primary?.backfillingResetTime(from: cachedPrimary, 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<Value> {
|
|
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<RateWindow?> = .unchanged,
|
|
secondary: Replacement<RateWindow?> = .unchanged,
|
|
tertiary: Replacement<RateWindow?> = .unchanged,
|
|
extraRateWindows: Replacement<[NamedRateWindow]?> = .unchanged,
|
|
codexResetCredits: Replacement<CodexRateLimitResetCreditsSnapshot?> = .unchanged,
|
|
identity: Replacement<ProviderIdentitySnapshot?> = .unchanged,
|
|
dataConfidence: Replacement<UsageDataConfidence> = .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,
|
|
sakanaPayAsYouGo: self.sakanaPayAsYouGo,
|
|
crossModelUsage: self.crossModelUsage,
|
|
clawRouterUsage: self.clawRouterUsage,
|
|
sub2APIUsage: self.sub2APIUsage,
|
|
wayfinderUsage: self.wayfinderUsage,
|
|
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 let identity: ProviderIdentitySnapshot?
|
|
|
|
public init(
|
|
usage: UsageSnapshot?,
|
|
credits: CreditsSnapshot?,
|
|
identity: ProviderIdentitySnapshot? = nil)
|
|
{
|
|
self.usage = usage
|
|
self.credits = credits
|
|
self.identity = identity
|
|
}
|
|
}
|
|
|
|
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 == .claude {
|
|
guard snapshot == nil else { return .available }
|
|
return ClaudeStatusProbe.isSubscriptionQuotaUnavailableDescription(lastErrorDescription)
|
|
? .unavailable
|
|
: .available
|
|
}
|
|
|
|
if provider == .doubao || provider == .antigravity {
|
|
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
|
|
let rateLimitsByLimitId: [String: RPCRateLimitSnapshot]?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case rateLimits
|
|
case rateLimitsByLimitId
|
|
case rateLimitsByLimitIdSnake = "rate_limits_by_limit_id"
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.rateLimits = try container.decode(RPCRateLimitSnapshot.self, forKey: .rateLimits)
|
|
self.rateLimitsByLimitId = (try? container.decodeIfPresent(
|
|
[String: RPCRateLimitSnapshot].self,
|
|
forKey: .rateLimitsByLimitId))
|
|
?? (try? container.decodeIfPresent(
|
|
[String: RPCRateLimitSnapshot].self,
|
|
forKey: .rateLimitsByLimitIdSnake))
|
|
}
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(self.rateLimits, forKey: .rateLimits)
|
|
try container.encodeIfPresent(self.rateLimitsByLimitId, forKey: .rateLimitsByLimitId)
|
|
}
|
|
}
|
|
|
|
private struct RPCRateLimitSnapshot: Decodable, Encodable {
|
|
let limitId: String?
|
|
let limitName: String?
|
|
let primary: RPCRateLimitWindow?
|
|
let secondary: RPCRateLimitWindow?
|
|
let credits: RPCCreditsSnapshot?
|
|
let individualLimit: RPCSpendControlLimitSnapshot?
|
|
let planType: String?
|
|
let rateLimitReachedType: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case limitId
|
|
case limitIdSnake = "limit_id"
|
|
case limitName
|
|
case limitNameSnake = "limit_name"
|
|
case primary
|
|
case secondary
|
|
case credits
|
|
case individualLimit
|
|
case individualLimitSnake = "individual_limit"
|
|
case planType
|
|
case planTypeSnake = "plan_type"
|
|
case rateLimitReachedType
|
|
case rateLimitReachedTypeSnake = "rate_limit_reached_type"
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.limitId = (try? container.decodeIfPresent(String.self, forKey: .limitId))
|
|
?? (try? container.decodeIfPresent(String.self, forKey: .limitIdSnake))
|
|
self.limitName = (try? container.decodeIfPresent(String.self, forKey: .limitName))
|
|
?? (try? container.decodeIfPresent(String.self, forKey: .limitNameSnake))
|
|
self.primary = try? container.decodeIfPresent(RPCRateLimitWindow.self, forKey: .primary)
|
|
self.secondary = try? container.decodeIfPresent(RPCRateLimitWindow.self, forKey: .secondary)
|
|
self.credits = try? container.decodeIfPresent(RPCCreditsSnapshot.self, forKey: .credits)
|
|
self.individualLimit = (try? container.decodeIfPresent(
|
|
RPCSpendControlLimitSnapshot.self,
|
|
forKey: .individualLimit))
|
|
?? (try? container.decodeIfPresent(RPCSpendControlLimitSnapshot.self, forKey: .individualLimitSnake))
|
|
self.planType = (try? container.decodeIfPresent(String.self, forKey: .planType))
|
|
?? (try? container.decodeIfPresent(String.self, forKey: .planTypeSnake))
|
|
self.rateLimitReachedType = (try? container.decodeIfPresent(String.self, forKey: .rateLimitReachedType))
|
|
?? (try? container.decodeIfPresent(String.self, forKey: .rateLimitReachedTypeSnake))
|
|
}
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encodeIfPresent(self.limitId, forKey: .limitId)
|
|
try container.encodeIfPresent(self.limitName, forKey: .limitName)
|
|
try container.encodeIfPresent(self.primary, forKey: .primary)
|
|
try container.encodeIfPresent(self.secondary, forKey: .secondary)
|
|
try container.encodeIfPresent(self.credits, forKey: .credits)
|
|
try container.encodeIfPresent(self.individualLimit, forKey: .individualLimit)
|
|
try container.encodeIfPresent(self.planType, forKey: .planType)
|
|
try container.encodeIfPresent(self.rateLimitReachedType, forKey: .rateLimitReachedType)
|
|
}
|
|
}
|
|
|
|
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 RPCSpendControlLimitSnapshot: Decodable, Encodable {
|
|
let limit: Double?
|
|
let used: Double?
|
|
let remainingPercent: Double?
|
|
let resetsAt: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case limit
|
|
case used
|
|
case remainingPercent
|
|
case remainingPercentSnake = "remaining_percent"
|
|
case resetsAt
|
|
case resetsAtSnake = "resets_at"
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.limit = Self.decodeFlexibleDouble(container, forKey: .limit)
|
|
self.used = Self.decodeFlexibleDouble(container, forKey: .used)
|
|
self.remainingPercent = Self.decodeFlexibleDouble(container, forKey: .remainingPercent)
|
|
?? Self.decodeFlexibleDouble(container, forKey: .remainingPercentSnake)
|
|
self.resetsAt = Self.decodeFlexibleInt(container, forKey: .resetsAt)
|
|
?? Self.decodeFlexibleInt(container, forKey: .resetsAtSnake)
|
|
}
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encodeIfPresent(self.limit, forKey: .limit)
|
|
try container.encodeIfPresent(self.used, forKey: .used)
|
|
try container.encodeIfPresent(self.remainingPercent, forKey: .remainingPercent)
|
|
try container.encodeIfPresent(self.resetsAt, forKey: .resetsAt)
|
|
}
|
|
|
|
private static func decodeFlexibleDouble(
|
|
_ container: KeyedDecodingContainer<CodingKeys>,
|
|
forKey key: CodingKeys) -> Double?
|
|
{
|
|
if let value = try? container.decodeIfPresent(Double.self, forKey: key) {
|
|
return value
|
|
}
|
|
if let value = try? container.decodeIfPresent(Int.self, forKey: key) {
|
|
return Double(value)
|
|
}
|
|
if let value = try? container.decodeIfPresent(String.self, forKey: key) {
|
|
return Double(value.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func decodeFlexibleInt(
|
|
_ container: KeyedDecodingContainer<CodingKeys>,
|
|
forKey key: CodingKeys) -> Int?
|
|
{
|
|
if let value = try? container.decodeIfPresent(Int.self, forKey: key) {
|
|
return value
|
|
}
|
|
if let value = try? container.decodeIfPresent(Double.self, forKey: key) {
|
|
return Int(value)
|
|
}
|
|
if let value = try? container.decodeIfPresent(String.self, forKey: key) {
|
|
return Int(value.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
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<Data>
|
|
private let stdoutLineContinuation: AsyncStream<Data>.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[..<newline])
|
|
self.buffer.removeSubrange(...newline)
|
|
if !lineData.isEmpty {
|
|
out.append(lineData)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
}
|
|
|
|
private static func debugWriteStderr(_ message: String) {
|
|
#if !os(Linux)
|
|
fputs(message, stderr)
|
|
#endif
|
|
}
|
|
|
|
init(
|
|
executable: String = "codex",
|
|
arguments: [String] = ["-s", "read-only", "-a", "untrusted", "app-server"],
|
|
environment: [String: String] = ProcessInfo.processInfo.environment,
|
|
initializeTimeoutSeconds: TimeInterval = 8.0,
|
|
requestTimeoutSeconds: TimeInterval = 3.0,
|
|
resolveExecutable: CodexExecutableResolver = defaultCodexExecutableResolver) throws
|
|
{
|
|
self.initializeTimeoutSeconds = initializeTimeoutSeconds
|
|
self.requestTimeoutSeconds = requestTimeoutSeconds
|
|
var stdoutContinuation: AsyncStream<Data>.Continuation!
|
|
self.stdoutLineStream = AsyncStream<Data> { 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<T: Sendable>(
|
|
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<T: Decodable>(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 limitsResponse = try await rpc.fetchRateLimits()
|
|
let limits = limitsResponse.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, rateLimitsByLimitId: limitsResponse.rateLimitsByLimitId)
|
|
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,
|
|
identity: identity)
|
|
} catch {
|
|
let usage = Self.recoverUsageFromRPCError(error)
|
|
let credits = Self.recoverCreditsFromRPCError(error)
|
|
if usage != nil || credits != nil {
|
|
return CodexCLIAccountSnapshot(
|
|
usage: usage,
|
|
credits: credits,
|
|
identity: usage?.identity)
|
|
}
|
|
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) ?? "<unprintable>"
|
|
} 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 limits: RPCRateLimitSnapshot,
|
|
rateLimitsByLimitId: [String: RPCRateLimitSnapshot]? = nil) -> CreditsSnapshot?
|
|
{
|
|
let updatedAt = Date()
|
|
let balance = limits.credits.map { self.parseCredits($0.balance) }
|
|
let creditLimit = self.codexCreditLimit(
|
|
from: limits,
|
|
rateLimitsByLimitId: rateLimitsByLimitId,
|
|
updatedAt: updatedAt)
|
|
guard balance != nil || creditLimit != nil else { return nil }
|
|
return CreditsSnapshot(
|
|
remaining: balance ?? 0,
|
|
events: [],
|
|
updatedAt: updatedAt,
|
|
codexCreditLimit: creditLimit)
|
|
}
|
|
|
|
private static func codexCreditLimit(
|
|
from limits: RPCRateLimitSnapshot,
|
|
rateLimitsByLimitId: [String: RPCRateLimitSnapshot]?,
|
|
updatedAt: Date) -> CodexCreditLimitSnapshot?
|
|
{
|
|
let candidates = [limits] + (rateLimitsByLimitId?.values.sorted {
|
|
($0.limitName ?? $0.limitId ?? "") < ($1.limitName ?? $1.limitId ?? "")
|
|
} ?? [])
|
|
for candidate in candidates {
|
|
if let limit = self.codexCreditLimit(from: candidate, updatedAt: updatedAt) {
|
|
return limit
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func codexCreditLimit(
|
|
from snapshot: RPCRateLimitSnapshot,
|
|
updatedAt: Date) -> CodexCreditLimitSnapshot?
|
|
{
|
|
guard let individualLimit = snapshot.individualLimit else { return nil }
|
|
guard let limit = individualLimit.limit, limit > 0 else { return nil }
|
|
let used: Double = if let used = individualLimit.used {
|
|
used
|
|
} else if let remainingPercent = individualLimit.remainingPercent {
|
|
limit * max(0, min(100, 100 - remainingPercent)) / 100
|
|
} else {
|
|
0
|
|
}
|
|
let remainingPercent = individualLimit.remainingPercent ?? max(0, min(100, 100 - (used / limit * 100)))
|
|
let resetsAt = individualLimit.resetsAt.flatMap { value -> Date? in
|
|
guard value > 0 else { return nil }
|
|
return Date(timeIntervalSince1970: TimeInterval(value))
|
|
}
|
|
return CodexCreditLimitSnapshot(
|
|
title: self.codexCreditLimitTitle(from: snapshot.limitName),
|
|
used: used,
|
|
limit: limit,
|
|
remainingPercent: remainingPercent,
|
|
resetsAt: resetsAt,
|
|
updatedAt: updatedAt)
|
|
}
|
|
|
|
private static func codexCreditLimitTitle(from limitName: String?) -> String {
|
|
let trimmed = limitName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
if trimmed.isEmpty {
|
|
return "Monthly credit limit"
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
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
|