mirror of
https://github.com/openai/codex.git
synced 2026-08-23 13:09:46 +00:00
Honor workspace spend controls in rate-limit handling (#33187)
## Why Sparse and out-of-order rate-limit updates can omit account metadata or let an older full read overwrite a newer workspace hard stop. Credit availability also does not always include a displayable balance. ## What changed - Propagate the backend's `spendControlReached` state through rate-limit snapshots and the app-server protocol, preserving it across sparse updates. - Keep workspace hard-stop classifications for error routing and ignore stale full-read results after a newer hard-stop notification. - Base TUI warnings and model-switch prompts on credit availability and hard-stop state, and show `Credits: Available` when a finite balance is hidden. ## Testing Added coverage for spend-control mapping, snapshot merging, out-of-order reads, workspace error routing, warning and prompt behavior, and credit status display. GitOrigin-RevId: 18b09d493d5a620a7c079fb721ec50b38dd5b04c
This commit is contained in:
committed by
copyberry
parent
be0e0d791a
commit
4df8027a97
@@ -86,12 +86,18 @@ pub fn map_api_error(err: ApiError) -> CodexErr {
|
||||
if let Ok(err) = serde_json::from_str::<UsageErrorResponse>(&body_text) {
|
||||
if err.error.error_type.as_deref() == Some("usage_limit_reached") {
|
||||
let limit_id = extract_header(headers.as_ref(), ACTIVE_LIMIT_HEADER);
|
||||
let rate_limits = headers.as_ref().and_then(|map| {
|
||||
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 rate_limits = headers
|
||||
.as_ref()
|
||||
.and_then(|map| {
|
||||
parse_rate_limit_for_limit(map, limit_id.as_deref())
|
||||
})
|
||||
.map(|mut snapshot| {
|
||||
snapshot.rate_limit_reached_type = rate_limit_reached_type;
|
||||
snapshot
|
||||
});
|
||||
let resets_at = err
|
||||
.error
|
||||
.resets_at
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use base64::Engine;
|
||||
use codex_protocol::protocol::RateLimitReachedType;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
@@ -222,6 +223,70 @@ fn map_api_error_does_not_fallback_limit_name_to_limit_id() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_api_error_copies_rate_limit_reached_type_to_usage_limit_snapshot() {
|
||||
for (active_limit, expected_limit_id) in [(None, "codex"), (Some("codex_other"), "codex_other")]
|
||||
{
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(active_limit) = active_limit {
|
||||
headers.insert(
|
||||
ACTIVE_LIMIT_HEADER,
|
||||
http::HeaderValue::from_static(active_limit),
|
||||
);
|
||||
}
|
||||
for (name, value) in [
|
||||
("x-codex-credits-has-credits", "true"),
|
||||
("x-codex-credits-unlimited", "false"),
|
||||
("x-codex-credits-balance", ""),
|
||||
(
|
||||
"x-codex-rate-limit-reached-type",
|
||||
"workspace_member_usage_limit_reached",
|
||||
),
|
||||
] {
|
||||
headers.insert(name, http::HeaderValue::from_static(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,
|
||||
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached)
|
||||
);
|
||||
let snapshot = usage_limit
|
||||
.rate_limits
|
||||
.as_ref()
|
||||
.expect("usage limit snapshot");
|
||||
assert_eq!(snapshot.limit_id.as_deref(), Some(expected_limit_id));
|
||||
assert_eq!(
|
||||
snapshot.rate_limit_reached_type,
|
||||
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached)
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.credits.as_ref().map(|credits| (
|
||||
credits.has_credits,
|
||||
credits.unlimited,
|
||||
credits.balance.as_deref()
|
||||
)),
|
||||
Some((true, false, None))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_api_error_ignores_unparseable_rate_limit_reached_type_headers() {
|
||||
let values = [
|
||||
|
||||
@@ -94,6 +94,7 @@ pub fn parse_rate_limit_for_limit(
|
||||
secondary,
|
||||
credits,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
})
|
||||
@@ -159,6 +160,7 @@ pub fn parse_rate_limit_event(payload: &str) -> Option<RateLimitSnapshot> {
|
||||
secondary,
|
||||
credits,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: event.plan_type,
|
||||
rate_limit_reached_type: None,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user