Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,13 @@ pub(crate) struct ChatWidget {
/// message is emitted once per exhaustion window instead of on every poll.
last_no_eligible_auth_profile_trigger: Option<String>,
pending_auth_profile_auto_switch_trigger: Option<String>,
/// Monotonic counter of auto-switches synthesized from a usage-limit *turn error*.
/// Included in the synthetic trigger key so a later genuine exhaustion is never
/// mistaken for a repeat of the previous one when its reset instant is unknown.
synthetic_auth_profile_auto_switch_epoch: u64,
/// Text of the most recent usage-limit turn error, so deferred recovery paths (the
/// reset-unavailable fallback) can still read the reset instant the server advertised.
last_usage_limit_error_message: Option<String>,
auth_profile_auto_switch_cooldowns:
BTreeMap<crate::legacy_core::usage_profile_health::UsageProfileCooldownKey, Instant>,
usage_self_heal: UsageSelfHealState,
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/tui/src/chatwidget/constructor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ impl ChatWidget {
last_auth_profile_auto_switch_trigger: None,
last_no_eligible_auth_profile_trigger: None,
pending_auth_profile_auto_switch_trigger: None,
synthetic_auth_profile_auto_switch_epoch: 0,
last_usage_limit_error_message: None,
auth_profile_auto_switch_cooldowns: BTreeMap::new(),
usage_self_heal: UsageSelfHealState::default(),
add_credits_nudge_email_in_flight: None,
Expand Down
209 changes: 200 additions & 9 deletions codex-rs/tui/src/chatwidget/rate_limits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use super::*;
use crate::chatwidget::auth_profile_popups::AUTH_PROFILE_USAGE_HEARTBEAT_FAILURE_BACKOFF;
use crate::chatwidget::usage_profile_broker::AutoSwitchTrigger;
use crate::chatwidget::usage_profile_broker::UsageProfileAutoSwitchWindow;
use crate::chatwidget::usage_profile_broker::UsageProfileSwitchOutcome;
use crate::chatwidget::usage_profile_broker::auth_profile_auto_switch_target as broker_auth_profile_auto_switch_target;
Expand All @@ -12,8 +13,12 @@ use crate::chatwidget::usage_profile_broker::exhausted_auto_switch_window_for_sn
use crate::chatwidget::usage_profile_broker::fallback_limit_label as broker_fallback_limit_label;
use crate::chatwidget::usage_profile_broker::get_limits_duration as broker_get_limits_duration;
use crate::chatwidget::usage_profile_broker::limit_label_for_window as broker_limit_label_for_window;
use crate::chatwidget::usage_self_heal::parse_usage_limit_reset_timestamp;
use crate::chatwidget::user_messages::QueueInsertionPosition;
use crate::legacy_core::usage_profile_health::FIVE_HOUR_LIMIT_LABEL;
use crate::legacy_core::usage_profile_health::UsageProfileCooldownKey;
use crate::legacy_core::usage_profile_health::WEEKLY_LIMIT_LABEL;
use crate::legacy_core::usage_profile_health::auth_profile_auto_switch_label_enabled;
use crate::legacy_core::usage_profile_health::cooldown_duration_for_reset;
use crate::status::format_reset_timestamp;
use chrono::DateTime;
Expand All @@ -27,6 +32,21 @@ pub(super) const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0;

const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0];

/// Longest reset horizon still attributable to the rolling 5-hour window. The server
/// advertises the reset of the window that actually blocked the request, and a weekly cap
/// always resets days out, so anything inside this horizon is the 5h window. The slack
/// absorbs server-side rounding and clock skew.
const FIVE_HOUR_RESET_HORIZON_SECS: i64 = 5 * 60 * 60 + 30 * 60;

/// Classify the window that blocked a turn from the reset instant the server advertised.
fn usage_limit_window_label_for_reset(resets_at: i64, now_unix_secs: i64) -> &'static str {
if resets_at.saturating_sub(now_unix_secs) <= FIVE_HOUR_RESET_HORIZON_SECS {
FIVE_HOUR_LIMIT_LABEL
} else {
WEEKLY_LIMIT_LABEL
}
}

#[derive(Default)]
pub(super) struct RateLimitWarningState {
pub(super) secondary_index: usize,
Expand Down Expand Up @@ -441,13 +461,175 @@ impl ChatWidget {
return;
}
let Some((next_profile, trigger_key)) =
self.auth_profile_auto_switch_target(limit_id, &window)
self.auth_profile_auto_switch_target(&AutoSwitchTrigger {
limit_id,
window: &window,
// No scope by design (not an oversight): this trigger comes from an
// authoritative exhausted snapshot, whose `resets_at` is always present and
// already unique per exhaustion, so `limit_id:label:resets_at` never
// collapses two distinct exhaustions. A scope here would instead *defeat*
// the dedupe, re-firing the same switch on every poll of the same window.
scope: None,
})
else {
return;
};
self.send_auth_profile_auto_switch(next_profile, trigger_key, window);
}

