Codex Handbook
tui/src/chatwidget/rate_limits.rs 509 lines
//! Rate-limit warning, prompt, and notice surfaces for `ChatWidget`.use super::*;use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;pub(super) const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini";pub(super) const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0;const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0];const PRIMARY_LIMIT_FALLBACK_LABEL: &str = "usage";const SECONDARY_LIMIT_FALLBACK_LABEL: &str = "secondary usage";#[derive(Default)]pub(super) struct RateLimitWarningState {    pub(super) secondary_index: usize,    pub(super) primary_index: usize,}impl RateLimitWarningState {    pub(super) fn take_warnings(        &mut self,        secondary_used_percent: Option<f64>,        secondary_window_minutes: Option<i64>,        primary_used_percent: Option<f64>,        primary_window_minutes: Option<i64>,    ) -> Vec<String> {        let reached_secondary_cap =            matches!(secondary_used_percent, Some(percent) if percent == 100.0);        let reached_primary_cap = matches!(primary_used_percent, Some(percent) if percent == 100.0);        if reached_secondary_cap || reached_primary_cap {            return Vec::new();        }        let mut warnings = Vec::new();        if let Some(secondary_used_percent) = secondary_used_percent {            let mut highest_secondary: Option<f64> = None;            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]);                self.secondary_index += 1;            }            if let Some(threshold) = highest_secondary {                let limit_label =                    limit_label_for_window(secondary_window_minutes, /*is_secondary*/ true);                let remaining_percent = 100.0 - threshold;                warnings.push(format!(                    "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown."                ));            }        }        if let Some(primary_used_percent) = primary_used_percent {            let mut highest_primary: Option<f64> = None;            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]);                self.primary_index += 1;            }            if let Some(threshold) = highest_primary {                let limit_label =                    limit_label_for_window(primary_window_minutes, /*is_secondary*/ false);                let remaining_percent = 100.0 - threshold;                warnings.push(format!(                    "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown."                ));            }        }        warnings    }}pub(crate) fn limit_label_for_window(window_minutes: Option<i64>, is_secondary: bool) -> String {    window_minutes        .and_then(get_limits_duration)        .unwrap_or_else(|| fallback_limit_label(is_secondary).to_string())}pub(crate) fn get_limits_duration(windows_minutes: i64) -> Option<String> {    const MINUTES_PER_HOUR: i64 = 60;    const MINUTES_PER_5_HOURS: i64 = 5 * MINUTES_PER_HOUR;    const MINUTES_PER_DAY: i64 = 24 * MINUTES_PER_HOUR;    const MINUTES_PER_WEEK: i64 = 7 * MINUTES_PER_DAY;    const MINUTES_PER_MONTH: i64 = 30 * MINUTES_PER_DAY;    const MINUTES_PER_YEAR: i64 = 365 * MINUTES_PER_DAY;    let windows_minutes = windows_minutes.max(0);    if is_approximate_window(windows_minutes, MINUTES_PER_5_HOURS) {        Some("5h".to_string())    } else if is_approximate_window(windows_minutes, MINUTES_PER_DAY) {        Some("daily".to_string())    } else if is_approximate_window(windows_minutes, MINUTES_PER_WEEK) {        Some("weekly".to_string())    } else if is_approximate_window(windows_minutes, MINUTES_PER_MONTH) {        Some("monthly".to_string())    } else if is_approximate_window(windows_minutes, MINUTES_PER_YEAR) {        Some("annual".to_string())    } else {        None    }}pub(crate) fn fallback_limit_label(is_secondary: bool) -> &'static str {    if is_secondary {        SECONDARY_LIMIT_FALLBACK_LABEL    } else {        PRIMARY_LIMIT_FALLBACK_LABEL    }}fn is_approximate_window(minutes: i64, expected_minutes: i64) -> bool {    let minutes = minutes as f64;    let expected_minutes = expected_minutes as f64;    minutes >= expected_minutes * 0.95 && minutes <= expected_minutes * 1.05}#[derive(Default)]pub(super) enum RateLimitSwitchPromptState {    #[default]    Idle,    Pending,    Shown,}#[derive(Debug)]pub(super) enum RateLimitErrorKind {    ServerOverloaded,    UsageLimit,    Generic,}pub(super) fn app_server_rate_limit_error_kind(    info: &AppServerCodexErrorInfo,) -> Option<RateLimitErrorKind> {    match info {        AppServerCodexErrorInfo::ServerOverloaded => Some(RateLimitErrorKind::ServerOverloaded),        AppServerCodexErrorInfo::UsageLimitExceeded => Some(RateLimitErrorKind::UsageLimit),        AppServerCodexErrorInfo::ResponseTooManyFailedAttempts {            http_status_code: Some(429),        } => Some(RateLimitErrorKind::Generic),        _ => None,    }}pub(super) fn is_app_server_cyber_policy_error(info: &AppServerCodexErrorInfo) -> bool {    matches!(info, AppServerCodexErrorInfo::CyberPolicy)}#[derive(Clone, Copy)]enum RateLimitSnapshotSource {    AccountUsage,    RollingUpdate,}impl ChatWidget {    pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option<RateLimitSnapshot>) {        self.on_rate_limit_snapshot_from(snapshot, RateLimitSnapshotSource::AccountUsage);    }    pub(crate) fn on_rolling_rate_limit_snapshot(&mut self, snapshot: RateLimitSnapshot) {        // Rolling app-server notifications are sparse. Preserve metadata learned from the full read.        self.on_rate_limit_snapshot_from(Some(snapshot), RateLimitSnapshotSource::RollingUpdate);    }    fn on_rate_limit_snapshot_from(        &mut self,        snapshot: Option<RateLimitSnapshot>,        source: RateLimitSnapshotSource,    ) {        if let Some(mut snapshot) = snapshot {            let limit_id = snapshot                .limit_id                .clone()                .unwrap_or_else(|| "codex".to_string());            let limit_label = snapshot                .limit_name                .clone()                .unwrap_or_else(|| limit_id.clone());            if snapshot.credits.is_none() {                snapshot.credits = self                    .rate_limit_snapshots_by_limit_id                    .get(&limit_id)                    .and_then(|display| display.credits.as_ref())                    .map(|credits| CreditsSnapshot {                        has_credits: credits.has_credits,                        unlimited: credits.unlimited,                        balance: credits.balance.clone(),                    });            }            let preserved_individual_limit =                if matches!(source, RateLimitSnapshotSource::RollingUpdate)                    && snapshot.individual_limit.is_none()                {                    self.rate_limit_snapshots_by_limit_id                        .get(&limit_id)                        .and_then(|display| display.individual_limit.clone())                } else {                    None                };            self.plan_type = snapshot.plan_type.or(self.plan_type);            let is_codex_limit = limit_id.eq_ignore_ascii_case("codex");            if is_codex_limit                && let Some(rate_limit_reached_type) = snapshot.rate_limit_reached_type            {                self.codex_rate_limit_reached_type = Some(rate_limit_reached_type);            }            let warnings = if is_codex_limit {                self.rate_limit_warnings.take_warnings(                    snapshot                        .secondary                        .as_ref()                        .map(|window| f64::from(window.used_percent)),                    snapshot                        .secondary                        .as_ref()                        .and_then(|window| window.window_duration_mins),                    snapshot                        .primary                        .as_ref()                        .map(|window| f64::from(window.used_percent)),                    snapshot                        .primary                        .as_ref()                        .and_then(|window| window.window_duration_mins),                )            } else {                vec![]            };            let high_usage = is_codex_limit                && (snapshot                    .secondary                    .as_ref()                    .map(|w| f64::from(w.used_percent) >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD)                    .unwrap_or(false)                    || snapshot                        .primary                        .as_ref()                        .map(|w| f64::from(w.used_percent) >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD)                        .unwrap_or(false));            let has_workspace_credits = snapshot                .credits                .as_ref()                .map(|credits| credits.has_credits)                .unwrap_or(false);            if high_usage                && !has_workspace_credits                && !self.rate_limit_switch_prompt_hidden()                && self.current_model() != NUDGE_MODEL_SLUG                && !matches!(                    self.rate_limit_switch_prompt,                    RateLimitSwitchPromptState::Shown                )            {                self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending;            }            let mut display =                rate_limit_snapshot_display_for_limit(&snapshot, limit_label, Local::now());            if display.individual_limit.is_none() {                display.individual_limit = preserved_individual_limit;            }            self.rate_limit_snapshots_by_limit_id                .insert(limit_id, display);            if !warnings.is_empty() {                for warning in warnings {                    self.add_to_history(history_cell::new_warning_event(warning));                }                self.request_redraw();            }        } else {            self.rate_limit_snapshots_by_limit_id.clear();            self.codex_rate_limit_reached_type = None;        }        self.refresh_status_line();    }    pub(super) fn stop_rate_limit_poller(&mut self) {}    #[cfg_attr(not(test), allow(dead_code))]    pub(super) fn prefetch_rate_limits(&mut self) {        self.stop_rate_limit_poller();    }    #[cfg_attr(not(test), allow(dead_code))]    pub(super) fn should_prefetch_rate_limits(&self) -> bool {        self.config.model_provider.requires_openai_auth && self.has_chatgpt_account    }    fn lower_cost_preset(&self) -> Option<ModelPreset> {        let models = self.model_catalog.try_list_models().ok()?;        models            .iter()            .find(|preset| preset.show_in_picker && preset.model == NUDGE_MODEL_SLUG)            .cloned()    }    fn rate_limit_switch_prompt_hidden(&self) -> bool {        self.config            .notices            .hide_rate_limit_model_nudge            .unwrap_or(false)    }    pub(super) fn maybe_show_pending_rate_limit_prompt(&mut self) {        if self.rate_limit_switch_prompt_hidden() {            self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;            return;        }        if !matches!(            self.rate_limit_switch_prompt,            RateLimitSwitchPromptState::Pending        ) {            return;        }        if let Some(preset) = self.lower_cost_preset() {            self.open_rate_limit_switch_prompt(preset);            self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Shown;        } else {            self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;        }    }    fn open_rate_limit_switch_prompt(&mut self, preset: ModelPreset) {        let switch_model = preset.model;        let switch_model_for_events = switch_model.clone();        let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort;        let switch_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {            tx.send(AppEvent::CodexOp(AppCommand::override_turn_context(                /*cwd*/ None,                /*approval_policy*/ None,                /*approvals_reviewer*/ None,                /*permission_profile*/ None,                /*active_permission_profile*/ None,                /*windows_sandbox_level*/ None,                Some(switch_model_for_events.clone()),                Some(Some(default_effort.clone())),                /*summary*/ None,                /*service_tier*/ None,                /*collaboration_mode*/ None,                /*personality*/ None,            )));            tx.send(AppEvent::UpdateModel(switch_model_for_events.clone()));            tx.send(AppEvent::UpdateReasoningEffort(Some(                default_effort.clone(),            )));        })];        let keep_actions: Vec<SelectionAction> = Vec::new();        let never_actions: Vec<SelectionAction> = vec![Box::new(|tx| {            tx.send(AppEvent::UpdateRateLimitSwitchPromptHidden(true));            tx.send(AppEvent::PersistRateLimitSwitchPromptHidden);        })];        let description = if preset.description.is_empty() {            Some("Uses fewer credits for upcoming turns.".to_string())        } else {            Some(preset.description)        };        let items = vec![            SelectionItem {                name: format!("Switch to {switch_model}"),                description,                selected_description: None,                is_current: false,                actions: switch_actions,                dismiss_on_select: true,                ..Default::default()            },            SelectionItem {                name: "Keep current model".to_string(),                description: None,                selected_description: None,                is_current: false,                actions: keep_actions,                dismiss_on_select: true,                ..Default::default()            },            SelectionItem {                name: "Keep current model (never show again)".to_string(),                description: Some(                    "Hide future rate limit reminders about switching models.".to_string(),                ),                selected_description: None,                is_current: false,                actions: never_actions,                dismiss_on_select: true,                ..Default::default()            },        ];        self.bottom_pane.show_selection_view(SelectionViewParams {            title: Some("Approaching rate limits".to_string()),            subtitle: Some(format!("Switch to {switch_model} for lower credit usage?")),            footer_hint: Some(standard_popup_hint_line()),            items,            ..Default::default()        });    }    pub(super) fn open_workspace_owner_nudge_prompt(        &mut self,        credit_type: AddCreditsNudgeCreditType,    ) {        if self.add_credits_nudge_email_in_flight.is_some() {            return;        }        let (title, prompt) = match credit_type {            AddCreditsNudgeCreditType::Credits => (                "You've reached your workspace credit limit",                "Your workspace is out of credits. Ask your workspace owner to add more. Notify owner?",            ),            AddCreditsNudgeCreditType::UsageLimit => (                "Usage limit reached",                "Request a limit increase from your owner to continue using codex. Request increase?",            ),        };        let send_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {            tx.send(AppEvent::SendAddCreditsNudgeEmail { credit_type });        })];        let items = vec![            SelectionItem {                name: "Yes".to_string(),                display_shortcut: Some(key_hint::plain(KeyCode::Char('y'))),                actions: send_actions,                dismiss_on_select: true,                ..Default::default()            },            SelectionItem {                name: "No".to_string(),                display_shortcut: Some(key_hint::plain(KeyCode::Char('n'))),                is_default: true,                dismiss_on_select: true,                ..Default::default()            },        ];        self.bottom_pane.show_selection_view(SelectionViewParams {            title: Some(title.to_string()),            subtitle: Some(prompt.to_string()),            footer_hint: Some(standard_popup_hint_line()),            items,            initial_selected_idx: Some(1),            ..Default::default()        });    }    pub(crate) fn start_add_credits_nudge_email_request(        &mut self,        credit_type: AddCreditsNudgeCreditType,    ) -> bool {        self.add_credits_nudge_email_in_flight = Some(credit_type);        true    }    pub(crate) fn finish_add_credits_nudge_email_request(        &mut self,        result: Result<AddCreditsNudgeEmailStatus, String>,    ) {        let credit_type = self            .add_credits_nudge_email_in_flight            .take()            .unwrap_or(AddCreditsNudgeCreditType::Credits);        let message = match (credit_type, result) {            (AddCreditsNudgeCreditType::Credits, Ok(AddCreditsNudgeEmailStatus::Sent)) => {                "Workspace owner notified."            }            (                AddCreditsNudgeCreditType::Credits,                Ok(AddCreditsNudgeEmailStatus::CooldownActive),            ) => "Workspace owner was already notified recently.",            (AddCreditsNudgeCreditType::Credits, Err(_)) => {                "Could not notify your workspace owner. Please try again."            }            (AddCreditsNudgeCreditType::UsageLimit, Ok(AddCreditsNudgeEmailStatus::Sent)) => {                "Limit increase requested."            }            (                AddCreditsNudgeCreditType::UsageLimit,                Ok(AddCreditsNudgeEmailStatus::CooldownActive),            ) => "A limit increase was already requested recently.",            (AddCreditsNudgeCreditType::UsageLimit, Err(_)) => {                "Could not request a limit increase. Please try again."            }        };        self.add_to_history(history_cell::new_info_event(            message.to_string(),            /*hint*/ None,        ));        self.request_redraw();    }    pub(crate) fn set_rate_limit_switch_prompt_hidden(&mut self, hidden: bool) {        self.config.notices.hide_rate_limit_model_nudge = Some(hidden);        if hidden {            self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;        }    }}