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:
stefanstokic-oai
2026-07-14 22:35:26 +00:00
committed by copyberry
parent be0e0d791a
commit 4df8027a97
42 changed files with 1259 additions and 120 deletions

View File

@@ -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

View File

@@ -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 = [

View File

@@ -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,
})