/// Auto-switch away from the CURRENT profile in response to its own, authoritative
/// usage-limit / 429 turn failure.
///
/// A usage-limit *turn error* is emitted by the app server rejecting *this* profile's
/// own turn, so — unlike a rolling `account/rateLimits/updated` snapshot, which #374
/// made display-only because a sibling agent spawned on a *different* account can emit
/// an identity-less 100%-usage snapshot that would be misattributed to the current
/// profile — it authoritatively identifies the current profile as exhausted. Switching
/// on it therefore cannot revive the cross-agent false-cascade #374 fixed: a foreign
/// snapshot never produces a usage-limit turn error on this widget's own profile.
///
/// Prefers an authoritative exhausted snapshot already cached for the current profile
/// (which carries the exact limiting window). When none has been observed yet — the
/// common case, because the limit is usually crossed mid-turn, before the ~60s
/// authoritative heartbeat re-reads it — falls back to a window synthesized from the
/// operator's enabled auto-switch config so the failed turn still moves to another
/// configured profile instead of stalling until the (possibly days-away) reset.
pub(in crate::chatwidget) fn try_auth_profile_switch_for_usage_limit(
&mut self,
is_usage_limit: bool,
error_message: Option<&str>,
) -> bool {
// Cached, account-verified exhaustion (populated only by `AccountUsage` reads,
// never by a rolling notification) takes priority and carries the exact window.
if self.try_auth_profile_switch_after_reset_unavailable() {
return true;
}
// Only a hard usage-limit block should synthesize a switch without a corroborating
// cached snapshot; a transient overload/429 must not rotate profiles.
if !is_usage_limit || !self.is_session_configured() {
return false;
}
let Some(window) = self.synthetic_usage_limit_auto_switch_window(error_message) else {
return false;
};
let trigger_scope = self.synthetic_auto_switch_trigger_scope();
let Some((next_profile, trigger_key)) =
self.auth_profile_auto_switch_target(&AutoSwitchTrigger {
limit_id: "codex",
window: &window,
scope: Some(trigger_scope.as_str()),
})
else {
return false;
};
// Every synthetic switch opens a new exhaustion epoch, so the *next* genuine
// exhaustion can never collapse onto this trigger key even when its reset instant
// is unknown (see `synthetic_auto_switch_trigger_scope`).
self.synthetic_auth_profile_auto_switch_epoch = self
.synthetic_auth_profile_auto_switch_epoch
.saturating_add(1);
self.send_auth_profile_auto_switch(next_profile, trigger_key, window);
true
}

/// Disambiguator appended to a synthetic trigger key so that repeated genuine
/// exhaustions stay distinct.
///
/// A synthetic trigger has no authoritative snapshot behind it, so its reset instant is
/// frequently unknown and renders as the literal `unknown` in the trigger key. Without a
/// scope, a second genuine exhaustion produces a byte-identical key, the
/// `last_auth_profile_auto_switch_trigger` guard rejects it, and the session is stranded
/// on an exhausted profile while untried profiles remain. The exhausted profile plus a
/// monotonic per-switch epoch makes each exhaustion its own trigger, while repeats of
/// the *same* exhaustion (same profile, no switch emitted in between) still collapse
/// onto one key and are deduped.
fn synthetic_auto_switch_trigger_scope(&self) -> String {
let profile = self
.config
.selected_auth_profile
.as_deref()
.unwrap_or("<default>");
format!(
"synthetic#{}:{profile}",
self.synthetic_auth_profile_auto_switch_epoch
)
}

