mirror of
https://github.com/openai/codex.git
synced 2026-05-28 15:00:16 +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:
@@ -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