From b79bf69af6a68d162a95af883564d9e480948e62 Mon Sep 17 00:00:00 2001 From: Colin Young Date: Thu, 29 Jan 2026 14:59:07 -0800 Subject: [PATCH] [Codex][CLI] Show model-capacity guidance on 429 (#10118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ###### Problem Users get generic 429s with no guidance when a model is at capacity. ###### Solution Detect model-cap headers, surface a clear “try a different model” message, and keep behavior non‑intrusive (no auto‑switch). ###### Scope CLI/TUI only; protocol + error mapping updated to carry model‑cap info. ###### Tests - just fmt - cargo test -p codex-tui - cargo test -p codex-core --lib shell_snapshot::tests::try_new_creates_and_deletes_snapshot_file -- --nocapture (ran in isolated env) - validate local build with backend image --- .../app-server-protocol/src/protocol/v2.rs | 11 +++ codex-rs/core/src/api_bridge.rs | 54 ++++++++++++ codex-rs/core/src/error.rs | 85 ++++++++++++++++++- codex-rs/protocol/src/protocol.rs | 4 + codex-rs/tui/src/chatwidget.rs | 79 ++++++++++++++++- codex-rs/tui/src/chatwidget/tests.rs | 37 ++++++++ 6 files changed, 268 insertions(+), 2 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 247360282..78cce916e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -86,6 +86,10 @@ macro_rules! v2_enum_from_core { pub enum CodexErrorInfo { ContextWindowExceeded, UsageLimitExceeded, + ModelCap { + model: String, + reset_after_seconds: Option, + }, HttpConnectionFailed { #[serde(rename = "httpStatusCode")] #[ts(rename = "httpStatusCode")] @@ -122,6 +126,13 @@ impl From for CodexErrorInfo { match value { CoreCodexErrorInfo::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded, CoreCodexErrorInfo::UsageLimitExceeded => CodexErrorInfo::UsageLimitExceeded, + CoreCodexErrorInfo::ModelCap { + model, + reset_after_seconds, + } => CodexErrorInfo::ModelCap { + model, + reset_after_seconds, + }, CoreCodexErrorInfo::HttpConnectionFailed { http_status_code } => { CodexErrorInfo::HttpConnectionFailed { http_status_code } } diff --git a/codex-rs/core/src/api_bridge.rs b/codex-rs/core/src/api_bridge.rs index 79ca83981..ec21f1ec8 100644 --- a/codex-rs/core/src/api_bridge.rs +++ b/codex-rs/core/src/api_bridge.rs @@ -9,6 +9,7 @@ use serde::Deserialize; use crate::auth::CodexAuth; use crate::error::CodexErr; +use crate::error::ModelCapError; use crate::error::RetryLimitReachedError; use crate::error::UnexpectedResponseError; use crate::error::UsageLimitReachedError; @@ -49,6 +50,23 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr { } else if status == http::StatusCode::INTERNAL_SERVER_ERROR { CodexErr::InternalServerError } else if status == http::StatusCode::TOO_MANY_REQUESTS { + if let Some(model) = headers + .as_ref() + .and_then(|map| map.get(MODEL_CAP_MODEL_HEADER)) + .and_then(|value| value.to_str().ok()) + .map(str::to_string) + { + let reset_after_seconds = headers + .as_ref() + .and_then(|map| map.get(MODEL_CAP_RESET_AFTER_HEADER)) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()); + return CodexErr::ModelCap(ModelCapError { + model, + reset_after_seconds, + }); + } + if let Ok(err) = serde_json::from_str::(&body_text) { if err.error.error_type.as_deref() == Some("usage_limit_reached") { let rate_limits = headers.as_ref().and_then(parse_rate_limit); @@ -92,6 +110,42 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr { } } +const MODEL_CAP_MODEL_HEADER: &str = "x-codex-model-cap-model"; +const MODEL_CAP_RESET_AFTER_HEADER: &str = "x-codex-model-cap-reset-after-seconds"; + +#[cfg(test)] +mod tests { + use super::*; + use codex_api::TransportError; + use http::HeaderMap; + use http::StatusCode; + + #[test] + fn map_api_error_maps_model_cap_headers() { + let mut headers = HeaderMap::new(); + headers.insert( + MODEL_CAP_MODEL_HEADER, + http::HeaderValue::from_static("boomslang"), + ); + headers.insert( + MODEL_CAP_RESET_AFTER_HEADER, + http::HeaderValue::from_static("120"), + ); + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: StatusCode::TOO_MANY_REQUESTS, + url: Some("http://example.com/v1/responses".to_string()), + headers: Some(headers), + body: Some(String::new()), + })); + + let CodexErr::ModelCap(model_cap) = err else { + panic!("expected CodexErr::ModelCap, got {err:?}"); + }; + assert_eq!(model_cap.model, "boomslang"); + assert_eq!(model_cap.reset_after_seconds, Some(120)); + } +} + fn extract_request_id(headers: Option<&HeaderMap>) -> Option { headers.and_then(|map| { ["cf-ray", "x-request-id", "x-oai-request-id"] diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index e9830f518..6c284074c 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -113,6 +113,9 @@ pub enum CodexErr { #[error("{0}")] UsageLimitReached(UsageLimitReachedError), + #[error("{0}")] + ModelCap(ModelCapError), + #[error("{0}")] ResponseStreamFailed(ResponseStreamFailed), @@ -205,7 +208,8 @@ impl CodexErr { | CodexErr::AgentLimitReached { .. } | CodexErr::Spawn | CodexErr::SessionConfiguredNotFirstEvent - | CodexErr::UsageLimitReached(_) => false, + | CodexErr::UsageLimitReached(_) + | CodexErr::ModelCap(_) => false, CodexErr::Stream(..) | CodexErr::Timeout | CodexErr::UnexpectedStatus(_) @@ -394,6 +398,30 @@ impl std::fmt::Display for UsageLimitReachedError { } } +#[derive(Debug)] +pub struct ModelCapError { + pub(crate) model: String, + pub(crate) reset_after_seconds: Option, +} + +impl std::fmt::Display for ModelCapError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut message = format!( + "Model {} is at capacity. Please try a different model.", + self.model + ); + if let Some(seconds) = self.reset_after_seconds { + message.push_str(&format!( + " Try again in {}.", + format_duration_short(seconds) + )); + } else { + message.push_str(" Try again later."); + } + write!(f, "{message}") + } +} + fn retry_suffix(resets_at: Option<&DateTime>) -> String { if let Some(resets_at) = resets_at { let formatted = format_retry_timestamp(resets_at); @@ -425,6 +453,18 @@ fn format_retry_timestamp(resets_at: &DateTime) -> String { } } +fn format_duration_short(seconds: u64) -> String { + if seconds < 60 { + "less than a minute".to_string() + } else if seconds < 3600 { + format!("{}m", seconds / 60) + } else if seconds < 86_400 { + format!("{}h", seconds / 3600) + } else { + format!("{}d", seconds / 86_400) + } +} + fn day_suffix(day: u32) -> &'static str { match day { 11..=13 => "th", @@ -488,6 +528,10 @@ impl CodexErr { CodexErr::UsageLimitReached(_) | CodexErr::QuotaExceeded | CodexErr::UsageNotIncluded => CodexErrorInfo::UsageLimitExceeded, + CodexErr::ModelCap(err) => CodexErrorInfo::ModelCap { + model: err.model.clone(), + reset_after_seconds: err.reset_after_seconds, + }, CodexErr::RetryLimit(_) => CodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code: self.http_status_code_value(), }, @@ -631,6 +675,45 @@ mod tests { ); } + #[test] + fn model_cap_error_formats_message() { + let err = ModelCapError { + model: "boomslang".to_string(), + reset_after_seconds: Some(120), + }; + assert_eq!( + err.to_string(), + "Model boomslang is at capacity. Please try a different model. Try again in 2m." + ); + } + + #[test] + fn model_cap_error_formats_message_without_reset() { + let err = ModelCapError { + model: "boomslang".to_string(), + reset_after_seconds: None, + }; + assert_eq!( + err.to_string(), + "Model boomslang is at capacity. Please try a different model. Try again later." + ); + } + + #[test] + fn model_cap_error_maps_to_protocol() { + let err = CodexErr::ModelCap(ModelCapError { + model: "boomslang".to_string(), + reset_after_seconds: Some(30), + }); + assert_eq!( + err.to_codex_protocol_error(), + CodexErrorInfo::ModelCap { + model: "boomslang".to_string(), + reset_after_seconds: Some(30), + } + ); + } + #[test] fn sandbox_denied_uses_aggregated_output_when_stderr_empty() { let output = ExecToolCallOutput { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index b9a4f2120..aea7d3f01 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -925,6 +925,10 @@ pub enum AgentStatus { pub enum CodexErrorInfo { ContextWindowExceeded, UsageLimitExceeded, + ModelCap { + model: String, + reset_after_seconds: Option, + }, HttpConnectionFailed { http_status_code: Option, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8a0dd81af..c13213051 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -49,6 +49,7 @@ use codex_core::protocol::AgentReasoningRawContentDeltaEvent; use codex_core::protocol::AgentReasoningRawContentEvent; use codex_core::protocol::ApplyPatchApprovalRequestEvent; use codex_core::protocol::BackgroundEventEvent; +use codex_core::protocol::CodexErrorInfo; use codex_core::protocol::CreditsSnapshot; use codex_core::protocol::DeprecationNoticeEvent; use codex_core::protocol::ErrorEvent; @@ -399,6 +400,33 @@ enum ConnectorsCacheState { Failed(String), } +#[derive(Debug)] +enum RateLimitErrorKind { + ModelCap { + model: String, + reset_after_seconds: Option, + }, + UsageLimit, + Generic, +} + +fn rate_limit_error_kind(info: &CodexErrorInfo) -> Option { + match info { + CodexErrorInfo::ModelCap { + model, + reset_after_seconds, + } => Some(RateLimitErrorKind::ModelCap { + model: model.clone(), + reset_after_seconds: *reset_after_seconds, + }), + CodexErrorInfo::UsageLimitExceeded => Some(RateLimitErrorKind::UsageLimit), + CodexErrorInfo::ResponseTooManyFailedAttempts { + http_status_code: Some(429), + } => Some(RateLimitErrorKind::Generic), + _ => None, + } +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum ExternalEditorState { #[default] @@ -1156,6 +1184,24 @@ impl ChatWidget { self.maybe_show_pending_rate_limit_prompt(); } + fn on_model_cap_error(&mut self, model: String, reset_after_seconds: Option) { + self.finalize_turn(); + + let mut message = format!("Model {model} is at capacity. Please try a different model."); + if let Some(seconds) = reset_after_seconds { + message.push_str(&format!( + " Try again in {}.", + format_duration_short(seconds) + )); + } else { + message.push_str(" Try again later."); + } + + self.add_to_history(history_cell::new_warning_event(message)); + self.request_redraw(); + self.maybe_send_next_queued_input(); + } + fn on_error(&mut self, message: String) { self.finalize_turn(); self.add_to_history(history_cell::new_error_event(message)); @@ -3085,7 +3131,26 @@ impl ChatWidget { self.on_rate_limit_snapshot(ev.rate_limits); } EventMsg::Warning(WarningEvent { message }) => self.on_warning(message), - EventMsg::Error(ErrorEvent { message, .. }) => self.on_error(message), + EventMsg::Error(ErrorEvent { + message, + codex_error_info, + }) => { + if let Some(info) = codex_error_info + && let Some(kind) = rate_limit_error_kind(&info) + { + match kind { + RateLimitErrorKind::ModelCap { + model, + reset_after_seconds, + } => self.on_model_cap_error(model, reset_after_seconds), + RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => { + self.on_error(message) + } + } + } else { + self.on_error(message); + } + } EventMsg::McpStartupUpdate(ev) => self.on_mcp_startup_update(ev), EventMsg::McpStartupComplete(ev) => self.on_mcp_startup_complete(ev), EventMsg::TurnAborted(ev) => match ev.reason { @@ -5911,5 +5976,17 @@ pub(crate) fn show_review_commit_picker_with_entries( }); } +fn format_duration_short(seconds: u64) -> String { + if seconds < 60 { + "less than a minute".to_string() + } else if seconds < 3600 { + format!("{}m", seconds / 60) + } else if seconds < 86_400 { + format!("{}h", seconds / 3600) + } else { + format!("{}d", seconds / 86_400) + } +} + #[cfg(test)] pub(crate) mod tests; diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 570a72750..9859e9e56 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -3021,6 +3021,43 @@ async fn model_picker_hides_show_in_picker_false_models_from_cache() { ); } +#[tokio::test] +async fn model_cap_error_does_not_switch_models() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("boomslang")).await; + chat.set_model("boomslang"); + while rx.try_recv().is_ok() {} + while op_rx.try_recv().is_ok() {} + + chat.handle_codex_event(Event { + id: "err-1".to_string(), + msg: EventMsg::Error(ErrorEvent { + message: "model cap".to_string(), + codex_error_info: Some(CodexErrorInfo::ModelCap { + model: "boomslang".to_string(), + reset_after_seconds: Some(120), + }), + }), + }); + + while let Ok(event) = rx.try_recv() { + if let AppEvent::UpdateModel(model) = event { + assert_eq!( + model, "boomslang", + "did not expect model switch on model-cap error" + ); + } + } + + while let Ok(event) = op_rx.try_recv() { + if let Op::OverrideTurnContext { model, .. } = event { + assert!( + model.is_none(), + "did not expect OverrideTurnContext model update on model-cap error" + ); + } + } +} + #[tokio::test] async fn approvals_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;