From 4bcabbfbec668de26bdb2b1c39f4748c419962ff Mon Sep 17 00:00:00 2001 From: dhruvgupta-oai Date: Fri, 22 May 2026 19:58:49 -0400 Subject: [PATCH] Display workspace usage limit error copy from response header (#24114) ## Why `openai/openai#947613` adds `X-Codex-Rate-Limit-Reached-Type` for Codex workspace credit-depletion and spend-cap responses. The CLI currently reads the adjacent promo header but otherwise renders generic usage-limit copy, so those responses do not explain the workspace-specific action the user needs to take. Backend dependency: https://github.com/openai/openai/pull/947613 ## What Changed - Parse `X-Codex-Rate-Limit-Reached-Type` in the usage-limit error handling path alongside `x-codex-promo-message`. - Keep the header value parsing with the shared `RateLimitReachedType` enum. - Carry the parsed type on `UsageLimitReachedError` and render client-owned copy for the four workspace owner/member credit and spend-cap values. - Preserve existing promo and plan-based text for absent, generic, or unknown header values. - Keep the existing TUI workspace-owner nudge state path unchanged; the response header only selects the displayed error string. - Add focused display coverage for all specific type values and the generic fallback case. ## Test Plan - Added `usage_limit_reached_error_formats_rate_limit_reached_types` coverage. - Not run manually, per request; CI runs validation on the pushed commit. --- codex-rs/codex-api/src/api_bridge.rs | 4 ++ codex-rs/codex-api/src/api_bridge_tests.rs | 31 +++++++++++++ codex-rs/codex-api/src/rate_limits.rs | 8 ++++ codex-rs/protocol/src/error.rs | 34 ++++++++++++++ codex-rs/protocol/src/error_tests.rs | 54 ++++++++++++++++++++++ codex-rs/protocol/src/protocol.rs | 15 ++++++ 6 files changed, 146 insertions(+) diff --git a/codex-rs/codex-api/src/api_bridge.rs b/codex-rs/codex-api/src/api_bridge.rs index 401dfab3a9..1c34d8bbf2 100644 --- a/codex-rs/codex-api/src/api_bridge.rs +++ b/codex-rs/codex-api/src/api_bridge.rs @@ -2,6 +2,7 @@ use crate::TransportError; use crate::error::ApiError; use crate::rate_limits::parse_promo_message; use crate::rate_limits::parse_rate_limit_for_limit; +use crate::rate_limits::parse_rate_limit_reached_type; use base64::Engine; use chrono::DateTime; use chrono::Utc; @@ -85,6 +86,8 @@ pub fn map_api_error(err: ApiError) -> CodexErr { parse_rate_limit_for_limit(map, limit_id.as_deref()) }); let promo_message = headers.as_ref().and_then(parse_promo_message); + let rate_limit_reached_type = + headers.as_ref().and_then(parse_rate_limit_reached_type); let resets_at = err .error .resets_at @@ -94,6 +97,7 @@ pub fn map_api_error(err: ApiError) -> CodexErr { resets_at, rate_limits: rate_limits.map(Box::new), promo_message, + rate_limit_reached_type, }); } else if err.error.error_type.as_deref() == Some("usage_not_included") { return CodexErr::UsageNotIncluded; diff --git a/codex-rs/codex-api/src/api_bridge_tests.rs b/codex-rs/codex-api/src/api_bridge_tests.rs index af7e34a649..101e5566fe 100644 --- a/codex-rs/codex-api/src/api_bridge_tests.rs +++ b/codex-rs/codex-api/src/api_bridge_tests.rs @@ -194,6 +194,37 @@ fn map_api_error_does_not_fallback_limit_name_to_limit_id() { ); } +#[test] +fn map_api_error_ignores_unparseable_rate_limit_reached_type_headers() { + let values = [ + http::HeaderValue::from_static("future_rate_limit_reached_type"), + http::HeaderValue::from_bytes(&[0xff]).expect("valid opaque header value"), + ]; + + for value in values { + let mut headers = HeaderMap::new(); + headers.insert("x-codex-rate-limit-reached-type", value); + let body = serde_json::json!({ + "error": { + "type": "usage_limit_reached", + "plan_type": "pro", + } + }) + .to_string(); + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::TOO_MANY_REQUESTS, + url: Some("http://example.com/v1/responses".to_string()), + headers: Some(headers), + body: Some(body), + })); + + let CodexErr::UsageLimitReached(usage_limit) = err else { + panic!("expected CodexErr::UsageLimitReached, got {err:?}"); + }; + assert_eq!(usage_limit.rate_limit_reached_type, None); + } +} + #[test] fn map_api_error_extracts_identity_auth_details_from_headers() { let mut headers = HeaderMap::new(); diff --git a/codex-rs/codex-api/src/rate_limits.rs b/codex-rs/codex-api/src/rate_limits.rs index 979500cdab..a2ad876671 100644 --- a/codex-rs/codex-api/src/rate_limits.rs +++ b/codex-rs/codex-api/src/rate_limits.rs @@ -1,5 +1,6 @@ use codex_protocol::account::PlanType; use codex_protocol::protocol::CreditsSnapshot; +use codex_protocol::protocol::RateLimitReachedType; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; use http::HeaderMap; @@ -178,6 +179,13 @@ pub fn parse_promo_message(headers: &HeaderMap) -> Option { .map(std::string::ToString::to_string) } +pub(crate) fn parse_rate_limit_reached_type(headers: &HeaderMap) -> Option { + parse_header_str(headers, "x-codex-rate-limit-reached-type")? + .trim() + .parse() + .ok() +} + fn parse_rate_limit_window( headers: &HeaderMap, used_percent_header: &str, diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs index ef9c86cadf..d7e953af0b 100644 --- a/codex-rs/protocol/src/error.rs +++ b/codex-rs/protocol/src/error.rs @@ -7,6 +7,7 @@ use crate::exec_output::ExecToolCallOutput; use crate::network_policy::NetworkPolicyDecisionPayload; use crate::protocol::CodexErrorInfo; use crate::protocol::ErrorEvent; +use crate::protocol::RateLimitReachedType; use crate::protocol::RateLimitSnapshot; use crate::protocol::TruncationPolicy; use chrono::DateTime; @@ -451,6 +452,7 @@ pub struct UsageLimitReachedError { pub resets_at: Option>, pub rate_limits: Option>, pub promo_message: Option, + pub rate_limit_reached_type: Option, } impl std::fmt::Display for UsageLimitReachedError { @@ -470,6 +472,38 @@ impl std::fmt::Display for UsageLimitReachedError { ); } + if let Some(rate_limit_reached_type) = self.rate_limit_reached_type { + match rate_limit_reached_type { + RateLimitReachedType::WorkspaceOwnerCreditsDepleted => { + return write!( + f, + "Your workspace is out of credits. Add credits to continue." + ); + } + RateLimitReachedType::WorkspaceMemberCreditsDepleted => { + return write!( + f, + "Your workspace is out of credits. Ask your workspace owner to refill in order to continue." + ); + } + RateLimitReachedType::WorkspaceOwnerUsageLimitReached => { + return write!( + f, + "You hit your spend cap set in your workspace. Increase your spend cap to continue." + ); + } + RateLimitReachedType::WorkspaceMemberUsageLimitReached => { + return write!( + f, + "You hit your spend cap set by the owner of your workspace. Ask an owner to increase your spend cap to continue." + ); + } + RateLimitReachedType::RateLimitReached => { + // Generic limits intentionally use the existing promo or plan copy below. + } + } + } + if let Some(promo_message) = &self.promo_message { return write!( f, diff --git a/codex-rs/protocol/src/error_tests.rs b/codex-rs/protocol/src/error_tests.rs index aef7478607..11bd26133b 100644 --- a/codex-rs/protocol/src/error_tests.rs +++ b/codex-rs/protocol/src/error_tests.rs @@ -56,6 +56,7 @@ fn usage_limit_reached_error_formats_plus_plan() { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; assert_eq!( err.to_string(), @@ -63,6 +64,44 @@ fn usage_limit_reached_error_formats_plus_plan() { ); } +#[test] +fn usage_limit_reached_error_formats_rate_limit_reached_types() { + let cases = [ + ( + RateLimitReachedType::RateLimitReached, + "You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again later.", + ), + ( + RateLimitReachedType::WorkspaceOwnerCreditsDepleted, + "Your workspace is out of credits. Add credits to continue.", + ), + ( + RateLimitReachedType::WorkspaceMemberCreditsDepleted, + "Your workspace is out of credits. Ask your workspace owner to refill in order to continue.", + ), + ( + RateLimitReachedType::WorkspaceOwnerUsageLimitReached, + "You hit your spend cap set in your workspace. Increase your spend cap to continue.", + ), + ( + RateLimitReachedType::WorkspaceMemberUsageLimitReached, + "You hit your spend cap set by the owner of your workspace. Ask an owner to increase your spend cap to continue.", + ), + ]; + + for (rate_limit_reached_type, expected) in cases { + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::Plus)), + resets_at: None, + rate_limits: Some(Box::new(rate_limit_snapshot())), + promo_message: None, + rate_limit_reached_type: Some(rate_limit_reached_type), + }; + + assert_eq!(err.to_string(), expected); + } +} + #[test] fn server_overloaded_maps_to_protocol() { let err = CodexErr::ServerOverloaded; @@ -177,6 +216,7 @@ fn usage_limit_reached_error_formats_free_plan() { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; assert_eq!( err.to_string(), @@ -191,6 +231,7 @@ fn usage_limit_reached_error_formats_go_plan() { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; assert_eq!( err.to_string(), @@ -205,6 +246,7 @@ fn usage_limit_reached_error_formats_default_when_none() { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; assert_eq!( err.to_string(), @@ -223,6 +265,7 @@ fn usage_limit_reached_error_formats_team_plan() { resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; let expected = format!( "You've hit your usage limit. To get more access now, send a request to your admin or try again at {expected_time}." @@ -238,6 +281,7 @@ fn usage_limit_reached_error_formats_business_plan_without_reset() { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; assert_eq!( err.to_string(), @@ -252,6 +296,7 @@ fn usage_limit_reached_error_formats_self_serve_business_usage_based_plan() { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; assert_eq!( err.to_string(), @@ -266,6 +311,7 @@ fn usage_limit_reached_error_formats_enterprise_cbp_usage_based_plan() { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; assert_eq!( err.to_string(), @@ -280,6 +326,7 @@ fn usage_limit_reached_error_formats_default_for_other_plans() { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; assert_eq!( err.to_string(), @@ -298,6 +345,7 @@ fn usage_limit_reached_error_formats_pro_plan_with_reset() { resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; let expected = format!( "You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}." @@ -324,6 +372,7 @@ fn usage_limit_reached_error_hides_upsell_for_non_codex_limit_name() { "Visit https://chatgpt.com/codex/settings/usage to purchase more credits" .to_string(), ), + rate_limit_reached_type: None, }; let expected = format!( "You've hit your usage limit for codex_other. Switch to another model now, or try again at {expected_time}." @@ -343,6 +392,7 @@ fn usage_limit_reached_includes_minutes_when_available() { resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; let expected = format!("You've hit your usage limit. Try again at {expected_time}."); assert_eq!(err.to_string(), expected); @@ -482,6 +532,7 @@ fn usage_limit_reached_includes_hours_and_minutes() { resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; let expected = format!( "You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}." @@ -502,6 +553,7 @@ fn usage_limit_reached_includes_days_hours_minutes() { resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; let expected = format!("You've hit your usage limit. Try again at {expected_time}."); assert_eq!(err.to_string(), expected); @@ -519,6 +571,7 @@ fn usage_limit_reached_less_than_minute() { resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; let expected = format!("You've hit your usage limit. Try again at {expected_time}."); assert_eq!(err.to_string(), expected); @@ -538,6 +591,7 @@ fn usage_limit_reached_with_promo_message() { promo_message: Some( "To continue using Codex, start a free trial of today".to_string(), ), + rate_limit_reached_type: None, }; let expected = format!( "You've hit your usage limit. To continue using Codex, start a free trial of today, or try again at {expected_time}." diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7d955f1d1f..d3b8a9e820 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2000,6 +2000,21 @@ pub enum RateLimitReachedType { WorkspaceMemberUsageLimitReached, } +impl FromStr for RateLimitReachedType { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "rate_limit_reached" => Ok(Self::RateLimitReached), + "workspace_owner_credits_depleted" => Ok(Self::WorkspaceOwnerCreditsDepleted), + "workspace_member_credits_depleted" => Ok(Self::WorkspaceMemberCreditsDepleted), + "workspace_owner_usage_limit_reached" => Ok(Self::WorkspaceOwnerUsageLimitReached), + "workspace_member_usage_limit_reached" => Ok(Self::WorkspaceMemberUsageLimitReached), + other => Err(format!("unknown rate limit reached type: {other}")), + } + } +} + #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct RateLimitWindow { /// Percentage (0-100) of the window that has been consumed.