From a876a9e49415beee2c7283b6f715cee6e81d4181 Mon Sep 17 00:00:00 2001 From: stefanstokic-oai Date: Tue, 1 Sep 2026 21:32:55 +0000 Subject: [PATCH] Add early rate-limit warnings for Plus and Team plans (#42142) ## What changed - Warn Plus and Team users when they have less than 50% of an approximately five-hour usage window remaining. - Preserve the existing 75%, 90%, and 95% usage thresholds for other plans and window lengths. - Continue deduplicating warnings across rolling rate-limit updates. ## Testing - Cover eligible and ineligible plan and window combinations, retained plan metadata, and warning deduplication. GitOrigin-RevId: b221def052b979c91490e7878829db2478c6e264 --- codex-rs/tui/src/chatwidget/rate_limits.rs | 22 ++++++- .../src/chatwidget/tests/status_and_layout.rs | 62 +++++++++++++++++-- 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index 255b8e2897..8533fd72cd 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -15,7 +15,7 @@ pub(super) const NUDGE_MODEL_SLUG: &str = "gpt-5.6-luna"; pub(super) const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; pub(super) const RATE_LIMIT_SWITCH_PROMPT_VIEW_ID: &str = "rate-limit-switch-prompt"; -const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0]; +const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 4] = [50.0, 75.0, 90.0, 95.0]; const PRIMARY_LIMIT_FALLBACK_LABEL: &str = "usage"; const SECONDARY_LIMIT_FALLBACK_LABEL: &str = "secondary usage"; @@ -28,6 +28,7 @@ pub(super) struct RateLimitWarningState { impl RateLimitWarningState { pub(super) fn take_warnings( &mut self, + plan_type: Option, secondary_used_percent: Option, secondary_window_minutes: Option, primary_used_percent: Option, @@ -47,7 +48,14 @@ impl RateLimitWarningState { while self.secondary_index < RATE_LIMIT_WARNING_THRESHOLDS.len() && secondary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index] { - highest_secondary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]); + let threshold = RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]; + if threshold != 50.0 + || (matches!(plan_type, Some(PlanType::Plus | PlanType::Team)) + && secondary_window_minutes + .is_some_and(|minutes| is_approximate_window(minutes, 5 * 60))) + { + highest_secondary = Some(threshold); + } self.secondary_index += 1; } if let Some(threshold) = highest_secondary { @@ -65,7 +73,14 @@ impl RateLimitWarningState { while self.primary_index < RATE_LIMIT_WARNING_THRESHOLDS.len() && primary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index] { - highest_primary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]); + let threshold = RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]; + if threshold != 50.0 + || (matches!(plan_type, Some(PlanType::Plus | PlanType::Team)) + && primary_window_minutes + .is_some_and(|minutes| is_approximate_window(minutes, 5 * 60))) + { + highest_primary = Some(threshold); + } self.primary_index += 1; } if let Some(threshold) = highest_primary { @@ -280,6 +295,7 @@ impl ChatWidget { let should_warn_about_rate_limit_usage = is_codex_limit && !has_workspace_credits; let warnings = if should_warn_about_rate_limit_usage { self.rate_limit_warnings.take_warnings( + self.plan_type, snapshot .secondary .as_ref() 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 2a6d8738a4..762e7019ba 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -545,14 +545,15 @@ async fn prefetch_rate_limits_is_gated_on_chatgpt_auth_provider() { #[tokio::test] async fn rate_limit_warnings_emit_thresholds() { let mut state = RateLimitWarningState::default(); + let plan = None; let mut warnings: Vec = Vec::new(); - warnings.extend(state.take_warnings(Some(10.0), Some(10079), Some(55.0), Some(299))); - warnings.extend(state.take_warnings(Some(55.0), Some(10081), Some(10.0), Some(299))); - warnings.extend(state.take_warnings(Some(10.0), Some(10081), Some(80.0), Some(299))); - warnings.extend(state.take_warnings(Some(80.0), Some(10081), Some(10.0), Some(299))); - warnings.extend(state.take_warnings(Some(10.0), Some(10081), Some(95.0), Some(299))); - warnings.extend(state.take_warnings(Some(95.0), Some(10079), Some(10.0), Some(299))); + warnings.extend(state.take_warnings(plan, Some(10.0), Some(10079), Some(55.0), Some(299))); + warnings.extend(state.take_warnings(plan, Some(55.0), Some(10081), Some(10.0), Some(299))); + warnings.extend(state.take_warnings(plan, Some(10.0), Some(10081), Some(80.0), Some(299))); + warnings.extend(state.take_warnings(plan, Some(80.0), Some(10081), Some(10.0), Some(299))); + warnings.extend(state.take_warnings(plan, Some(10.0), Some(10081), Some(95.0), Some(299))); + warnings.extend(state.take_warnings(plan, Some(95.0), Some(10079), Some(10.0), Some(299))); assert_eq!( warnings, @@ -574,12 +575,59 @@ async fn rate_limit_warnings_emit_thresholds() { ); } +#[tokio::test] +async fn rate_limit_usage_warnings_early_threshold_is_scoped_and_deduplicated() { + for (plan_type, window_minutes, should_warn_early) in [ + (Some(PlanType::Plus), Some(300), true), + (Some(PlanType::Team), Some(299), true), + (Some(PlanType::Pro), Some(300), false), + (Some(PlanType::Business), Some(300), false), + (Some(PlanType::SelfServeBusinessProLite), Some(300), false), + (None, Some(300), false), + (Some(PlanType::Plus), Some(10080), false), + (Some(PlanType::Team), None, false), + ] { + let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await; + let mut usage = snapshot(/*percent*/ 49.0); + usage.plan_type = plan_type; + usage.primary.as_mut().unwrap().window_duration_mins = window_minutes; + chat.on_rate_limit_snapshot(Some(usage.clone())); + assert!(drain_insert_history(&mut rx).is_empty()); + + // Rolling updates retain the plan learned from the account usage response. + usage.plan_type = None; + usage.primary.as_mut().unwrap().used_percent = 50; + chat.on_rolling_rate_limit_snapshot(usage.clone()); + let warnings = drain_insert_history(&mut rx); + assert_eq!(!warnings.is_empty(), should_warn_early); + if should_warn_early { + insta::allow_duplicates! { + insta::assert_snapshot!(lines_to_single_string(&warnings.concat()), @r" + ⚠ Heads up, you have less than 50% of your 5h limit left. Run /status for a + breakdown. + "); + } + } + + chat.on_rolling_rate_limit_snapshot(usage.clone()); + assert!(drain_insert_history(&mut rx).is_empty()); + for used_percent in [75, 90, 95] { + usage.primary.as_mut().unwrap().used_percent = used_percent; + chat.on_rolling_rate_limit_snapshot(usage.clone()); + assert_eq!(drain_insert_history(&mut rx).len(), 1); + chat.on_rolling_rate_limit_snapshot(usage.clone()); + assert!(drain_insert_history(&mut rx).is_empty()); + } + } +} + #[tokio::test] async fn test_rate_limit_warnings_monthly() { let mut state = RateLimitWarningState::default(); let mut warnings: Vec = Vec::new(); warnings.extend(state.take_warnings( + /*plan_type*/ None, Some(75.0), Some(43199), /*primary_used_percent*/ None, @@ -610,6 +658,7 @@ async fn test_rate_limit_warnings_use_generic_fallback_labels() { assert_eq!( state.take_warnings( + /*plan_type*/ None, /*secondary_used_percent*/ Some(75.0), /*secondary_window_minutes*/ None, /*primary_used_percent*/ Some(75.0), @@ -632,6 +681,7 @@ async fn test_rate_limit_warnings_use_secondary_fallback_for_unsupported_window( assert_eq!( state.take_warnings( + /*plan_type*/ None, /*secondary_used_percent*/ Some(75.0), /*secondary_window_minutes*/ Some(2 * 60), /*primary_used_percent*/ None,