/// Trigger window used to auto-switch on a genuine usage-limit turn error when no
/// authoritative exhausted snapshot has driven the switch.
///
/// The label is derived from the window that actually blocked the turn, never from
/// which auto-switch flags happen to be enabled (both default to `true`, so preferring
/// weekly would mislabel every 5-hour exhaustion — the common case — and would also
/// make `HighestAvailable` rank candidates by weekly headroom instead of 5h headroom).
/// In order:
/// 1. an authoritative, account-verified exhausted window cached for this profile,
/// 2. the reset instant advertised in the error text — a reset inside the rolling
/// 5-hour horizon can only be the 5h window, since a weekly cap resets days out,
/// 3. otherwise 5h, the overwhelmingly more common exhaustion, falling back to weekly
/// when the operator opted out of 5h switching.
///
/// Returns `None` when auto-switch is disabled, when both windows are opted out, or
/// when the window that actually blocked the turn is one the operator opted out of, so
/// a disabled window is never switched on.
fn synthetic_usage_limit_auto_switch_window(
&self,
error_message: Option<&str>,
) -> Option<UsageProfileAutoSwitchWindow> {
let config = &self.config.auth_profile_auto_switch;
if !config.enabled {
return None;
}
let parsed_resets_at = error_message
.and_then(parse_usage_limit_reset_timestamp)
.map(|reset_at| reset_at.timestamp());

if let Some(observed) = self.authoritative_exhausted_codex_window() {
let resets_at = parsed_resets_at.or(observed.resets_at);
return auth_profile_auto_switch_label_enabled(&observed.label, config).then_some(
UsageProfileAutoSwitchWindow {
label: observed.label,
resets_at,
},
);
}

let label = match parsed_resets_at {
Some(resets_at) => {
let label =
usage_limit_window_label_for_reset(resets_at, chrono::Utc::now().timestamp());
if !auth_profile_auto_switch_label_enabled(label, config) {
return None;
}
label
}
// The blocking window is genuinely unknown: prefer 5h (by far the more common
// exhaustion) and only fall back to weekly when 5h switching is opted out.
None if config.on_5h_limit => FIVE_HOUR_LIMIT_LABEL,
None if config.on_weekly_limit => WEEKLY_LIMIT_LABEL,
None => return None,
};
Some(UsageProfileAutoSwitchWindow {
label: label.to_string(),
resets_at: parsed_resets_at,
})
}

/// Exhausted codex window observed by an account-verified `AccountUsage` read for the
/// current profile, if one has been cached. Rolling notifications never populate this
/// map (see `on_rate_limit_snapshot_from`), so it cannot be poisoned by a sibling
/// agent's identity-less snapshot.
fn authoritative_exhausted_codex_window(&self) -> Option<UsageProfileAutoSwitchWindow> {
self.auth_profile_auto_switch_snapshots_by_limit_id
.iter()
.find_map(|(limit_id, snapshot)| {
exhausted_auto_switch_window_for_snapshot(
snapshot,
limit_id.eq_ignore_ascii_case("codex"),
)
})
}

pub(super) fn maybe_auto_switch_auth_profile_before_user_turn(
&mut self,
user_message: &UserMessage,
Expand Down Expand Up @@ -475,7 +657,16 @@ impl ChatWidget {
{
return false;
}
let trigger_key = auto_switch_trigger_key(&limit_id, &window);
let trigger = AutoSwitchTrigger {
limit_id: &limit_id,
window: &window,
// No scope by design (not an oversight): same as the snapshot-driven path above,
// this window comes from an authoritative exhausted snapshot with a known
// `resets_at`, so the unscoped key is already unique per exhaustion and must
// stay stable so a re-submitted turn matches the pending trigger.
scope: None,
};
let trigger_key = auto_switch_trigger_key(&trigger);
if self.pending_auth_profile_auto_switch_trigger.as_deref() == Some(trigger_key.as_str()) {
self.queue_user_message_at_position(
user_message,
Expand All @@ -485,8 +676,7 @@ impl ChatWidget {
);
return true;
}
let Some((next_profile, trigger_key)) =
self.auth_profile_auto_switch_target(&limit_id, &window)
let Some((next_profile, trigger_key)) = self.auth_profile_auto_switch_target(&trigger)
else {
return false;
};
Expand Down Expand Up @@ -550,10 +740,12 @@ impl ChatWidget {

fn auth_profile_auto_switch_target(
&mut self,
limit_id: &str,
window: &UsageProfileAutoSwitchWindow,
trigger: &AutoSwitchTrigger<'_>,
) -> Option<(String, String)> {
let trigger_key = auto_switch_trigger_key(limit_id, window);
let AutoSwitchTrigger {
limit_id, window, ..
} = *trigger;
let trigger_key = auto_switch_trigger_key(trigger);
if self.last_auth_profile_auto_switch_trigger.as_deref() == Some(trigger_key.as_str()) {
return None;
}
Expand All @@ -579,8 +771,7 @@ impl ChatWidget {
&profiles,
&self.auth_profile_rate_limit_snapshots_by_profile,
&recently_failed,
limit_id,
window,
trigger,
) {
UsageProfileSwitchOutcome::Switch(target) => {
self.mark_auth_profile_auto_switch_cooldown(cooldown_key);
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/tui/src/chatwidget/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,8 @@ impl ChatWidget {
self.rate_limit_warnings = RateLimitWarningState::default();
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::default();
self.pending_auth_profile_auto_switch_trigger = None;
// The previous profile's usage-limit block says nothing about the new one.
self.last_usage_limit_error_message = None;
self.prefetch_rate_limits();
self.refresh_status_line();
}
Expand Down
Loading
Loading