Files
steipete--codexbar/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift
T
2026-07-13 12:22:33 +08:00

599 lines
22 KiB
Swift

import CodexBarCore
import Foundation
struct CodexUIErrorMapper {
private static var codexCLINotSignedInMessage: String {
L("Codex CLI is not signed in. Run `codex login --device-auth`, then refresh.")
}
static func userFacingMessage(_ raw: String?) -> String? {
guard let raw, !raw.isEmpty else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let lower = trimmed.lowercased()
if self.isAlreadyUserFacing(lower: lower) {
return trimmed
}
if let cachedMessage = self.cachedMessage(raw: trimmed, lower: lower) {
return cachedMessage
}
if self.looksCodexCLIMissing(lower: lower) {
return L("Codex CLI missing. Install via `npm i -g @openai/codex` (or bun install) and restart.")
}
if self.looksCodexCLILoginRequired(lower: lower) {
return self.codexCLINotSignedInMessage
}
if self.looksExpired(lower: lower) {
return L("Codex session expired. Sign in again.")
}
if lower.contains("frame load interrupted") {
return L("OpenAI web refresh was interrupted. Refresh OpenAI cookies and try again.")
}
if self.looksOpenAIWebTimeout(lower: lower) {
return L("OpenAI web refresh timed out. Refresh OpenAI cookies and try again.")
}
if self.looksOpenAIWebNetworkError(lower: lower) {
return L(
"OpenAI web refresh hit a network error. " +
"Check your connection, then refresh OpenAI cookies and try again.")
}
if self.looksInternalTransport(lower: lower) {
return L("Codex usage is temporarily unavailable. Try refreshing.")
}
return trimmed
}
private static func cachedMessage(raw: String, lower: String) -> String? {
let cachedMarker = " Cached values from "
guard let suffixRange = raw.range(of: cachedMarker) else { return nil }
let rawPrefix = String(raw[..<suffixRange.lowerBound])
let stamp = self.cachedStamp(raw: raw, suffixRange: suffixRange, marker: cachedMarker)
if lower.hasPrefix("last codex credits refresh failed:"),
let base = self.userFacingMessage(self.failureMessage(
rawPrefix: rawPrefix,
prefix: "Last Codex credits refresh failed:"))
{
return "\(base) \(L("Cached values from %@.", stamp))"
}
if lower.hasPrefix("last openai dashboard refresh failed:"),
let base = self.userFacingMessage(self.failureMessage(
rawPrefix: rawPrefix,
prefix: "Last OpenAI dashboard refresh failed:"))
{
return "\(base) \(L("Cached values from %@.", stamp))"
}
return nil
}
private static func failureMessage(rawPrefix: String, prefix: String) -> String {
let droppedPrefix = if rawPrefix.lowercased().hasPrefix(prefix.lowercased()) {
String(rawPrefix.dropFirst(prefix.count))
} else {
rawPrefix
}
var message = droppedPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
if message.hasSuffix(".") {
message.removeLast()
}
return message
}
private static func cachedStamp(raw: String, suffixRange: Range<String.Index>, marker: String) -> String {
let start = raw.index(suffixRange.lowerBound, offsetBy: marker.count)
var stamp = String(raw[start...]).trimmingCharacters(in: .whitespacesAndNewlines)
if stamp.hasSuffix(".") {
stamp.removeLast()
}
return stamp
}
private static func isAlreadyUserFacing(lower: String) -> Bool {
lower.contains("openai cookies are for")
|| lower.contains("sign in to chatgpt.com")
|| lower.contains("requires a signed-in chatgpt.com session")
|| lower.contains("managed codex account data is unavailable")
|| lower.contains("selected managed codex account is unavailable")
|| lower.contains("codex credits are still loading")
|| lower.contains("codex account changed; importing browser cookies")
|| lower.contains("codex cli is not signed in.")
|| lower.contains("codex session expired. sign in again.")
|| lower.contains("openai web refresh timed out. refresh openai cookies and try again.")
|| lower.contains(
"openai web refresh hit a network error. "
+ "check your connection, then refresh openai cookies and try again.")
|| lower.contains("codex usage is temporarily unavailable. try refreshing.")
}
private static func looksCodexCLIMissing(lower: String) -> Bool {
lower.contains("codex cli missing")
|| lower.contains("codex cli not found")
|| lower.contains("missing cli codex")
|| lower.contains("missing cli 'codex'")
|| lower.contains("missing cli \"codex\"")
|| (lower.contains("binary not found") && lower.contains("codex"))
}
private static func looksCodexCLILoginRequired(lower: String) -> Bool {
lower.contains("codex account authentication required")
|| lower.contains("account authentication required to read rate limits")
|| lower.contains("requiresopenaiauth")
}
private static func looksExpired(lower: String) -> Bool {
lower.contains("token_expired")
|| lower.contains("authentication token is expired")
|| lower.contains("oauth token has expired")
|| lower.contains("provided authentication token is expired")
|| lower.contains("please try signing in again")
|| lower.contains("please sign in again")
|| (lower.contains("401") && lower.contains("unauthorized"))
}
private static func looksInternalTransport(lower: String) -> Bool {
lower.contains("codex connection failed")
|| lower.contains("failed to fetch codex rate limits")
|| lower.contains("/backend-api/")
|| lower.contains("content-type=")
|| lower.contains("body={")
|| lower.contains("body=")
|| lower.contains("get https://")
|| lower.contains("get http://")
|| lower.contains("returned invalid data")
}
private static func looksOpenAIWebTimeout(lower: String) -> Bool {
lower.contains("nsurlerrordomain")
&& (lower.contains("timed out") || lower.contains("error -1001"))
}
private static func looksOpenAIWebNetworkError(lower: String) -> Bool {
lower.contains("nsurlerrordomain")
}
}
struct CodexConsumerProjection {
enum Surface {
case liveCard
case overrideCard
case widget
case menuBar
}
enum RateLane: String {
case session
case weekly
}
enum SupplementalMetric: String {
case codeReview
}
struct PlanUtilizationLane {
let role: PlanUtilizationSeriesName
let window: RateWindow
}
enum DashboardVisibility {
case hidden
case displayOnly
case attached
}
struct CreditsProjection {
let snapshot: CreditsSnapshot?
let userFacingError: String?
var remaining: Double? {
self.snapshot?.codexCreditLimit?.remaining ?? self.snapshot?.remaining
}
}
struct UserFacingErrors {
let usage: String?
let credits: String?
let dashboard: String?
}
struct Context {
let snapshot: UsageSnapshot?
let rawUsageError: String?
let liveCredits: CreditsSnapshot?
let rawCreditsError: String?
let liveDashboard: OpenAIDashboardSnapshot?
let rawDashboardError: String?
let dashboardAttachmentAuthorized: Bool
let dashboardRequiresLogin: Bool
let now: Date
}
enum MenuBarFallback {
case none
case creditsBalance
}
let visibleRateLanes: [RateLane]
let supplementalMetrics: [SupplementalMetric]
let planUtilizationLanes: [PlanUtilizationLane]
let dashboardVisibility: DashboardVisibility
let credits: CreditsProjection?
let menuBarFallback: MenuBarFallback
let userFacingErrors: UserFacingErrors
let canShowBuyCredits: Bool
let hasUsageBreakdown: Bool
let hasCreditsHistory: Bool
private let rateWindowsByLane: [RateLane: RateWindow]
private let codeReviewRemainingPercent: Double?
private let codeReviewLimit: RateWindow?
private let evaluationTime: Date
static func make(surface: Surface, context: Context) -> CodexConsumerProjection {
let allowsLiveAdjuncts = surface != .overrideCard
let dashboardVisibility = self.dashboardVisibility(surface: surface, context: context)
let dashboard = allowsLiveAdjuncts && dashboardVisibility != .hidden ? context.liveDashboard : nil
let rateWindowsByLane = self.rateWindowsByLane(snapshot: context.snapshot)
let visibleRateLanes = self.visibleRateLanes(from: rateWindowsByLane, snapshot: context.snapshot)
let planUtilizationLanes = self.planUtilizationLanes(from: rateWindowsByLane)
let creditsProjection: CreditsProjection? = if allowsLiveAdjuncts,
context.liveCredits != nil || context.rawCreditsError != nil
{
CreditsProjection(
snapshot: context.liveCredits,
userFacingError: CodexUIErrorMapper.userFacingMessage(context.rawCreditsError))
} else {
nil
}
let userFacingErrors = UserFacingErrors(
usage: CodexUIErrorMapper.userFacingMessage(context.rawUsageError),
credits: allowsLiveAdjuncts ? CodexUIErrorMapper.userFacingMessage(context.rawCreditsError) : nil,
dashboard: allowsLiveAdjuncts ? CodexUIErrorMapper.userFacingMessage(context.rawDashboardError) : nil)
let supplementalMetrics: [SupplementalMetric] = if surface == .liveCard,
dashboardVisibility == .attached,
dashboard?.codeReviewRemainingPercent != nil
{
[.codeReview]
} else {
[]
}
let displayableUsageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices(
from: dashboard?.usageBreakdown ?? [])
let canShowBuyCredits = surface == .liveCard
let hasUsageBreakdown = surface == .liveCard
&& dashboardVisibility == .attached
&& !displayableUsageBreakdown.isEmpty
let hasCreditsHistory = surface == .liveCard
&& dashboardVisibility == .attached
&& !(dashboard?.dailyBreakdown ?? []).isEmpty
return CodexConsumerProjection(
visibleRateLanes: visibleRateLanes,
supplementalMetrics: supplementalMetrics,
planUtilizationLanes: planUtilizationLanes,
dashboardVisibility: dashboardVisibility,
credits: creditsProjection,
menuBarFallback: self.menuBarFallback(
creditsRemaining: creditsProjection?.remaining,
rateWindowsByLane: rateWindowsByLane,
evaluationTime: context.now),
userFacingErrors: userFacingErrors,
canShowBuyCredits: canShowBuyCredits,
hasUsageBreakdown: hasUsageBreakdown,
hasCreditsHistory: hasCreditsHistory,
rateWindowsByLane: rateWindowsByLane,
codeReviewRemainingPercent: dashboardVisibility == .attached ? dashboard?.codeReviewRemainingPercent : nil,
codeReviewLimit: dashboardVisibility == .attached ? dashboard?.codeReviewLimit : nil,
evaluationTime: context.now)
}
func rateWindow(for lane: RateLane) -> RateWindow? {
guard let window = self.rateWindowsByLane[lane] else { return nil }
switch lane {
case .session:
return Self.sessionDisplayWindow(
session: window,
weekly: self.rateWindowsByLane[.weekly],
evaluationTime: self.evaluationTime)
case .weekly:
return window
}
}
func sourceRateWindow(for lane: RateLane) -> RateWindow? {
self.rateWindowsByLane[lane]
}
static func sourceRateWindow(for lane: RateLane, snapshot: UsageSnapshot?) -> RateWindow? {
self.rateWindowsByLane(snapshot: snapshot)[lane]
}
func menuBarSelectableRateWindow(for lane: RateLane) -> RateWindow? {
guard let window = self.rateWindow(for: lane) else { return nil }
guard window.remainingPercent <= 0,
let resetAt = window.resetsAt,
resetAt <= self.evaluationTime
else {
return window
}
return nil
}
var nextMenuBarStateChangeAt: Date? {
self.rateWindowsByLane.values.compactMap { window in
guard window.remainingPercent <= 0,
let resetAt = window.resetsAt,
resetAt > self.evaluationTime
else {
return nil
}
return resetAt
}.min()
}
var hasBindingWeeklyCap: Bool {
Self.weeklyCapsSession(
weekly: self.rateWindowsByLane[.weekly],
evaluationTime: self.evaluationTime)
}
func remainingPercent(for metric: SupplementalMetric) -> Double? {
switch metric {
case .codeReview:
self.codeReviewRemainingPercent
}
}
func limitWindow(for metric: SupplementalMetric) -> RateWindow? {
switch metric {
case .codeReview:
self.codeReviewLimit
}
}
private static func dashboardVisibility(surface: Surface, context: Context) -> DashboardVisibility {
guard surface != .overrideCard else { return .hidden }
guard context.dashboardRequiresLogin == false, context.liveDashboard != nil else { return .hidden }
return context.dashboardAttachmentAuthorized ? .attached : .displayOnly
}
private static func rateWindowsByLane(snapshot: UsageSnapshot?) -> [RateLane: RateWindow] {
guard let snapshot else { return [:] }
var windowsByLane: [RateLane: RateWindow] = [:]
let slottedWindows: [(RateLane, RateWindow)] = [
self.classifyRateWindow(snapshot.primary, slot: .primary),
self.classifyRateWindow(snapshot.secondary, slot: .secondary),
].compactMap(\.self)
for (lane, window) in slottedWindows {
windowsByLane[lane] = window
}
return windowsByLane
}
private static func visibleRateLanes(
from rateWindowsByLane: [RateLane: RateWindow],
snapshot: UsageSnapshot?) -> [RateLane]
{
guard let snapshot else { return [] }
let slottedLanes = [
self.classifyRateWindow(snapshot.primary, slot: .primary)?.0,
self.classifyRateWindow(snapshot.secondary, slot: .secondary)?.0,
].compactMap(\.self)
var visible: [RateLane] = []
for lane in slottedLanes where rateWindowsByLane[lane] != nil && !visible.contains(lane) {
visible.append(lane)
}
return visible
}
private static func planUtilizationLanes(from rateWindowsByLane: [RateLane: RateWindow]) -> [PlanUtilizationLane] {
let semanticOrder: [RateLane] = [.session, .weekly]
return semanticOrder.compactMap { lane in
guard let window = rateWindowsByLane[lane] else { return nil }
return PlanUtilizationLane(role: self.planUtilizationRole(for: lane), window: window)
}
}
private static func planUtilizationRole(for lane: RateLane) -> PlanUtilizationSeriesName {
switch lane {
case .session:
.session
case .weekly:
.weekly
}
}
private enum SnapshotSlot {
case primary
case secondary
}
private static func classifyRateWindow(_ window: RateWindow?, slot: SnapshotSlot) -> (RateLane, RateWindow)? {
guard let window else { return nil }
let lane: RateLane = switch window.windowMinutes {
case 300:
.session
case 10080:
.weekly
default:
switch slot {
case .primary:
.session
case .secondary:
.weekly
}
}
return (lane, window)
}
/// When Codex's weekly lane is exhausted, it is the binding cap: session quota cannot be used until
/// the weekly window resets, even if the API still reports room in the 5-hour bucket.
private static func weeklyCapsSession(weekly: RateWindow?, evaluationTime: Date) -> Bool {
guard let weekly else { return false }
guard weekly.remainingPercent <= 0 else { return false }
return weekly.resetsAt.map { $0 > evaluationTime } ?? true
}
private static func sessionDisplayWindow(
session: RateWindow,
weekly: RateWindow?,
evaluationTime: Date) -> RateWindow
{
guard self.weeklyCapsSession(weekly: weekly, evaluationTime: evaluationTime) else {
return session
}
let reset = self.bindingReset(
session: session,
weekly: weekly,
evaluationTime: evaluationTime)
return RateWindow(
usedPercent: max(session.usedPercent, 100),
windowMinutes: session.windowMinutes,
resetsAt: reset.date,
resetDescription: reset.description,
nextRegenPercent: session.nextRegenPercent,
isSyntheticPlaceholder: session.isSyntheticPlaceholder)
}
private static func bindingReset(
session: RateWindow,
weekly: RateWindow?,
evaluationTime: Date) -> (date: Date?, description: String?)
{
guard let weekly else { return (nil, nil) }
let sessionIsExhausted = session.remainingPercent <= 0 &&
(session.resetsAt.map { $0 > evaluationTime } ?? true)
guard sessionIsExhausted else {
return (weekly.resetsAt, weekly.resetDescription)
}
guard let sessionReset = session.resetsAt, let weeklyReset = weekly.resetsAt else {
return (nil, nil)
}
if sessionReset > weeklyReset {
return (sessionReset, session.resetDescription)
}
return (weeklyReset, weekly.resetDescription)
}
private static func menuBarFallback(
creditsRemaining: Double?,
rateWindowsByLane: [RateLane: RateWindow],
evaluationTime: Date) -> MenuBarFallback
{
guard let creditsRemaining, creditsRemaining > 0 else { return .none }
let hasExhaustedLane = rateWindowsByLane.values.contains {
$0.remainingPercent <= 0 && ($0.resetsAt.map { $0 > evaluationTime } ?? true)
}
let hasNoRateWindows = rateWindowsByLane.isEmpty
return (hasExhaustedLane || hasNoRateWindows) ? .creditsBalance : .none
}
var hasExhaustedRateLane: Bool {
self.rateWindowsByLane.values.contains {
$0.remainingPercent <= 0 && ($0.resetsAt.map { $0 > self.evaluationTime } ?? true)
}
}
}
extension UsageStore {
func codexConsumerProjectionIfNeeded(
for provider: UsageProvider,
surface: CodexConsumerProjection.Surface,
snapshotOverride: UsageSnapshot? = nil,
errorOverride: String? = nil,
now: Date = Date()) -> CodexConsumerProjection?
{
guard provider == .codex else { return nil }
return self.codexConsumerProjection(
surface: surface,
snapshotOverride: snapshotOverride,
errorOverride: errorOverride,
now: now)
}
func codexConsumerProjection(
surface: CodexConsumerProjection.Surface,
snapshotOverride: UsageSnapshot? = nil,
errorOverride: String? = nil,
now: Date = Date()) -> CodexConsumerProjection
{
let snapshot = surface == .overrideCard ? snapshotOverride : snapshotOverride ?? self.snapshots[.codex]
let rawUsageError = surface == .overrideCard ? errorOverride : errorOverride ?? self.errors[.codex]
let context = CodexConsumerProjection.Context(
snapshot: snapshot,
rawUsageError: rawUsageError,
liveCredits: self.credits,
rawCreditsError: self.lastCreditsError,
liveDashboard: self.openAIDashboard,
rawDashboardError: self.lastOpenAIDashboardError,
dashboardAttachmentAuthorized: self.openAIDashboardAttachmentAuthorized,
dashboardRequiresLogin: self.openAIDashboardRequiresLogin,
now: now)
return CodexConsumerProjection.make(surface: surface, context: context)
}
func codexMenuBarCreditsRemaining(snapshotOverride: UsageSnapshot? = nil, now: Date = Date()) -> Double? {
let projection = self.codexConsumerProjection(
surface: .menuBar,
snapshotOverride: snapshotOverride,
now: now)
guard projection.menuBarFallback == .creditsBalance else { return nil }
return projection.credits?.remaining
}
func codexMenuBarMetricWindow(snapshot: UsageSnapshot, now: Date = Date()) -> RateWindow? {
let projection = self.codexConsumerProjection(
surface: .menuBar,
snapshotOverride: snapshot,
now: now)
let windows = projection.visibleRateLanes.compactMap {
projection.menuBarSelectableRateWindow(for: $0)
}
let first = windows.first
let second = windows.dropFirst().first
switch self.settings.menuBarMetricPreference(for: .codex, snapshot: snapshot) {
case .secondary, .tertiary:
return second ?? first
case .extraUsage:
return first
case .average:
guard self.settings.menuBarMetricSupportsAverage(for: .codex),
let primary = first,
let secondary = second
else {
return first
}
let usedPercent = (primary.usedPercent + secondary.usedPercent) / 2
return RateWindow(
usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: nil)
case .primaryAndSecondary:
return windows.prefix(2).max(by: { $0.usedPercent < $1.usedPercent })
case .automatic, .primary, .monthlyPlan:
return first
}
}
}