Files
codex/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs
T
efrazer-oai c8e5db16c9 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.
2026-06-01 21:25:42 -07:00

134 lines
3.7 KiB
Rust

/*
* codex-backend
*
* codex-backend
*
* The version of the OpenAPI document: 0.0.1
*
* Generated by: https://openapi-generator.tech
*/
use crate::models;
use serde::Deserialize;
use serde::Serialize;
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct RateLimitStatusPayload {
#[serde(rename = "plan_type")]
pub plan_type: PlanType,
#[serde(
rename = "rate_limit",
default,
with = "::serde_with::rust::double_option",
skip_serializing_if = "Option::is_none"
)]
pub rate_limit: Option<Option<Box<models::RateLimitStatusDetails>>>,
#[serde(
rename = "credits",
default,
with = "::serde_with::rust::double_option",
skip_serializing_if = "Option::is_none"
)]
pub credits: Option<Option<Box<models::CreditStatusDetails>>>,
#[serde(
rename = "spend_control",
default,
with = "::serde_with::rust::double_option",
skip_serializing_if = "Option::is_none"
)]
pub spend_control: Option<Option<Box<models::SpendControlStatusDetails>>>,
#[serde(
rename = "additional_rate_limits",
default,
with = "::serde_with::rust::double_option",
skip_serializing_if = "Option::is_none"
)]
pub additional_rate_limits: Option<Option<Vec<models::AdditionalRateLimitDetails>>>,
#[serde(
rename = "rate_limit_reached_type",
default,
with = "::serde_with::rust::double_option",
skip_serializing_if = "Option::is_none"
)]
pub rate_limit_reached_type: Option<Option<RateLimitReachedType>>,
}
impl RateLimitStatusPayload {
pub fn new(plan_type: PlanType) -> RateLimitStatusPayload {
RateLimitStatusPayload {
plan_type,
rate_limit: None,
credits: None,
spend_control: None,
additional_rate_limits: None,
rate_limit_reached_type: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RateLimitReachedType {
#[serde(rename = "type")]
pub kind: RateLimitReachedKind,
}
#[derive(
Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, Default,
)]
pub enum RateLimitReachedKind {
#[serde(rename = "rate_limit_reached")]
RateLimitReached,
#[serde(rename = "workspace_owner_credits_depleted")]
WorkspaceOwnerCreditsDepleted,
#[serde(rename = "workspace_member_credits_depleted")]
WorkspaceMemberCreditsDepleted,
#[serde(rename = "workspace_owner_usage_limit_reached")]
WorkspaceOwnerUsageLimitReached,
#[serde(rename = "workspace_member_usage_limit_reached")]
WorkspaceMemberUsageLimitReached,
#[serde(rename = "unknown", other)]
#[default]
Unknown,
}
#[derive(
Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, Default,
)]
pub enum PlanType {
#[serde(rename = "guest")]
#[default]
Guest,
#[serde(rename = "free")]
Free,
#[serde(rename = "go")]
Go,
#[serde(rename = "plus")]
Plus,
#[serde(rename = "pro")]
Pro,
#[serde(rename = "prolite")]
ProLite,
#[serde(rename = "free_workspace")]
FreeWorkspace,
#[serde(rename = "team")]
Team,
#[serde(rename = "self_serve_business_usage_based")]
SelfServeBusinessUsageBased,
#[serde(rename = "business")]
Business,
#[serde(rename = "enterprise_cbp_usage_based")]
EnterpriseCbpUsageBased,
#[serde(rename = "education")]
Education,
#[serde(rename = "quorum")]
Quorum,
#[serde(rename = "k12")]
K12,
#[serde(rename = "enterprise")]
Enterprise,
#[serde(rename = "edu")]
Edu,
#[serde(rename = "unknown", other)]
Unknown,
}