[Codex][CLI] Show model-capacity guidance on 429 (#10118)

###### 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
     
<img width="719" height="845" alt="image"
src="https://github.com/user-attachments/assets/1470b33d-0974-4b1f-b8e6-d11f892f4b54"
/>
This commit is contained in:
Colin Young
2026-01-29 14:59:07 -08:00
committed by GitHub
Unverified
parent ca9d417633
commit b79bf69af6
6 changed files with 268 additions and 2 deletions
@@ -86,6 +86,10 @@ macro_rules! v2_enum_from_core {
pub enum CodexErrorInfo {
ContextWindowExceeded,
UsageLimitExceeded,
ModelCap {
model: String,
reset_after_seconds: Option<u64>,
},
HttpConnectionFailed {
#[serde(rename = "httpStatusCode")]
#[ts(rename = "httpStatusCode")]
@@ -122,6 +126,13 @@ impl From<CoreCodexErrorInfo> 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 }
}
+54
View File
@@ -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::<u64>().ok());
return CodexErr::ModelCap(ModelCapError {
model,
reset_after_seconds,
});
}
if let Ok(err) = serde_json::from_str::<UsageErrorResponse>(&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<String> {
headers.and_then(|map| {
["cf-ray", "x-request-id", "x-oai-request-id"]
+84 -1
View File
@@ -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<u64>,
}
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<Utc>>) -> 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<Utc>) -> 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 {
+4
View File
@@ -925,6 +925,10 @@ pub enum AgentStatus {
pub enum CodexErrorInfo {
ContextWindowExceeded,
UsageLimitExceeded,
ModelCap {
model: String,
reset_after_seconds: Option<u64>,
},
HttpConnectionFailed {
http_status_code: Option<u16>,
},
+78 -1
View File
@@ -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<u64>,
},
UsageLimit,
Generic,
}
fn rate_limit_error_kind(info: &CodexErrorInfo) -> Option<RateLimitErrorKind> {
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<u64>) {
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;
+37
View File
@@ -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;