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
This commit is contained in:
stefanstokic-oai
2026-09-01 21:42:40 +00:00
committed by copyberry
parent b192442fc7
commit a876a9e494
2 changed files with 75 additions and 9 deletions
+19 -3
View File
@@ -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<PlanType>,
secondary_used_percent: Option<f64>,
secondary_window_minutes: Option<i64>,
primary_used_percent: Option<f64>,
@@ -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()
@@ -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<String> = 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<String> = 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,