mirror of
https://github.com/openai/codex.git
synced 2026-05-24 04:54:52 +00:00
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.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<String> {
|
||||
.map(std::string::ToString::to_string)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_rate_limit_reached_type(headers: &HeaderMap) -> Option<RateLimitReachedType> {
|
||||
parse_header_str(headers, "x-codex-rate-limit-reached-type")?
|
||||
.trim()
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn parse_rate_limit_window(
|
||||
headers: &HeaderMap,
|
||||
used_percent_header: &str,
|
||||
|
||||
@@ -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<DateTime<Utc>>,
|
||||
pub rate_limits: Option<Box<RateLimitSnapshot>>,
|
||||
pub promo_message: Option<String>,
|
||||
pub rate_limit_reached_type: Option<RateLimitReachedType>,
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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 <PLAN> 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 <PLAN> today, or try again at {expected_time}."
|
||||
|
||||
@@ -2000,6 +2000,21 @@ pub enum RateLimitReachedType {
|
||||
WorkspaceMemberUsageLimitReached,
|
||||
}
|
||||
|
||||
impl FromStr for RateLimitReachedType {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user