feat: show enterprise monthly credit limits in status (#24812)

## Summary

Enterprise users can have an effective monthly credit limit, but Codex
`/status` currently drops that metadata from the account-usage response.

This change adds the optional `spend_control.individual_limit`
projection to the existing rate-limit snapshot flow. The backend client
reads the monthly limit, app-server exposes it as `individualLimit`, and
the TUI renders a `Monthly credit limit` row through the existing
progress-bar renderer.

When the backend does not return an effective monthly limit, existing
rate-limit behavior is unchanged.

## Existing backend state

The account-usage backend already returns the effective monthly limit
and current usage together:

```json
{
  "spend_control": {
    "reached": false,
    "individual_limit": {
      "limit": "25000",
      "used": "8000",
      "remaining": "17000",
      "used_percent": 32,
      "remaining_percent": 68,
      "reset_after_seconds": 86400,
      "reset_at": 1778137680
    }
  }
}
```

Before this change, Codex projected rolling `primary` and `secondary`
windows plus `credits`. It ignored `spend_control.individual_limit`, so
app-server clients and `/status` could not render the monthly cap.

The updated flow is:

```text
account usage backend
  -> backend-client reads spend_control.individual_limit
  -> existing rate-limit snapshot carries optional individual_limit
  -> app-server exposes optional individualLimit
  -> TUI renders Monthly credit limit
```

## App-server contract

`account/rateLimits/read` and sparse `account/rateLimits/updated`
notifications now include an additive nullable
`rateLimits.individualLimit` field:

```json
{
  "individualLimit": {
    "limit": "25000",
    "used": "8000",
    "remainingPercent": 68,
    "resetsAt": 1778137680
  }
}
```

In an `account/rateLimits/read` response, `null` means no monthly limit
is available. `account/rateLimits/updated` remains a sparse rolling
notification: clients merge available values into their most recent
`account/rateLimits/read` snapshot or refetch. Nullable account metadata
in a rolling notification does not clear a previously observed value.

## Design decisions

- Extend the existing rate-limit snapshot instead of introducing a
separate request or wire-level update protocol.
- Keep the Codex projection narrow: `/status` needs the effective limit,
current usage, remaining percentage, and reset timestamp.
- Render the monthly row through the existing progress-bar renderer,
with one optional detail line for `8,000 of 25,000 credits used`.
- Keep the backend response optional so existing accounts and older
usage states preserve their current behavior.
- Preserve cached monthly metadata when sparse rolling notifications
omit it. Live account-usage reads remain authoritative and can clear a
removed limit.

## Visual evidence

```text
 Monthly credit limit:   [██████████████░░░░░░] 68% left (resets 07:08 on 7 May)
                         8,000 of 25,000 credits used
```

Snapshot:
`codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_enterprise_monthly_credit_limit.snap`

## Testing

Tests: generated app-server schema verification, protocol tests,
backend-client tests, app-server integration coverage, TUI snapshot
coverage, formatting, and workspace lint cleanup.
This commit is contained in:
efrazer-oai
2026-06-01 21:25:42 -07:00
committed by GitHub
parent c955f73078
commit c8e5db16c9
40 changed files with 822 additions and 11 deletions
+53
View File
@@ -16,6 +16,7 @@ use codex_protocol::protocol::CreditsSnapshot;
use codex_protocol::protocol::RateLimitReachedType;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::SpendControlLimitSnapshot;
use reqwest::StatusCode;
use reqwest::header::CONTENT_TYPE;
use reqwest::header::HeaderMap;
@@ -470,11 +471,17 @@ impl Client {
.rate_limit_reached_type
.flatten()
.and_then(|details| Self::map_rate_limit_reached_type(details.kind));
let individual_limit = payload
.spend_control
.flatten()
.and_then(|details| details.individual_limit.flatten())
.map(|details| Self::map_individual_limit(*details));
let mut snapshots = vec![Self::make_rate_limit_snapshot(
Some("codex".to_string()),
/*limit_name*/ None,
payload.rate_limit.flatten().map(|details| *details),
payload.credits.flatten().map(|details| *details),
individual_limit,
plan_type,
rate_limit_reached_type,
)];
@@ -485,6 +492,7 @@ impl Client {
Some(details.limit_name),
details.rate_limit.flatten().map(|rate_limit| *rate_limit),
/*credits*/ None,
/*individual_limit*/ None,
plan_type,
/*rate_limit_reached_type*/ None,
)
@@ -498,6 +506,7 @@ impl Client {
limit_name: Option<String>,
rate_limit: Option<crate::types::RateLimitStatusDetails>,
credits: Option<crate::types::CreditStatusDetails>,
individual_limit: Option<SpendControlLimitSnapshot>,
plan_type: Option<AccountPlanType>,
rate_limit_reached_type: Option<RateLimitReachedType>,
) -> RateLimitSnapshot {
@@ -514,6 +523,7 @@ impl Client {
primary,
secondary,
credits: Self::map_credits(credits),
individual_limit,
plan_type,
rate_limit_reached_type,
}
@@ -582,6 +592,17 @@ impl Client {
})
}
fn map_individual_limit(
details: crate::types::SpendControlLimitDetails,
) -> SpendControlLimitSnapshot {
SpendControlLimitSnapshot {
limit: details.limit,
used: details.used,
remaining_percent: details.remaining_percent,
resets_at: i64::from(details.reset_at),
}
}
fn map_plan_type(plan_type: crate::types::PlanType) -> AccountPlanType {
match plan_type {
crate::types::PlanType::Free => AccountPlanType::Free,
@@ -676,6 +697,23 @@ mod tests {
balance: Some(Some("9.99".to_string())),
..Default::default()
}))),
spend_control: Some(Some(Box::new(
codex_backend_openapi_models::models::SpendControlStatusDetails {
reached: false,
individual_limit: Some(Some(Box::new(
crate::types::SpendControlLimitDetails {
source: None,
limit: "25000".to_string(),
used: "8000".to_string(),
remaining: "17000".to_string(),
used_percent: 32,
remaining_percent: 68,
reset_after_seconds: 3600,
reset_at: 789,
},
))),
},
))),
rate_limit_reached_type: Some(Some(BackendRateLimitReachedType {
kind: RateLimitReachedKind::WorkspaceMemberCreditsDepleted,
})),
@@ -707,6 +745,15 @@ mod tests {
snapshots[0].rate_limit_reached_type,
Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted)
);
assert_eq!(
snapshots[0].individual_limit,
Some(SpendControlLimitSnapshot {
limit: "25000".to_string(),
used: "8000".to_string(),
remaining_percent: 68,
resets_at: 789,
})
);
assert_eq!(snapshots[1].limit_id.as_deref(), Some("codex_other"));
assert_eq!(snapshots[1].limit_name.as_deref(), Some("codex_other"));
@@ -715,6 +762,7 @@ mod tests {
Some(70.0)
);
assert_eq!(snapshots[1].credits, None);
assert_eq!(snapshots[1].individual_limit, None);
assert_eq!(snapshots[1].plan_type, Some(AccountPlanType::Pro));
assert_eq!(snapshots[1].rate_limit_reached_type, None);
}
@@ -730,6 +778,7 @@ mod tests {
rate_limit: None,
}])),
credits: None,
spend_control: None,
rate_limit_reached_type: None,
};
@@ -755,6 +804,7 @@ mod tests {
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: Some(AccountPlanType::Pro),
rate_limit_reached_type: None,
},
@@ -768,6 +818,7 @@ mod tests {
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: Some(AccountPlanType::Pro),
rate_limit_reached_type: None,
},
@@ -812,6 +863,7 @@ mod tests {
plan_type: crate::types::PlanType::Plus,
rate_limit: None,
credits: None,
spend_control: None,
additional_rate_limits: None,
rate_limit_reached_type: Some(Some(BackendRateLimitReachedType { kind })),
};
@@ -827,6 +879,7 @@ mod tests {
plan_type: crate::types::PlanType::Plus,
rate_limit: None,
credits: None,
spend_control: None,
additional_rate_limits: None,
rate_limit_reached_type: None,
};
+1
View File
@@ -10,6 +10,7 @@ pub use codex_backend_openapi_models::models::RateLimitReachedKind;
pub use codex_backend_openapi_models::models::RateLimitStatusDetails;
pub use codex_backend_openapi_models::models::RateLimitStatusPayload;
pub use codex_backend_openapi_models::models::RateLimitWindowSnapshot;
pub use codex_backend_openapi_models::models::SpendControlLimitDetails;
pub use codex_backend_openapi_models::models::TaskListItem;
use serde::Deserialize;