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
Unverified
parent c955f73078
commit c8e5db16c9
40 changed files with 822 additions and 11 deletions
@@ -29,6 +29,7 @@
"type": "object"
},
"AccountRateLimitsUpdatedNotification": {
"description": "Sparse rolling rate-limit update.\n\nClients should merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and does not clear a previously observed value.",
"properties": {
"rateLimits": {
"$ref": "#/definitions/RateLimitSnapshot"
@@ -2677,6 +2678,16 @@
}
]
},
"individualLimit": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"limitId": {
"type": [
"string",
@@ -3135,6 +3146,31 @@
"description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.",
"type": "object"
},
"SpendControlLimitSnapshot": {
"properties": {
"limit": {
"type": "string"
},
"remainingPercent": {
"format": "int32",
"type": "integer"
},
"resetsAt": {
"format": "int64",
"type": "integer"
},
"used": {
"type": "string"
}
},
"required": [
"limit",
"remainingPercent",
"resetsAt",
"used"
],
"type": "object"
},
"SubAgentSource": {
"oneOf": [
{
@@ -5720,6 +5720,7 @@
},
"AccountRateLimitsUpdatedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Sparse rolling rate-limit update.\n\nClients should merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and does not clear a previously observed value.",
"properties": {
"rateLimits": {
"$ref": "#/definitions/v2/RateLimitSnapshot"
@@ -13327,6 +13328,16 @@
}
]
},
"individualLimit": {
"anyOf": [
{
"$ref": "#/definitions/v2/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"limitId": {
"type": [
"string",
@@ -15269,6 +15280,31 @@
],
"type": "string"
},
"SpendControlLimitSnapshot": {
"properties": {
"limit": {
"type": "string"
},
"remainingPercent": {
"format": "int32",
"type": "integer"
},
"resetsAt": {
"format": "int64",
"type": "integer"
},
"used": {
"type": "string"
}
},
"required": [
"limit",
"remainingPercent",
"resetsAt",
"used"
],
"type": "object"
},
"SubAgentSource": {
"oneOf": [
{
@@ -92,6 +92,7 @@
},
"AccountRateLimitsUpdatedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Sparse rolling rate-limit update.\n\nClients should merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and does not clear a previously observed value.",
"properties": {
"rateLimits": {
"$ref": "#/definitions/RateLimitSnapshot"
@@ -9856,6 +9857,16 @@
}
]
},
"individualLimit": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"limitId": {
"type": [
"string",
@@ -13093,6 +13104,31 @@
],
"type": "string"
},
"SpendControlLimitSnapshot": {
"properties": {
"limit": {
"type": "string"
},
"remainingPercent": {
"format": "int32",
"type": "integer"
},
"resetsAt": {
"format": "int64",
"type": "integer"
},
"used": {
"type": "string"
}
},
"required": [
"limit",
"remainingPercent",
"resetsAt",
"used"
],
"type": "object"
},
"SubAgentSource": {
"oneOf": [
{
@@ -61,6 +61,16 @@
}
]
},
"individualLimit": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"limitId": {
"type": [
"string",
@@ -141,8 +151,34 @@
"usedPercent"
],
"type": "object"
},
"SpendControlLimitSnapshot": {
"properties": {
"limit": {
"type": "string"
},
"remainingPercent": {
"format": "int32",
"type": "integer"
},
"resetsAt": {
"format": "int64",
"type": "integer"
},
"used": {
"type": "string"
}
},
"required": [
"limit",
"remainingPercent",
"resetsAt",
"used"
],
"type": "object"
}
},
"description": "Sparse rolling rate-limit update.\n\nClients should merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and does not clear a previously observed value.",
"properties": {
"rateLimits": {
"$ref": "#/definitions/RateLimitSnapshot"
@@ -61,6 +61,16 @@
}
]
},
"individualLimit": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"limitId": {
"type": [
"string",
@@ -141,6 +151,31 @@
"usedPercent"
],
"type": "object"
},
"SpendControlLimitSnapshot": {
"properties": {
"limit": {
"type": "string"
},
"remainingPercent": {
"format": "int32",
"type": "integer"
},
"resetsAt": {
"format": "int64",
"type": "integer"
},
"used": {
"type": "string"
}
},
"required": [
"limit",
"remainingPercent",
"resetsAt",
"used"
],
"type": "object"
}
},
"properties": {
@@ -3,4 +3,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { RateLimitSnapshot } from "./RateLimitSnapshot";
/**
* Sparse rolling rate-limit update.
*
* Clients should merge available values into the most recent `account/rateLimits/read` response
* or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and
* does not clear a previously observed value.
*/
export type AccountRateLimitsUpdatedNotification = { rateLimits: RateLimitSnapshot, };
@@ -5,5 +5,6 @@ import type { PlanType } from "../PlanType";
import type { CreditsSnapshot } from "./CreditsSnapshot";
import type { RateLimitReachedType } from "./RateLimitReachedType";
import type { RateLimitWindow } from "./RateLimitWindow";
import type { SpendControlLimitSnapshot } from "./SpendControlLimitSnapshot";
export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, planType: PlanType | null, rateLimitReachedType: RateLimitReachedType | null, };
export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, individualLimit: SpendControlLimitSnapshot | null, planType: PlanType | null, rateLimitReachedType: RateLimitReachedType | null, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type SpendControlLimitSnapshot = { limit: string, used: string, remainingPercent: number, resetsAt: number, };
@@ -350,6 +350,7 @@ export type { SkillsListEntry } from "./SkillsListEntry";
export type { SkillsListParams } from "./SkillsListParams";
export type { SkillsListResponse } from "./SkillsListResponse";
export type { SortDirection } from "./SortDirection";
export type { SpendControlLimitSnapshot } from "./SpendControlLimitSnapshot";
export type { SubagentMigration } from "./SubagentMigration";
export type { TerminalInteractionNotification } from "./TerminalInteractionNotification";
export type { TextElement } from "./TextElement";
@@ -6,6 +6,7 @@ use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot;
use codex_protocol::protocol::RateLimitReachedType as CoreRateLimitReachedType;
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow;
use codex_protocol::protocol::SpendControlLimitSnapshot as CoreSpendControlLimitSnapshot;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
@@ -247,6 +248,11 @@ pub struct AccountUpdatedNotification {
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
/// Sparse rolling rate-limit update.
///
/// Clients should merge available values into the most recent `account/rateLimits/read` response
/// or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and
/// does not clear a previously observed value.
pub struct AccountRateLimitsUpdatedNotification {
pub rate_limits: RateLimitSnapshot,
}
@@ -260,6 +266,7 @@ pub struct RateLimitSnapshot {
pub primary: Option<RateLimitWindow>,
pub secondary: Option<RateLimitWindow>,
pub credits: Option<CreditsSnapshot>,
pub individual_limit: Option<SpendControlLimitSnapshot>,
pub plan_type: Option<PlanType>,
pub rate_limit_reached_type: Option<RateLimitReachedType>,
}
@@ -272,6 +279,7 @@ impl From<CoreRateLimitSnapshot> for RateLimitSnapshot {
primary: value.primary.map(RateLimitWindow::from),
secondary: value.secondary.map(RateLimitWindow::from),
credits: value.credits.map(CreditsSnapshot::from),
individual_limit: value.individual_limit.map(SpendControlLimitSnapshot::from),
plan_type: value.plan_type,
rate_limit_reached_type: value
.rate_limit_reached_type
@@ -371,6 +379,28 @@ impl From<CoreCreditsSnapshot> for CreditsSnapshot {
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct SpendControlLimitSnapshot {
pub limit: String,
pub used: String,
pub remaining_percent: i32,
#[ts(type = "number")]
pub resets_at: i64,
}
impl From<CoreSpendControlLimitSnapshot> for SpendControlLimitSnapshot {
fn from(value: CoreSpendControlLimitSnapshot) -> Self {
Self {
limit: value.limit,
used: value.used,
remaining_percent: value.remaining_percent,
resets_at: value.resets_at,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+3 -2
View File
@@ -1760,8 +1760,8 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
- `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`.
- `account/logout` — sign out; triggers `account/updated`.
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available.
- `account/rateLimits/read` — fetch ChatGPT rate limits; updates arrive via `account/rateLimits/updated` (notify).
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change.
- `account/rateLimits/read` — fetch ChatGPT rate limits and an optional effective monthly credit limit; updates arrive via `account/rateLimits/updated` (notify).
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot.
- `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit.
- `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`.
- `mcpServer/startupStatus/updated` (notify) — emitted when a configured MCP server's startup status changes for a loaded thread; payload includes `{ name, status, error }` where `status` is `starting`, `ready`, `failed`, or `cancelled`.
@@ -1865,6 +1865,7 @@ Field notes:
- `windowDurationMins` is the quota window length.
- `resetsAt` is a Unix timestamp (seconds) for the next reset.
- `rateLimitReachedType` identifies the backend-classified limit state when one has been reached.
- `individualLimit` describes the effective monthly credit limit when available. In an `account/rateLimits/read` response, `null` means no monthly limit is available. In a sparse `account/rateLimits/updated` notification, nullable account metadata may be unavailable and does not clear a previously observed value.
### 8) Notify a workspace owner about a limit
@@ -3546,6 +3546,7 @@ mod tests {
unlimited: false,
balance: Some("5".to_string()),
}),
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -798,6 +798,7 @@ mod tests {
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: Some(PlanType::Plus),
rate_limit_reached_type: None,
},
@@ -818,6 +819,7 @@ mod tests {
},
"secondary": null,
"credits": null,
"individualLimit": null,
"planType": "plus",
"rateLimitReachedType": null
}
@@ -15,6 +15,7 @@ use codex_app_server_protocol::RateLimitWindow;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SendAddCreditsNudgeEmailParams;
use codex_app_server_protocol::SendAddCreditsNudgeEmailResponse;
use codex_app_server_protocol::SpendControlLimitSnapshot;
use codex_config::types::AuthCredentialsStoreMode;
use codex_protocol::account::PlanType as AccountPlanType;
use pretty_assertions::assert_eq;
@@ -128,6 +129,19 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
"rate_limit_reached_type": {
"type": "workspace_member_usage_limit_reached",
},
"spend_control": {
"reached": false,
"individual_limit": {
"source": "workspace_spend_controls",
"limit": "25000",
"used": "8000",
"remaining": "17000",
"used_percent": 32,
"remaining_percent": 68,
"reset_after_seconds": 43200,
"reset_at": secondary_reset_timestamp,
}
},
"additional_rate_limits": [
{
"limit_name": "codex_other",
@@ -183,6 +197,12 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
resets_at: Some(secondary_reset_timestamp),
}),
credits: None,
individual_limit: Some(SpendControlLimitSnapshot {
limit: "25000".to_string(),
used: "8000".to_string(),
remaining_percent: 68,
resets_at: secondary_reset_timestamp,
}),
plan_type: Some(AccountPlanType::Pro),
rate_limit_reached_type: Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached),
},
@@ -204,6 +224,12 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
resets_at: Some(secondary_reset_timestamp),
}),
credits: None,
individual_limit: Some(SpendControlLimitSnapshot {
limit: "25000".to_string(),
used: "8000".to_string(),
remaining_percent: 68,
resets_at: secondary_reset_timestamp,
}),
plan_type: Some(AccountPlanType::Pro),
rate_limit_reached_type: Some(
RateLimitReachedType::WorkspaceMemberUsageLimitReached,
@@ -222,6 +248,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: Some(AccountPlanType::Pro),
rate_limit_reached_type: None,
},
+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;
+2
View File
@@ -93,6 +93,7 @@ pub fn parse_rate_limit_for_limit(
primary,
secondary,
credits,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
})
@@ -157,6 +158,7 @@ pub fn parse_rate_limit_event(payload: &str) -> Option<RateLimitSnapshot> {
primary,
secondary,
credits,
individual_limit: None,
plan_type: event.plan_type,
rate_limit_reached_type: None,
})
@@ -56,3 +56,9 @@ pub use self::rate_limit_window_snapshot::RateLimitWindowSnapshot;
pub(crate) mod credit_status_details;
pub use self::credit_status_details::CreditStatusDetails;
pub(crate) mod spend_control_limit_details;
pub use self::spend_control_limit_details::SpendControlLimitDetails;
pub(crate) mod spend_control_status_details;
pub use self::spend_control_status_details::SpendControlStatusDetails;
@@ -30,6 +30,13 @@ pub struct RateLimitStatusPayload {
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,
@@ -52,6 +59,7 @@ impl RateLimitStatusPayload {
plan_type,
rate_limit: None,
credits: None,
spend_control: None,
additional_rate_limits: None,
rate_limit_reached_type: None,
}
@@ -0,0 +1,60 @@
/*
* codex-backend
*
* codex-backend
*
* The version of the OpenAPI document: 0.0.1
*
* Generated by: https://openapi-generator.tech
*/
use serde::Deserialize;
use serde::Serialize;
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct SpendControlLimitDetails {
#[serde(
rename = "source",
default,
with = "::serde_with::rust::double_option",
skip_serializing_if = "Option::is_none"
)]
pub source: Option<Option<String>>,
#[serde(rename = "limit")]
pub limit: String,
#[serde(rename = "used")]
pub used: String,
#[serde(rename = "remaining")]
pub remaining: String,
#[serde(rename = "used_percent")]
pub used_percent: i32,
#[serde(rename = "remaining_percent")]
pub remaining_percent: i32,
#[serde(rename = "reset_after_seconds")]
pub reset_after_seconds: i32,
#[serde(rename = "reset_at")]
pub reset_at: i32,
}
impl SpendControlLimitDetails {
pub fn new(
limit: String,
used: String,
remaining: String,
used_percent: i32,
remaining_percent: i32,
reset_after_seconds: i32,
reset_at: i32,
) -> SpendControlLimitDetails {
SpendControlLimitDetails {
source: None,
limit,
used,
remaining,
used_percent,
remaining_percent,
reset_after_seconds,
reset_at,
}
}
}
@@ -0,0 +1,35 @@
/*
* 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 SpendControlStatusDetails {
#[serde(rename = "reached")]
pub reached: bool,
#[serde(
rename = "individual_limit",
default,
with = "::serde_with::rust::double_option",
skip_serializing_if = "Option::is_none"
)]
pub individual_limit: Option<Option<Box<models::SpendControlLimitDetails>>>,
}
impl SpendControlStatusDetails {
pub fn new(reached: bool) -> SpendControlStatusDetails {
SpendControlStatusDetails {
reached,
individual_limit: None,
}
}
}
+6
View File
@@ -3118,6 +3118,7 @@ async fn set_rate_limits_retains_previous_credits() {
unlimited: false,
balance: Some("10.00".to_string()),
}),
individual_limit: None,
plan_type: Some(codex_protocol::account::PlanType::Plus),
rate_limit_reached_type: None,
};
@@ -3137,6 +3138,7 @@ async fn set_rate_limits_retains_previous_credits() {
resets_at: Some(1_900),
}),
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -3150,6 +3152,7 @@ async fn set_rate_limits_retains_previous_credits() {
primary: update.primary.clone(),
secondary: update.secondary,
credits: initial.credits,
individual_limit: initial.individual_limit,
plan_type: initial.plan_type,
rate_limit_reached_type: None,
})
@@ -3227,6 +3230,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
unlimited: false,
balance: Some("15.00".to_string()),
}),
individual_limit: None,
plan_type: Some(codex_protocol::account::PlanType::Plus),
rate_limit_reached_type: None,
};
@@ -3242,6 +3246,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: Some(codex_protocol::account::PlanType::Pro),
rate_limit_reached_type: None,
};
@@ -3255,6 +3260,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
primary: update.primary,
secondary: update.secondary,
credits: initial.credits,
individual_limit: initial.individual_limit,
plan_type: update.plan_type,
rate_limit_reached_type: None,
})
+3
View File
@@ -258,6 +258,9 @@ fn merge_rate_limit_fields(
if snapshot.credits.is_none() {
snapshot.credits = previous.and_then(|prior| prior.credits.clone());
}
if snapshot.individual_limit.is_none() {
snapshot.individual_limit = previous.and_then(|prior| prior.individual_limit.clone());
}
if snapshot.plan_type.is_none() {
snapshot.plan_type = previous.and_then(|prior| prior.plan_type);
}
+18 -1
View File
@@ -3,6 +3,7 @@ use crate::session::tests::make_session_configuration_for_tests;
use crate::state::AutoCompactWindowSnapshot;
use codex_protocol::protocol::CreditsSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::SpendControlLimitSnapshot;
use pretty_assertions::assert_eq;
#[tokio::test]
@@ -49,6 +50,7 @@ async fn set_rate_limits_defaults_limit_id_to_codex_when_missing() {
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
});
@@ -95,6 +97,7 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
});
@@ -108,6 +111,7 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
});
@@ -122,7 +126,7 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc
}
#[tokio::test]
async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other() {
async fn set_rate_limits_carries_account_metadata_from_codex_to_codex_other() {
let session_configuration = make_session_configuration_for_tests().await;
let mut state = SessionState::new(session_configuration);
@@ -140,6 +144,12 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other
unlimited: false,
balance: Some("50".to_string()),
}),
individual_limit: Some(SpendControlLimitSnapshot {
limit: "25000".to_string(),
used: "8000".to_string(),
remaining_percent: 68,
resets_at: 300,
}),
plan_type: Some(codex_protocol::account::PlanType::Plus),
rate_limit_reached_type: None,
});
@@ -154,6 +164,7 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
});
@@ -174,6 +185,12 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other
unlimited: false,
balance: Some("50".to_string()),
}),
individual_limit: Some(SpendControlLimitSnapshot {
limit: "25000".to_string(),
used: "8000".to_string(),
remaining_percent: 68,
resets_at: 300,
}),
plan_type: Some(codex_protocol::account::PlanType::Plus),
rate_limit_reached_type: None,
})
+2
View File
@@ -2580,6 +2580,7 @@ async fn token_count_includes_rate_limits_snapshot() {
"resets_at": 1704074400
},
"credits": null,
"individual_limit": null,
"plan_type": null,
"rate_limit_reached_type": null
}
@@ -2655,6 +2656,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> {
"resets_at": null
},
"credits": null,
"individual_limit": null,
"plan_type": null,
"rate_limit_reached_type": null
});
@@ -1350,6 +1350,7 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() {
"resets_at": null
},
"credits": null,
"individual_limit": null,
"plan_type": null,
"rate_limit_reached_type": null
}
@@ -11,6 +11,7 @@ fn snapshot(
primary: primary_used_percent.map(window),
secondary: secondary_used_percent.map(window),
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
}
+1
View File
@@ -35,6 +35,7 @@ fn rate_limit_snapshot() -> RateLimitSnapshot {
resets_at: Some(secondary_reset_at),
}),
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
}
+9
View File
@@ -2009,6 +2009,7 @@ pub struct RateLimitSnapshot {
pub primary: Option<RateLimitWindow>,
pub secondary: Option<RateLimitWindow>,
pub credits: Option<CreditsSnapshot>,
pub individual_limit: Option<SpendControlLimitSnapshot>,
pub plan_type: Option<crate::account::PlanType>,
pub rate_limit_reached_type: Option<RateLimitReachedType>,
}
@@ -2058,6 +2059,14 @@ pub struct CreditsSnapshot {
pub balance: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
pub struct SpendControlLimitSnapshot {
pub limit: String,
pub used: String,
pub remaining_percent: i32,
pub resets_at: i64,
}
// Includes prompts, tools and space to call compact.
const BASELINE_TOKENS: i64 = 12000;
+1 -1
View File
@@ -77,7 +77,7 @@ impl App {
}
ServerNotification::AccountRateLimitsUpdated(notification) => {
self.chat_widget
.on_rate_limit_snapshot(Some(notification.rate_limits.clone()));
.on_rolling_rate_limit_snapshot(notification.rate_limits.clone());
return;
}
ServerNotification::AccountUpdated(notification) => {
+1
View File
@@ -1804,6 +1804,7 @@ mod tests {
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
}
+33 -2
View File
@@ -150,8 +150,27 @@ pub(super) fn is_app_server_cyber_policy_error(info: &AppServerCodexErrorInfo) -
matches!(info, AppServerCodexErrorInfo::CyberPolicy)
}
#[derive(Clone, Copy)]
enum RateLimitSnapshotSource {
AccountUsage,
RollingUpdate,
}
impl ChatWidget {
pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option<RateLimitSnapshot>) {
self.on_rate_limit_snapshot_from(snapshot, RateLimitSnapshotSource::AccountUsage);
}
pub(crate) fn on_rolling_rate_limit_snapshot(&mut self, snapshot: RateLimitSnapshot) {
// Rolling app-server notifications are sparse. Preserve metadata learned from the full read.
self.on_rate_limit_snapshot_from(Some(snapshot), RateLimitSnapshotSource::RollingUpdate);
}
fn on_rate_limit_snapshot_from(
&mut self,
snapshot: Option<RateLimitSnapshot>,
source: RateLimitSnapshotSource,
) {
if let Some(mut snapshot) = snapshot {
let limit_id = snapshot
.limit_id
@@ -172,7 +191,16 @@ impl ChatWidget {
balance: credits.balance.clone(),
});
}
let preserved_individual_limit =
if matches!(source, RateLimitSnapshotSource::RollingUpdate)
&& snapshot.individual_limit.is_none()
{
self.rate_limit_snapshots_by_limit_id
.get(&limit_id)
.and_then(|display| display.individual_limit.clone())
} else {
None
};
self.plan_type = snapshot.plan_type.or(self.plan_type);
let is_codex_limit = limit_id.eq_ignore_ascii_case("codex");
@@ -234,8 +262,11 @@ impl ChatWidget {
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending;
}
let display =
let mut display =
rate_limit_snapshot_display_for_limit(&snapshot, limit_label, Local::now());
if display.individual_limit.is_none() {
display.individual_limit = preserved_individual_limit;
}
self.rate_limit_snapshots_by_limit_id
.insert(limit_id, display);
@@ -112,6 +112,7 @@ pub(super) fn snapshot(percent: f64) -> RateLimitSnapshot {
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
}
@@ -2,6 +2,7 @@ use super::*;
use crate::bottom_pane::goal_status_indicator_line;
use crate::chatwidget::rate_limits::NUDGE_MODEL_SLUG;
use crate::chatwidget::rate_limits::get_limits_duration;
use codex_app_server_protocol::SpendControlLimitSnapshot;
use pretty_assertions::assert_eq;
use ratatui::backend::TestBackend;
use serial_test::serial;
@@ -580,6 +581,7 @@ async fn status_line_uses_secondary_fallback_for_unsupported_window() {
resets_at: None,
}),
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
}));
@@ -608,6 +610,7 @@ async fn status_line_legacy_limit_items_prefer_matching_windows() {
resets_at: None,
}),
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
}));
@@ -640,6 +643,7 @@ async fn status_line_shows_secondary_non_weekly_when_primary_is_weekly() {
resets_at: None,
}),
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
}));
@@ -668,6 +672,7 @@ async fn status_line_five_hour_item_omits_weekly_only_limit() {
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
}));
@@ -696,6 +701,7 @@ async fn status_line_single_monthly_primary_omits_weekly_limit_item() {
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
}));
@@ -724,6 +730,7 @@ async fn status_line_secondary_only_non_weekly_limit_omits_primary_limit_item()
resets_at: None,
}),
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
}));
@@ -752,6 +759,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
unlimited: false,
balance: Some("17.5".to_string()),
}),
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
}));
@@ -772,6 +780,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
}));
@@ -793,6 +802,40 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
);
}
#[tokio::test]
async fn rolling_rate_limit_snapshot_preserves_prior_individual_limit() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let mut usage_limits = snapshot(/*percent*/ 10.0);
usage_limits.individual_limit = Some(SpendControlLimitSnapshot {
limit: "25000".to_string(),
used: "8000".to_string(),
remaining_percent: 68,
resets_at: 1_800_000_000,
});
chat.on_rate_limit_snapshot(Some(usage_limits));
chat.on_rolling_rate_limit_snapshot(snapshot(/*percent*/ 20.0));
let display = chat
.rate_limit_snapshots_by_limit_id
.get("codex")
.expect("rate limits should be cached");
let individual_limit = display
.individual_limit
.as_ref()
.expect("rolling updates should preserve monthly limits");
assert_eq!(individual_limit.used, "8,000");
assert_eq!(individual_limit.limit, "25,000");
assert_eq!(individual_limit.percent_remaining, 68.0);
chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 30.0)));
let display = chat
.rate_limit_snapshots_by_limit_id
.get("codex")
.expect("rate limits should be cached");
assert!(display.individual_limit.is_none());
}
#[tokio::test]
async fn rate_limit_snapshot_updates_and_retains_plan_type() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -811,6 +854,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
resets_at: None,
}),
credits: None,
individual_limit: None,
plan_type: Some(PlanType::Plus),
rate_limit_reached_type: None,
}));
@@ -830,6 +874,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
resets_at: Some(234),
}),
credits: None,
individual_limit: None,
plan_type: Some(PlanType::Pro),
rate_limit_reached_type: None,
}));
@@ -849,6 +894,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
resets_at: Some(567),
}),
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
}));
@@ -873,6 +919,7 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() {
unlimited: false,
balance: Some("5.00".to_string()),
}),
individual_limit: None,
plan_type: Some(PlanType::Pro),
rate_limit_reached_type: None,
}));
@@ -887,6 +934,7 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() {
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: Some(PlanType::Pro),
rate_limit_reached_type: None,
}));
@@ -940,6 +988,7 @@ async fn rate_limit_switch_prompt_skips_non_codex_limit() {
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
}));
@@ -76,6 +76,7 @@ fn cache_rate_limit_snapshot(chat: &mut ChatWidget) {
resets_at: None,
}),
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
}));
@@ -233,6 +234,7 @@ async fn status_surface_preview_omits_unavailable_rate_limit_items() {
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
}));
+13
View File
@@ -471,6 +471,7 @@ impl StatusHistoryCell {
StatusRateLimitValue::Window {
percent_used,
resets_at,
details,
} => {
let percent_remaining = (100.0 - percent_used).clamp(0.0, 100.0);
let summary = format_status_limit_summary(percent_remaining);
@@ -522,6 +523,18 @@ impl StatusHistoryCell {
} else {
lines.push(base_line);
}
if let Some(details) = details {
let detail_width = formatter.value_width(available_inner_width).max(1);
let wrap_options = textwrap::Options::new(detail_width).break_words(false);
lines.extend(
textwrap::wrap(details.as_str(), wrap_options)
.into_iter()
.map(|wrapped| {
formatter
.continuation(vec![Span::from(wrapped.into_owned()).dim()])
}),
);
}
}
StatusRateLimitValue::Text(text) => {
let label = row.label.clone();
+71
View File
@@ -17,6 +17,8 @@ use chrono::Utc;
use codex_app_server_protocol::CreditsSnapshot as CoreCreditsSnapshot;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::RateLimitWindow;
use codex_app_server_protocol::SpendControlLimitSnapshot as CoreSpendControlLimitSnapshot;
use codex_protocol::num_format::format_with_separators;
const STATUS_LIMIT_BAR_SEGMENTS: usize = 20;
const STATUS_LIMIT_BAR_FILLED: &str = "";
@@ -39,6 +41,8 @@ pub(crate) enum StatusRateLimitValue {
percent_used: f64,
/// Localized reset string, or `None` when unknown.
resets_at: Option<String>,
/// Optional detail line rendered beneath the progress bar.
details: Option<String>,
},
/// Plain text value used for non-window rows.
Text(String),
@@ -99,6 +103,8 @@ pub(crate) struct RateLimitSnapshotDisplay {
pub secondary: Option<RateLimitWindowDisplay>,
/// Optional credits metadata when available.
pub credits: Option<CreditsSnapshotDisplay>,
/// Optional effective monthly credit limit from workspace spend controls.
pub individual_limit: Option<SpendControlLimitSnapshotDisplay>,
}
/// Display-ready credits state extracted from protocol snapshots.
@@ -112,6 +118,17 @@ pub(crate) struct CreditsSnapshotDisplay {
pub balance: Option<String>,
}
/// Display-ready effective monthly limit extracted from spend controls.
#[derive(Debug, Clone)]
pub(crate) struct SpendControlLimitSnapshotDisplay {
/// Local timestamp representing when this monthly usage value was captured.
pub captured_at: DateTime<Local>,
pub percent_remaining: f64,
pub used: String,
pub limit: String,
pub resets_at: Option<String>,
}
/// Converts a protocol snapshot into UI-friendly display data.
///
/// Pass the timestamp from the same observation point as `snapshot`; supplying a significantly
@@ -141,6 +158,10 @@ pub(crate) fn rate_limit_snapshot_display_for_limit(
.as_ref()
.map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
credits: snapshot.credits.as_ref().map(CreditsSnapshotDisplay::from),
individual_limit: snapshot
.individual_limit
.as_ref()
.and_then(|limit| SpendControlLimitSnapshotDisplay::from_limit(limit, captured_at)),
}
}
@@ -154,6 +175,22 @@ impl From<&CoreCreditsSnapshot> for CreditsSnapshotDisplay {
}
}
impl SpendControlLimitSnapshotDisplay {
fn from_limit(
value: &CoreSpendControlLimitSnapshot,
captured_at: DateTime<Local>,
) -> Option<Self> {
Some(Self {
captured_at,
percent_remaining: f64::from(value.remaining_percent.clamp(0, 100)),
used: format_credit_amount(&value.used)?,
limit: format_credit_amount(&value.limit)?,
resets_at: DateTime::<Utc>::from_timestamp(value.resets_at, 0)
.map(|dt| format_reset_timestamp(dt.with_timezone(&Local), captured_at)),
})
}
}
/// Builds display rows from a snapshot and marks stale data by capture age.
///
/// Callers should pass `Local::now()` for `now` at render time; using a cached timestamp can make
@@ -182,6 +219,14 @@ pub(crate) fn compose_rate_limit_data_many(
for snapshot in snapshots {
stale |= now.signed_duration_since(snapshot.captured_at)
> ChronoDuration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES);
stale |= snapshot
.individual_limit
.as_ref()
.map(|limit| {
now.signed_duration_since(limit.captured_at)
> ChronoDuration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES)
})
.unwrap_or(false);
let limit_bucket_label = snapshot.limit_name.clone();
let show_limit_prefix = !limit_bucket_label.eq_ignore_ascii_case("codex");
@@ -230,6 +275,7 @@ pub(crate) fn compose_rate_limit_data_many(
value: StatusRateLimitValue::Window {
percent_used: primary.used_percent,
resets_at: primary.resets_at.clone(),
details: None,
},
});
}
@@ -256,6 +302,7 @@ pub(crate) fn compose_rate_limit_data_many(
value: StatusRateLimitValue::Window {
percent_used: secondary.used_percent,
resets_at: secondary.resets_at.clone(),
details: None,
},
});
}
@@ -265,6 +312,19 @@ pub(crate) fn compose_rate_limit_data_many(
{
rows.push(row);
}
if let Some(individual_limit) = snapshot.individual_limit.as_ref() {
rows.push(StatusRateLimitRow {
label: "Monthly credit limit".to_string(),
value: StatusRateLimitValue::Window {
percent_used: 100.0 - individual_limit.percent_remaining,
resets_at: individual_limit.resets_at.clone(),
details: Some(format!(
"{} of {} credits used",
individual_limit.used, individual_limit.limit
)),
},
});
}
}
if rows.is_empty() {
@@ -341,6 +401,14 @@ fn format_credit_balance(raw: &str) -> Option<String> {
None
}
fn format_credit_amount(raw: &str) -> Option<String> {
let value = raw.trim().parse::<f64>().ok()?;
if !value.is_finite() || value < 0.0 {
return None;
}
Some(format_with_separators(value.round() as i64))
}
#[cfg(test)]
mod tests {
use super::CreditsSnapshotDisplay;
@@ -372,6 +440,7 @@ mod tests {
unlimited: false,
balance: Some("25".to_string()),
}),
individual_limit: None,
};
let other = RateLimitSnapshotDisplay {
limit_name: "codex-other".to_string(),
@@ -383,6 +452,7 @@ mod tests {
unlimited: false,
balance: Some("99".to_string()),
}),
individual_limit: None,
};
let rows = match compose_rate_limit_data_many(&[codex, other], now) {
@@ -420,6 +490,7 @@ mod tests {
window_minutes: Some(2 * 60),
}),
credits: None,
individual_limit: None,
};
let rows = match compose_rate_limit_data_many(&[other], now) {
@@ -0,0 +1,22 @@
---
source: tui/src/status/tests.rs
expression: sanitized
---
/status
╭───────────────────────────────────────────────────────────────────────────────────╮
│ >_ OpenAI Codex (v0.0.0) │
│ │
│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │
│ information on rate limits and credits │
│ │
│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │
│ Directory: [[workspace]] │
│ Permissions: Custom (workspace with network access, Ask for approval) │
│ Agents.md: <none> │
│ │
│ Token usage: 1.2K total (800 input + 400 output) │
│ Context window: 100% left (1.2K used / 272K) │
│ Monthly credit limit: [██████████████░░░░░░] 68% left (resets 07:08 on 7 May) │
│ 8,000 of 25,000 credits used │
╰───────────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,27 @@
---
source: tui/src/status/tests.rs
expression: sanitized
---
/status
╭────────────────────────────────────────────╮
│ >_ OpenAI Codex (v0.0.0) │
│ │
│ Visit │
│ https://chatgpt.com/codex/settings/usage │
│ for up-to-date │
│ information on rate limits and credits │
│ │
│ Model: gpt-5.1-codex-max │
│ Directory: [[workspace]] │
│ Permissions: Custom (workspace │
│ Agents.md: <none> │
│ │
│ Token usage: 1.2K total (800 │
│ Context window: 100% left (1.2K u │
│ Monthly credit limit: 68% left │
│ (resets 07:08 on │
│ 7 May) │
│ 8,000 of 25,000 │
│ credits used │
╰────────────────────────────────────────────╯
+139 -4
View File
@@ -2,6 +2,11 @@ use super::new_status_output;
use super::new_status_output_with_rate_limits;
use super::new_status_output_with_rate_limits_handle;
use super::rate_limit_snapshot_display;
use super::rate_limits::RateLimitSnapshotDisplay;
use super::rate_limits::RateLimitWindowDisplay;
use super::rate_limits::SpendControlLimitSnapshotDisplay;
use super::rate_limits::StatusRateLimitData;
use super::rate_limits::compose_rate_limit_data_many;
use crate::history_cell::HistoryCell;
use crate::legacy_core::config::Config;
use crate::legacy_core::config::ConfigBuilder;
@@ -13,12 +18,14 @@ use crate::test_support::test_path_buf;
use crate::token_usage::TokenUsage;
use crate::token_usage::TokenUsageInfo;
use chrono::Duration as ChronoDuration;
use chrono::Local;
use chrono::TimeZone;
use chrono::Utc;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::CreditsSnapshot;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::RateLimitWindow;
use codex_app_server_protocol::SpendControlLimitSnapshot;
use codex_config::LoaderOverrides;
use codex_model_provider_info::ModelProviderAwsAuthInfo;
use codex_model_provider_info::ModelProviderInfo;
@@ -40,6 +47,35 @@ use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use ratatui::prelude::*;
use tempfile::TempDir;
use unicode_width::UnicodeWidthStr;
#[test]
fn stale_monthly_limit_marks_fresh_rolling_snapshot_stale() {
let now = Local::now();
let snapshot = RateLimitSnapshotDisplay {
limit_name: "codex".to_string(),
captured_at: now,
primary: Some(RateLimitWindowDisplay {
used_percent: 20.0,
resets_at: Some("soon".to_string()),
window_minutes: Some(300),
}),
secondary: None,
credits: None,
individual_limit: Some(SpendControlLimitSnapshotDisplay {
captured_at: now - ChronoDuration::minutes(20),
percent_remaining: 68.0,
used: "8,000".to_string(),
limit: "25,000".to_string(),
resets_at: Some("later".to_string()),
}),
};
assert!(matches!(
compose_rate_limit_data_many(&[snapshot], now),
StatusRateLimitData::Stale(_)
));
}
fn app_server_workspace_write_profile(network_enabled: bool) -> PermissionProfile {
PermissionProfile::Managed {
@@ -133,18 +169,27 @@ fn render_lines(lines: &[Line<'static>]) -> Vec<String> {
}
fn sanitize_directory(lines: Vec<String>) -> Vec<String> {
let frame_width = lines
.iter()
.find(|line| line.starts_with('╭'))
.map(|line| UnicodeWidthStr::width(line.as_str()));
lines
.into_iter()
.map(|line| {
if let (Some(dir_pos), Some(pipe_idx)) = (line.find("Directory: "), line.rfind('│')) {
if let (Some(frame_width), Some(dir_pos), Some(pipe_idx)) =
(frame_width, line.find("Directory: "), line.rfind('│'))
{
let prefix = &line[..dir_pos + "Directory: ".len()];
let suffix = &line[pipe_idx..];
let content_width = pipe_idx.saturating_sub(dir_pos + "Directory: ".len());
let replacement = "[[workspace]]";
let content_width = frame_width.saturating_sub(
UnicodeWidthStr::width(prefix) + UnicodeWidthStr::width(suffix),
);
let mut rebuilt = prefix.to_string();
rebuilt.push_str(replacement);
if content_width > replacement.len() {
rebuilt.push_str(&" ".repeat(content_width - replacement.len()));
let replacement_width = UnicodeWidthStr::width(replacement);
if content_width > replacement_width {
rebuilt.push_str(&" ".repeat(content_width - replacement_width));
}
rebuilt.push_str(suffix);
rebuilt
@@ -236,6 +281,7 @@ async fn status_snapshot_includes_reasoning_details() {
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 1_200)),
}),
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -871,6 +917,7 @@ async fn status_snapshot_includes_monthly_limit() {
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -903,6 +950,82 @@ async fn status_snapshot_includes_monthly_limit() {
assert_snapshot!(sanitized);
}
#[tokio::test]
async fn status_snapshot_includes_enterprise_monthly_credit_limit() {
let temp_home = TempDir::new().expect("temp home");
let mut config = test_config(&temp_home).await;
config.model = Some("gpt-5.1-codex-max".to_string());
config.model_provider_id = "openai".to_string();
set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs());
let account_display = test_status_account_display();
let usage = TokenUsage {
input_tokens: 800,
cached_input_tokens: 0,
output_tokens: 400,
reasoning_output_tokens: 0,
total_tokens: 1_200,
};
let captured_at = chrono::Local
.with_ymd_and_hms(2024, 5, 6, 7, 8, 9)
.single()
.expect("timestamp");
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: None,
secondary: None,
credits: None,
individual_limit: Some(SpendControlLimitSnapshot {
limit: "25000".to_string(),
used: "8000".to_string(),
remaining_percent: 68,
resets_at: reset_at_from(&captured_at, /*seconds*/ 86_400),
}),
plan_type: None,
rate_limit_reached_type: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let model_slug = crate::legacy_core::test_support::get_model_offline(config.model.as_deref());
let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
account_display.as_ref(),
Some(&token_info),
&usage,
&None,
/*thread_name*/ None,
/*forked_from*/ None,
Some(&rate_display),
None,
captured_at,
&model_slug,
/*collaboration_mode*/ None,
/*reasoning_effort_override*/ None,
);
let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 92));
if cfg!(windows) {
for line in &mut rendered_lines {
*line = line.replace('\\', "/");
}
}
let sanitized = sanitize_directory(rendered_lines).join("\n");
assert_snapshot!(sanitized);
let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 46));
if cfg!(windows) {
for line in &mut rendered_lines {
*line = line.replace('\\', "/");
}
}
let sanitized = sanitize_directory(rendered_lines).join("\n");
assert_snapshot!(
"status_snapshot_wraps_enterprise_monthly_credit_details_in_narrow_terminal",
sanitized
);
}
#[tokio::test]
async fn status_snapshot_uses_generic_limit_labels_for_unsupported_windows() {
let temp_home = TempDir::new().expect("temp home");
@@ -938,6 +1061,7 @@ async fn status_snapshot_uses_generic_limit_labels_for_unsupported_windows() {
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 172_800)),
}),
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -990,6 +1114,7 @@ async fn status_snapshot_shows_unlimited_credits() {
unlimited: true,
balance: None,
}),
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -1040,6 +1165,7 @@ async fn status_snapshot_shows_positive_credits() {
unlimited: false,
balance: Some("12.5".to_string()),
}),
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -1090,6 +1216,7 @@ async fn status_snapshot_hides_zero_credits() {
unlimited: false,
balance: Some("0".to_string()),
}),
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -1138,6 +1265,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() {
unlimited: true,
balance: None,
}),
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -1244,6 +1372,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() {
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -1414,6 +1543,7 @@ async fn status_snapshot_shows_refreshing_limits_notice() {
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 2_700)),
}),
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -1485,6 +1615,7 @@ async fn status_snapshot_includes_credits_and_limits() {
unlimited: false,
balance: Some("37.5".to_string()),
}),
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -1539,6 +1670,7 @@ async fn status_snapshot_shows_unavailable_limits_message() {
primary: None,
secondary: None,
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -1596,6 +1728,7 @@ async fn status_snapshot_treats_refreshing_empty_limits_as_unavailable() {
primary: None,
secondary: None,
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -1667,6 +1800,7 @@ async fn status_snapshot_shows_stale_limits_message() {
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 1_800)),
}),
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -1738,6 +1872,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() {
unlimited: false,
balance: Some("80".to_string()),
}),
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
};