From 25dd091bef01d0b2a2d74c13ab82ee43fb8cb269 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Thu, 23 Jul 2026 15:48:07 +0300 Subject: [PATCH 1/2] fix(auth): auto-switch profile on genuine exhaustion (regression from #374's false-cascade fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit account/rateLimits/updated snapshots display-only and gating all exhaustion bookkeeping + auto-switch to the account-verified AccountUsage read. That over- corrected: the rolling snapshot was also what drove auto-switch on GENUINE mid-turn exhaustion (it populated auth_profile_auto_switch_snapshots_by_limit_id and called maybe_auto_switch_auth_profile_for_rate_limit). After #374, when the current profile is really exhausted the switch map is empty (the ~60s authoritative heartbeat has not re-read yet and is then suppressed), and the usage-limit turn-error path only switched when a manual reset was active — so with auto-reset unavailable the failed turn just parked on a self-heal retry until the (days-away) reset instead of switching. Fix: drive auto-switch from the current profile's OWN authoritative usage-limit / 429 turn error (turn_runtime.rs on_rate_limit_error and the reset-unavailable fallback in usage_limit_reset/automatic.rs). This is authoritative for the current profile and immune to the #374 misattribution: a sibling agent's identity-less rolling snapshot never fails THIS profile's own turn. It prefers a cached account-verified exhausted window and otherwise synthesizes the trigger window from the operator's enabled auto-switch config (weekly preferred, else 5h), reusing the existing candidate selection that cycles to an Unknown-but- untried profile and bails (no loop) when every alternate is exhausted. Rolling snapshots remain strictly display-only and never seed the switch map, so Tests (tui/src/chatwidget/tests/status_and_layout.rs): - genuine_usage_limit_error_auto_switches_without_a_cached_snapshot (the regression) - rolling_snapshot_alone_never_switches_and_never_seeds_the_switch_map (#374 preserved) - genuine_usage_limit_error_does_not_loop_when_all_profiles_exhausted (graceful) --- codex-rs/tui/src/chatwidget/rate_limits.rs | 80 +++++++++++ .../src/chatwidget/tests/status_and_layout.rs | 133 ++++++++++++++++++ codex-rs/tui/src/chatwidget/turn_runtime.rs | 12 +- .../chatwidget/usage_limit_reset/automatic.rs | 7 +- .../tui/src/chatwidget/usage_self_heal.rs | 4 +- 5 files changed, 232 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index 5377a363d..8df2fb2f5 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -12,8 +12,11 @@ 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::cooldown_duration_for_reset; use crate::status::format_reset_timestamp; use chrono::DateTime; @@ -448,6 +451,83 @@ impl ChatWidget { 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 Some((next_profile, trigger_key)) = + self.auth_profile_auto_switch_target("codex", &window) + else { + return false; + }; + self.send_auth_profile_auto_switch(next_profile, trigger_key, window); + true + } + + /// Trigger window used to auto-switch on a genuine usage-limit turn error when no + /// authoritative exhausted snapshot has been cached yet. + /// + /// Honors the operator's enabled windows: a hard usage-limit block ("try again + /// ") is the weekly cap in practice, so weekly is preferred when enabled and the + /// 5h window is used otherwise. Returns `None` when auto-switch is disabled or both + /// windows are opted out, so a disabled window is never switched on. The reset instant + /// is parsed from the error text when present, keeping the trigger key distinct per + /// exhaustion so repeated genuine failures can cycle across profiles. + fn synthetic_usage_limit_auto_switch_window( + &self, + error_message: Option<&str>, + ) -> Option { + let config = &self.config.auth_profile_auto_switch; + if !config.enabled { + return None; + } + let label = if config.on_weekly_limit { + WEEKLY_LIMIT_LABEL + } else if config.on_5h_limit { + FIVE_HOUR_LIMIT_LABEL + } else { + return None; + }; + let resets_at = error_message + .and_then(parse_usage_limit_reset_timestamp) + .map(|reset_at| reset_at.timestamp()); + Some(UsageProfileAutoSwitchWindow { + label: label.to_string(), + resets_at, + }) + } + pub(super) fn maybe_auto_switch_auth_profile_before_user_turn( &mut self, user_message: &UserMessage, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 513c90552..e40b1cf7d 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1635,6 +1635,139 @@ async fn rolling_rate_limit_update_does_not_auto_switch_auth_profile() { } } +#[tokio::test] +async fn genuine_usage_limit_error_auto_switches_without_a_cached_snapshot() { + // Regression (from #374): when the current profile is genuinely exhausted mid-turn the + // authoritative account read has not observed it yet, so no exhausted snapshot is + // cached. #374 made the rolling per-turn snapshot display-only, which used to be what + // drove the switch here — leaving the failed turn to just stall until the (days-away) + // reset. The profile's OWN usage-limit turn error is authoritative for it, so it must + // still auto-switch to another configured profile. + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + save_test_auth_profile(&chat, "work"); + save_test_auth_profile(&chat, "personal"); + chat.config.selected_auth_profile = Some("work".to_string()); + chat.config.auth_profile_auto_switch.enabled = true; + chat.config.auth_profile_auto_switch.on_weekly_limit = true; + chat.config.auth_profile_auto_switch.profiles = + vec!["work".to_string(), "personal".to_string()]; + // No usage-limit reset flow available, so the switch must be driven directly by the + // authoritative turn failure rather than a reset attempt. + chat.config.usage_limit.auto_reset_enabled = false; + configure_test_session(&mut chat); + drain_insert_history(&mut rx); + + // Deliberately no `on_rate_limit_snapshot(...)`: the switch map is empty, mirroring a + // limit crossed mid-turn before the authoritative heartbeat could re-read it. + assert!( + chat.auth_profile_auto_switch_snapshots_by_limit_id + .is_empty() + ); + chat.on_rate_limit_error( + RateLimitErrorKind::UsageLimit, + "You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at Jul 28th, 2026 4:53 PM." + .to_string(), + ); + + let switches = std::iter::from_fn(|| rx.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::SwitchAuthProfile { + profile, reason, .. + } => Some((profile, reason)), + _ => None, + }) + .collect::>(); + assert_eq!( + switches, + vec![( + Some("personal".to_string()), + crate::app_event::AuthProfileSwitchReason::AutoRateLimit { + window: "weekly".to_string(), + }, + )], + "genuine usage-limit error must auto-switch to the next configured profile" + ); + // The failed turn is re-queued so it resumes on the freshly selected profile. + assert!(chat.pending_usage_self_heal_retry_id().is_none()); +} + +#[tokio::test] +async fn rolling_snapshot_alone_never_switches_and_never_seeds_the_switch_map() { + // Preserves #374: a rolling `account/rateLimits/updated` notification carries no + // account identity, so under multi-agent spawning it can originate from a sibling on a + // *different* exhausted account. It must never switch profiles, and — unlike the + // authoritative turn-error path added for genuine exhaustion — it must never seed the + // switch map that a later user turn consumes, otherwise the #374 false-cascade returns. + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + save_test_auth_profile(&chat, "work"); + save_test_auth_profile(&chat, "personal"); + chat.config.selected_auth_profile = Some("work".to_string()); + chat.config.auth_profile_auto_switch.enabled = true; + chat.config.auth_profile_auto_switch.profiles = + vec!["work".to_string(), "personal".to_string()]; + configure_test_session(&mut chat); + drain_insert_history(&mut rx); + + chat.on_rolling_rate_limit_snapshot(rate_limit_snapshot_for_window( + /*used_percent*/ 100, + /*window_duration_mins*/ 7 * 24 * 60, + /*resets_at*/ chrono::Utc::now().timestamp() + 7 * 24 * 60 * 60, + )); + + while let Ok(event) = rx.try_recv() { + assert!( + !matches!(event, AppEvent::SwitchAuthProfile { .. }), + "rolling snapshot must not auto-switch, got {event:?}" + ); + } + assert!( + chat.auth_profile_auto_switch_snapshots_by_limit_id + .is_empty(), + "rolling snapshot must not seed the auth-profile auto-switch map" + ); +} + +#[tokio::test] +async fn genuine_usage_limit_error_does_not_loop_when_all_profiles_exhausted() { + // Graceful degradation: when the only alternate profile is itself known-exhausted the + // switch must bail without emitting a switch, and repeating the failure must not loop. + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + save_test_auth_profile(&chat, "work"); + save_test_auth_profile(&chat, "personal"); + chat.config.selected_auth_profile = Some("work".to_string()); + chat.config.auth_profile_auto_switch.enabled = true; + chat.config.auth_profile_auto_switch.on_weekly_limit = true; + chat.config.auth_profile_auto_switch.profiles = + vec!["work".to_string(), "personal".to_string()]; + chat.config.usage_limit.auto_reset_enabled = false; + configure_test_session(&mut chat); + drain_insert_history(&mut rx); + + // The only alternate profile is already exhausted, so there is nowhere healthy to go. + chat.on_auth_profile_rate_limit_snapshots( + Some("personal".to_string()), + vec![rate_limit_snapshot_for_window( + /*used_percent*/ 100, + /*window_duration_mins*/ 7 * 24 * 60, + /*resets_at*/ chrono::Utc::now().timestamp() + 7 * 24 * 60 * 60, + )], + ); + drain_insert_history(&mut rx); + + for _ in 0..2 { + chat.on_rate_limit_error( + RateLimitErrorKind::UsageLimit, + "You've hit your usage limit. Try again at Jul 28th, 2026 4:53 PM.".to_string(), + ); + while let Ok(event) = rx.try_recv() { + assert!( + !matches!(event, AppEvent::SwitchAuthProfile { .. }), + "no profile has capacity, so no switch must be emitted, got {event:?}" + ); + } + } +} + #[tokio::test] async fn exhausted_limit_auto_switches_to_profile_with_most_fresh_usage() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index 989108081..87808c37d 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -464,13 +464,21 @@ impl ChatWidget { ); let reset_recovery_was_opted_out = matches!(auto_reset_check, UsageLimitAutoResetCheckOutcome::OptedOut); + // When no usage-limit reset flow will own the failed turn (auto-reset disabled, + // opted out, non-canonical backend, or a manual reset already in progress), + // auto-switch to another configured profile driven by this profile's OWN + // authoritative usage-limit failure. This restores the switch that #374 removed + // when it made rolling snapshots display-only, without reviving the cross-agent + // false-cascade (a sibling's snapshot never fails *this* profile's turn). let profile_fallback_owns_failed_turn = should_retry && matches!( auto_reset_check, UsageLimitAutoResetCheckOutcome::Unavailable ) - && self.manual_usage_limit_reset_is_active() - && self.try_auth_profile_switch_after_reset_unavailable(); + && self.try_auth_profile_switch_for_usage_limit( + matches!(error_kind, RateLimitErrorKind::UsageLimit), + Some(message.as_str()), + ); if profile_fallback_owns_failed_turn { self.resume_after_usage_limit_reset(); } diff --git a/codex-rs/tui/src/chatwidget/usage_limit_reset/automatic.rs b/codex-rs/tui/src/chatwidget/usage_limit_reset/automatic.rs index 84ef5584f..5d5917115 100644 --- a/codex-rs/tui/src/chatwidget/usage_limit_reset/automatic.rs +++ b/codex-rs/tui/src/chatwidget/usage_limit_reset/automatic.rs @@ -202,7 +202,12 @@ impl ChatWidget { } pub(super) fn fallback_auth_profile_switch_after_reset_unavailable(&mut self) { - if self.try_auth_profile_switch_after_reset_unavailable() { + // Reached only after a genuine usage-limit failure whose reset recovery is + // unavailable, so the current profile is authoritatively exhausted: switch even + // when the authoritative read has not yet cached the exhausted window. + if self.try_auth_profile_switch_for_usage_limit( + /*is_usage_limit*/ true, /*error_message*/ None, + ) { // A switch to a healthier profile is underway. Re-queue the interrupted turn so it // resumes automatically on the new profile once the switch is applied, instead of // being silently dropped (which forced the user to re-type `go`). The pending diff --git a/codex-rs/tui/src/chatwidget/usage_self_heal.rs b/codex-rs/tui/src/chatwidget/usage_self_heal.rs index 5d3e0f049..fce49d2ca 100644 --- a/codex-rs/tui/src/chatwidget/usage_self_heal.rs +++ b/codex-rs/tui/src/chatwidget/usage_self_heal.rs @@ -349,7 +349,9 @@ fn select_model_fallback( }) } -fn parse_usage_limit_reset_timestamp(message: &str) -> Option> { +pub(in crate::chatwidget) fn parse_usage_limit_reset_timestamp( + message: &str, +) -> Option> { let marker = "try again at "; let start = message.to_ascii_lowercase().find(marker)? + marker.len(); let candidate = message.get(start..)?.trim_start(); From f1bd73414fd5dbada7aa211cd5402d02e228fb3c Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Fri, 24 Jul 2026 22:42:20 +0300 Subject: [PATCH 2/2] fix(auth): make synthetic auto-switch repeatable and label the real window Follow-up to the genuine-exhaustion auto-switch: two defects found in review. 1) The synthetic auto-switch could fire at most ONCE per session when the reset instant is unparseable. `auto_switch_trigger_key` is `limit_id:label:resets_at`, `resets_at` renders as the literal `unknown` when `None`, and the key carried no profile. `last_auth_profile_auto_switch_trigger` is never cleared (only the sibling `pending_...` latch is, on profile change), so a SECOND genuine exhaustion computed a byte-identical key, was rejected by the guard, and stranded the session on an exhausted profile while untried profiles remained. The reset-unavailable fallback was worse: it hardcoded `error_message: None`, so it could never parse a reset instant at all. Triggers now carry an optional `scope` (`AutoSwitchTrigger`); the synthetic path scopes its key with the exhausted profile plus a monotonic per-switch epoch, so each genuine exhaustion is its own trigger while repeats of the same exhaustion still dedupe. The reset-unavailable fallback replays the usage-limit turn error (`last_usage_limit_error_message`) instead of passing `None`; it is cleared when the selected profile changes. 2) The synthetic window was chosen by flag precedence (weekly first) and both flags default to true, so EVERY synthetic switch was labelled `weekly`, including 5-hour exhaustions (the common case). That surfaced the wrong window to the user and made `HighestAvailable` rank candidates by weekly headroom instead of 5h headroom. The label is now derived from the window that actually blocked the turn: an authoritative cached exhausted window first, else the advertised reset instant (inside the rolling 5h horizon can only be the 5h window, a weekly cap resets days out), else 5h. A window the operator opted out of is never switched on. Tests (all verified failing before this change): - repeated_genuine_usage_limit_errors_keep_switching_to_untried_profiles - synthetic_auto_switch_window_follows_the_exhausted_window_not_the_enabled_flags - synthetic_auto_switch_does_not_fire_for_a_window_the_operator_opted_out_of - reset_unavailable_fallback_replays_the_turn_error_to_identify_the_exhausted_window Existing genuine-exhaustion tests switched to relative reset instants so their weekly assertions stay correct as the calendar moves. --- codex-rs/tui/src/chatwidget.rs | 7 + codex-rs/tui/src/chatwidget/constructor.rs | 2 + codex-rs/tui/src/chatwidget/rate_limits.rs | 163 +++++++++++++--- codex-rs/tui/src/chatwidget/settings.rs | 2 + .../src/chatwidget/tests/status_and_layout.rs | 178 ++++++++++++++++-- .../usage_limit_reset_tests/automatic.rs | 63 +++++++ codex-rs/tui/src/chatwidget/turn_runtime.rs | 6 + .../chatwidget/usage_limit_reset/automatic.rs | 9 +- .../src/chatwidget/usage_profile_broker.rs | 35 +++- 9 files changed, 416 insertions(+), 49 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0e89813db..1952ae642 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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, pending_auth_profile_auto_switch_trigger: Option, + /// 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, auth_profile_auto_switch_cooldowns: BTreeMap, usage_self_heal: UsageSelfHealState, diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 3758174e6..5e34591f1 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -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, diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index 8df2fb2f5..66a947fbd 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -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; @@ -17,6 +18,7 @@ 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; @@ -30,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, @@ -444,7 +461,16 @@ 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; }; @@ -486,24 +512,66 @@ impl ChatWidget { 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("codex", &window) + 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(""); + 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 been cached yet. + /// 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. /// - /// Honors the operator's enabled windows: a hard usage-limit block ("try again - /// ") is the weekly cap in practice, so weekly is preferred when enabled and the - /// 5h window is used otherwise. Returns `None` when auto-switch is disabled or both - /// windows are opted out, so a disabled window is never switched on. The reset instant - /// is parsed from the error text when present, keeping the trigger key distinct per - /// exhaustion so repeated genuine failures can cycle across profiles. + /// 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>, @@ -512,22 +580,56 @@ impl ChatWidget { if !config.enabled { return None; } - let label = if config.on_weekly_limit { - WEEKLY_LIMIT_LABEL - } else if config.on_5h_limit { - FIVE_HOUR_LIMIT_LABEL - } else { - return None; - }; - let resets_at = error_message + 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, + 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 { + 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, @@ -555,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, @@ -565,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; }; @@ -630,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; } @@ -659,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); diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 5f7de6321..0730c8143 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -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(); } diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index e40b1cf7d..3aaa3c835 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -67,6 +67,36 @@ fn rate_limit_snapshot_for_window( } } +/// Usage-limit turn error carrying the reset instant the server advertises, `delta` from +/// now. Relative (not a fixed date) so the 5h-vs-weekly classification the synthetic +/// auto-switch derives from it stays stable as the calendar moves. +fn usage_limit_error_message_resetting_in(delta: chrono::Duration) -> String { + let reset_at = chrono::Local::now() + delta; + format!( + "You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {}.", + reset_at.format("%b %d, %Y %I:%M %p") + ) +} + +/// Usage-limit turn error whose reset instant this client cannot parse (the server phrases +/// it relatively). The synthetic auto-switch must still work with no reset instant at all. +fn usage_limit_error_message_without_a_parseable_reset() -> String { + "You've hit your usage limit. Try again in 3 hours.".to_string() +} + +fn drain_auth_profile_switches( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> Vec<(Option, crate::app_event::AuthProfileSwitchReason)> { + std::iter::from_fn(|| rx.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::SwitchAuthProfile { + profile, reason, .. + } => Some((profile, reason)), + _ => None, + }) + .collect() +} + fn weekly_rate_limit_snapshot(used_percent: i32) -> RateLimitSnapshot { let mut limits = snapshot(f64::from(used_percent)); limits.primary = None; @@ -1665,18 +1695,11 @@ async fn genuine_usage_limit_error_auto_switches_without_a_cached_snapshot() { ); chat.on_rate_limit_error( RateLimitErrorKind::UsageLimit, - "You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at Jul 28th, 2026 4:53 PM." - .to_string(), + // A reset days out can only be the weekly cap, so the synthetic window is weekly. + usage_limit_error_message_resetting_in(chrono::Duration::days(4)), ); - let switches = std::iter::from_fn(|| rx.try_recv().ok()) - .filter_map(|event| match event { - AppEvent::SwitchAuthProfile { - profile, reason, .. - } => Some((profile, reason)), - _ => None, - }) - .collect::>(); + let switches = drain_auth_profile_switches(&mut rx); assert_eq!( switches, vec![( @@ -1691,6 +1714,139 @@ async fn genuine_usage_limit_error_auto_switches_without_a_cached_snapshot() { assert!(chat.pending_usage_self_heal_retry_id().is_none()); } +#[tokio::test] +async fn repeated_genuine_usage_limit_errors_keep_switching_to_untried_profiles() { + // Regression: the synthetic trigger key was `limit_id:label:resets_at`, and a reset + // instant this client cannot parse renders as the literal `unknown`. With no profile + // and no counter in the key, a SECOND genuine exhaustion produced a byte-identical + // trigger, the `last_auth_profile_auto_switch_trigger` guard rejected it, and the + // session was stranded on an exhausted profile while untried profiles remained — at + // most one synthetic auto-switch could ever fire for the whole session. + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + save_test_auth_profile(&chat, "work"); + save_test_auth_profile(&chat, "personal"); + save_test_auth_profile(&chat, "backup"); + chat.config.selected_auth_profile = Some("work".to_string()); + chat.config.auth_profile_auto_switch.enabled = true; + chat.config.auth_profile_auto_switch.profiles = vec![ + "work".to_string(), + "personal".to_string(), + "backup".to_string(), + ]; + chat.config.usage_limit.auto_reset_enabled = false; + configure_test_session(&mut chat); + drain_insert_history(&mut rx); + + chat.on_rate_limit_error( + RateLimitErrorKind::UsageLimit, + usage_limit_error_message_without_a_parseable_reset(), + ); + assert_eq!( + drain_auth_profile_switches(&mut rx) + .into_iter() + .map(|(profile, _)| profile) + .collect::>(), + vec![Some("personal".to_string())], + "the first genuine exhaustion must switch off the exhausted profile" + ); + + // The app applies the switch, exactly as `AppEvent::SwitchAuthProfile` does in + // production (this is what clears the pending-trigger latch). + chat.set_auth_profile(Some("personal".to_string())); + drain_insert_history(&mut rx); + let _ = drain_auth_profile_switches(&mut rx); + + // `personal` now hits its own limit, described exactly like the first one. + chat.on_rate_limit_error( + RateLimitErrorKind::UsageLimit, + usage_limit_error_message_without_a_parseable_reset(), + ); + assert_eq!( + drain_auth_profile_switches(&mut rx) + .into_iter() + .map(|(profile, _)| profile) + .collect::>(), + vec![Some("backup".to_string())], + "a second genuine exhaustion whose reset cannot be parsed must still switch to the next untried profile" + ); +} + +#[tokio::test] +async fn synthetic_auto_switch_window_follows_the_exhausted_window_not_the_enabled_flags() { + // Regression: the synthetic window was picked by flag precedence (weekly first), and + // both flags default to `true`, so every synthetic switch was labelled `weekly` even + // for a 5-hour exhaustion — the common case. That surfaced the wrong window to the user + // and made `HighestAvailable` rank candidates by weekly headroom instead of 5h headroom. + for (delta, expected_window) in [ + (Some(chrono::Duration::hours(3)), "5h"), + (Some(chrono::Duration::days(4)), "weekly"), + // Reset instant unknown: 5h is by far the more common exhaustion. + (None, "5h"), + ] { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + save_test_auth_profile(&chat, "work"); + save_test_auth_profile(&chat, "personal"); + chat.config.selected_auth_profile = Some("work".to_string()); + chat.config.auth_profile_auto_switch.enabled = true; + // Both windows enabled, as they are by default. + chat.config.auth_profile_auto_switch.on_5h_limit = true; + chat.config.auth_profile_auto_switch.on_weekly_limit = true; + chat.config.auth_profile_auto_switch.profiles = + vec!["work".to_string(), "personal".to_string()]; + chat.config.usage_limit.auto_reset_enabled = false; + configure_test_session(&mut chat); + drain_insert_history(&mut rx); + + chat.on_rate_limit_error( + RateLimitErrorKind::UsageLimit, + delta.map_or_else( + usage_limit_error_message_without_a_parseable_reset, + usage_limit_error_message_resetting_in, + ), + ); + + assert_eq!( + drain_auth_profile_switches(&mut rx), + vec![( + Some("personal".to_string()), + crate::app_event::AuthProfileSwitchReason::AutoRateLimit { + window: expected_window.to_string(), + }, + )], + "reset in {delta:?} must be reported as the {expected_window} window" + ); + } +} + +#[tokio::test] +async fn synthetic_auto_switch_does_not_fire_for_a_window_the_operator_opted_out_of() { + // The derived window is the one that actually blocked the turn, so an operator who only + // opted into weekly switching must not be switched off a 5-hour exhaustion. + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + save_test_auth_profile(&chat, "work"); + save_test_auth_profile(&chat, "personal"); + chat.config.selected_auth_profile = Some("work".to_string()); + chat.config.auth_profile_auto_switch.enabled = true; + chat.config.auth_profile_auto_switch.on_5h_limit = false; + chat.config.auth_profile_auto_switch.on_weekly_limit = true; + chat.config.auth_profile_auto_switch.profiles = + vec!["work".to_string(), "personal".to_string()]; + chat.config.usage_limit.auto_reset_enabled = false; + configure_test_session(&mut chat); + drain_insert_history(&mut rx); + + chat.on_rate_limit_error( + RateLimitErrorKind::UsageLimit, + usage_limit_error_message_resetting_in(chrono::Duration::hours(3)), + ); + + assert_eq!( + drain_auth_profile_switches(&mut rx), + vec![], + "a 5h exhaustion must not switch when only weekly switching is enabled" + ); +} + #[tokio::test] async fn rolling_snapshot_alone_never_switches_and_never_seeds_the_switch_map() { // Preserves #374: a rolling `account/rateLimits/updated` notification carries no @@ -1757,7 +1913,7 @@ async fn genuine_usage_limit_error_does_not_loop_when_all_profiles_exhausted() { for _ in 0..2 { chat.on_rate_limit_error( RateLimitErrorKind::UsageLimit, - "You've hit your usage limit. Try again at Jul 28th, 2026 4:53 PM.".to_string(), + usage_limit_error_message_resetting_in(chrono::Duration::days(4)), ); while let Ok(event) = rx.try_recv() { assert!( diff --git a/codex-rs/tui/src/chatwidget/tests/usage_limit_reset_tests/automatic.rs b/codex-rs/tui/src/chatwidget/tests/usage_limit_reset_tests/automatic.rs index ce3f68972..91102c42c 100644 --- a/codex-rs/tui/src/chatwidget/tests/usage_limit_reset_tests/automatic.rs +++ b/codex-rs/tui/src/chatwidget/tests/usage_limit_reset_tests/automatic.rs @@ -624,3 +624,66 @@ async fn automatic_weekly_switch_requeues_failed_turn_for_resume_on_new_profile( "interrupted turn resumes exactly once" ); } + +#[tokio::test] +async fn reset_unavailable_fallback_replays_the_turn_error_to_identify_the_exhausted_window() { + // Regression: the reset-unavailable fallback passed `error_message: None`, so the + // synthetic auto-switch it drives could never see the reset instant the server + // advertised — every switch from this call site was mislabelled and its trigger key + // always carried an `unknown` reset. The fallback runs asynchronously, off the + // reset-check response, so the error text that failed the turn has to be replayed. + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + super::super::status_and_layout::save_test_auth_profile(&chat, "work"); + super::super::status_and_layout::save_test_auth_profile(&chat, "personal"); + chat.config.selected_auth_profile = Some("work".to_string()); + chat.config.auth_profile_auto_switch.enabled = true; + chat.config.auth_profile_auto_switch.on_5h_limit = true; + chat.config.auth_profile_auto_switch.on_weekly_limit = true; + chat.config.auth_profile_auto_switch.profiles = + vec!["work".to_string(), "personal".to_string()]; + super::status_and_layout::configure_test_session(&mut chat); + set_canonical_reset_provider(&mut chat); + chat.config.usage_limit.auto_reset_enabled = true; + chat.config.usage_self_heal.enabled = true; + chat.config.usage_self_heal.max_retries = 2; + + chat.submit_user_message(UserMessage::from("recover this failed turn")); + assert!(matches!(next_submit_op(&mut op_rx), Op::UserTurn { .. })); + chat.on_task_started(); + // No cached exhausted snapshot: the blocking window is only knowable from the error. + let reset_at = chrono::Local::now() + chrono::Duration::hours(3); + chat.on_rate_limit_error( + RateLimitErrorKind::UsageLimit, + format!( + "You've hit your usage limit. Try again at {}.", + reset_at.format("%b %d, %Y %I:%M %p") + ), + ); + // The auto-reset check owns the failed turn first, so nothing has switched yet. + assert!( + std::iter::from_fn(|| rx.try_recv().ok()) + .all(|event| !matches!(event, AppEvent::SwitchAuthProfile { .. })) + ); + + // Reset recovery turns out to be unavailable, handing the failed turn to the fallback. + chat.finish_usage_limit_auto_reset_check(1, Err("refresh failed".to_string())); + + let switches = std::iter::from_fn(|| rx.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::SwitchAuthProfile { + profile, reason, .. + } => Some((profile, reason)), + _ => None, + }) + .collect::>(); + assert_eq!( + switches, + vec![( + Some("personal".to_string()), + crate::app_event::AuthProfileSwitchReason::AutoRateLimit { + window: "5h".to_string(), + }, + )], + "the fallback must switch using the window the replayed error identifies" + ); +} diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index 87808c37d..c5e55f8c6 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -443,6 +443,12 @@ impl ChatWidget { } }); self.codex_rate_limit_reached_type = rate_limit_reached_type; + if matches!(error_kind, RateLimitErrorKind::UsageLimit) { + // Remember the server's own description of this block: the reset-unavailable + // fallback runs later, off an async reset-check response that carries no error + // text, and needs the advertised reset instant to identify the exhausted window. + self.last_usage_limit_error_message = Some(message.clone()); + } let should_retry = matches!(error_kind, RateLimitErrorKind::UsageLimit) && matches!( diff --git a/codex-rs/tui/src/chatwidget/usage_limit_reset/automatic.rs b/codex-rs/tui/src/chatwidget/usage_limit_reset/automatic.rs index 5d5917115..ae31d4870 100644 --- a/codex-rs/tui/src/chatwidget/usage_limit_reset/automatic.rs +++ b/codex-rs/tui/src/chatwidget/usage_limit_reset/automatic.rs @@ -204,9 +204,14 @@ impl ChatWidget { pub(super) fn fallback_auth_profile_switch_after_reset_unavailable(&mut self) { // Reached only after a genuine usage-limit failure whose reset recovery is // unavailable, so the current profile is authoritatively exhausted: switch even - // when the authoritative read has not yet cached the exhausted window. + // when the authoritative read has not yet cached the exhausted window. This runs + // asynchronously, long after the turn error itself, so replay the error text that + // failed the turn — without it the reset instant is unknown and the exhausted + // window cannot be identified. + let error_message = self.last_usage_limit_error_message.clone(); if self.try_auth_profile_switch_for_usage_limit( - /*is_usage_limit*/ true, /*error_message*/ None, + /*is_usage_limit*/ true, + error_message.as_deref(), ) { // A switch to a healthier profile is underway. Re-queue the interrupted turn so it // resumes automatically on the new profile once the switch is applied, instead of diff --git a/codex-rs/tui/src/chatwidget/usage_profile_broker.rs b/codex-rs/tui/src/chatwidget/usage_profile_broker.rs index ea9bbb754..7b0960274 100644 --- a/codex-rs/tui/src/chatwidget/usage_profile_broker.rs +++ b/codex-rs/tui/src/chatwidget/usage_profile_broker.rs @@ -71,15 +71,31 @@ pub(super) fn earliest_exhausted_reset_at( ) } -pub(super) fn auto_switch_trigger_key( - limit_id: &str, - window: &UsageProfileAutoSwitchWindow, -) -> String { - let resets_at = window +/// The exhaustion event an auto-switch is reacting to. +pub(super) struct AutoSwitchTrigger<'a> { + pub(super) limit_id: &'a str, + pub(super) window: &'a UsageProfileAutoSwitchWindow, + /// Disambiguates triggers whose `limit_id`, label, and reset instant are identical but + /// which describe *different* exhaustions. Required whenever the reset instant may be + /// unknown — it renders as the literal `unknown`, so without a scope every such trigger + /// collapses onto one key and only the first one ever switches. Callers pass the + /// exhausted profile plus a monotonic counter here. + pub(super) scope: Option<&'a str>, +} + +/// Identity of a single exhaustion event, used to avoid re-firing the same auto-switch. +pub(super) fn auto_switch_trigger_key(trigger: &AutoSwitchTrigger<'_>) -> String { + let resets_at = trigger + .window .resets_at .map(|reset| reset.to_string()) .unwrap_or_else(|| "unknown".to_string()); - format!("{limit_id}:{}:{resets_at}", window.label) + let limit_id = trigger.limit_id; + let label = &trigger.window.label; + match trigger.scope { + Some(scope) => format!("{limit_id}:{label}:{resets_at}:{scope}"), + None => format!("{limit_id}:{label}:{resets_at}"), + } } pub(super) fn auth_profile_auto_switch_target( @@ -91,8 +107,7 @@ pub(super) fn auth_profile_auto_switch_target( BTreeMap, >, recently_failed_profiles: &HashSet, - limit_id: &str, - window: &UsageProfileAutoSwitchWindow, + trigger: &AutoSwitchTrigger<'_>, ) -> UsageProfileSwitchOutcome { let ordered = ordered_auth_profiles_for_auto_switch(&config.profiles, saved_profiles); let candidates = auth_profile_auto_switch_candidates(selected_auth_profile, &ordered); @@ -103,13 +118,13 @@ pub(super) fn auth_profile_auto_switch_target( cached_snapshots_by_profile, recently_failed_profiles, &candidates, - window, + trigger.window, ), }; match selection.selected_profile { Some(profile) => UsageProfileSwitchOutcome::Switch(UsageProfileSwitchTarget { profile, - trigger_key: auto_switch_trigger_key(limit_id, window), + trigger_key: auto_switch_trigger_key(trigger), }), None => UsageProfileSwitchOutcome::NoEligibleProfile { earliest_reset_at: selection.retry_at,