From c8e5db16c9994affe8e1959fb76d58c3775d4075 Mon Sep 17 00:00:00 2001 From: efrazer-oai Date: Mon, 1 Jun 2026 21:25:42 -0700 Subject: [PATCH] feat: show enterprise monthly credit limits in status (#24812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- .../schema/json/ServerNotification.json | 36 +++++ .../codex_app_server_protocol.schemas.json | 36 +++++ .../codex_app_server_protocol.v2.schemas.json | 36 +++++ .../AccountRateLimitsUpdatedNotification.json | 36 +++++ .../json/v2/GetAccountRateLimitsResponse.json | 35 +++++ .../AccountRateLimitsUpdatedNotification.ts | 7 + .../schema/typescript/v2/RateLimitSnapshot.ts | 3 +- .../v2/SpendControlLimitSnapshot.ts | 5 + .../schema/typescript/v2/index.ts | 1 + .../src/protocol/v2/account.rs | 30 ++++ codex-rs/app-server/README.md | 5 +- .../app-server/src/bespoke_event_handling.rs | 1 + codex-rs/app-server/src/outgoing_message.rs | 2 + .../app-server/tests/suite/v2/rate_limits.rs | 27 ++++ codex-rs/backend-client/src/client.rs | 53 +++++++ codex-rs/backend-client/src/types.rs | 1 + codex-rs/codex-api/src/rate_limits.rs | 2 + .../src/models/mod.rs | 6 + .../src/models/rate_limit_status_payload.rs | 8 + .../src/models/spend_control_limit_details.rs | 60 ++++++++ .../models/spend_control_status_details.rs | 35 +++++ codex-rs/core/src/session/tests.rs | 6 + codex-rs/core/src/state/session.rs | 3 + codex-rs/core/src/state/session_tests.rs | 19 ++- codex-rs/core/tests/suite/client.rs | 2 + .../core/tests/suite/client_websockets.rs | 1 + codex-rs/memories/write/src/guard_tests.rs | 1 + codex-rs/protocol/src/error_tests.rs | 1 + codex-rs/protocol/src/protocol.rs | 9 ++ codex-rs/tui/src/app/app_server_events.rs | 2 +- codex-rs/tui/src/app_server_session.rs | 1 + codex-rs/tui/src/chatwidget/rate_limits.rs | 35 ++++- codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + .../src/chatwidget/tests/status_and_layout.rs | 49 ++++++ .../tests/status_surface_previews.rs | 2 + codex-rs/tui/src/status/card.rs | 13 ++ codex-rs/tui/src/status/rate_limits.rs | 71 +++++++++ ...ludes_enterprise_monthly_credit_limit.snap | 22 +++ ...hly_credit_details_in_narrow_terminal.snap | 27 ++++ codex-rs/tui/src/status/tests.rs | 143 +++++++++++++++++- 40 files changed, 822 insertions(+), 11 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/SpendControlLimitSnapshot.ts create mode 100644 codex-rs/codex-backend-openapi-models/src/models/spend_control_limit_details.rs create mode 100644 codex-rs/codex-backend-openapi-models/src/models/spend_control_status_details.rs create mode 100644 codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_enterprise_monthly_credit_limit.snap create mode 100644 codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_wraps_enterprise_monthly_credit_details_in_narrow_terminal.snap diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 4eb5cae38..faf5a9687 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -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": [ { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index b97c1f8fb..ee1a49069 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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": [ { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index f8261c86d..4d219f383 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json index 96fefb228..c518085df 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json index 8f7db24e7..7916d619e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AccountRateLimitsUpdatedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AccountRateLimitsUpdatedNotification.ts index 96c735a2e..a6d6a33f4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AccountRateLimitsUpdatedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AccountRateLimitsUpdatedNotification.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts index dc8417a30..c1e3953df 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SpendControlLimitSnapshot.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SpendControlLimitSnapshot.ts new file mode 100644 index 000000000..077e590af --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SpendControlLimitSnapshot.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 66b67fb1b..48e1f9580 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index 796c93d55..c2a538982 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -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, pub secondary: Option, pub credits: Option, + pub individual_limit: Option, pub plan_type: Option, pub rate_limit_reached_type: Option, } @@ -272,6 +279,7 @@ impl From 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 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 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/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index eb6e4afd8..c853eb50f 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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 diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 56754868f..43e3d305b 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -3546,6 +3546,7 @@ mod tests { unlimited: false, balance: Some("5".to_string()), }), + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index e8fffd536..ac7e87741 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -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 } diff --git a/codex-rs/app-server/tests/suite/v2/rate_limits.rs b/codex-rs/app-server/tests/suite/v2/rate_limits.rs index 0eca04499..1bb93db81 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -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, }, diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 46c83fdde..159dba8b8 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -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, rate_limit: Option, credits: Option, + individual_limit: Option, plan_type: Option, rate_limit_reached_type: Option, ) -> 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, }; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 67304f100..38d9c6838 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -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; diff --git a/codex-rs/codex-api/src/rate_limits.rs b/codex-rs/codex-api/src/rate_limits.rs index a2ad87667..91f96fc3b 100644 --- a/codex-rs/codex-api/src/rate_limits.rs +++ b/codex-rs/codex-api/src/rate_limits.rs @@ -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 { primary, secondary, credits, + individual_limit: None, plan_type: event.plan_type, rate_limit_reached_type: None, }) diff --git a/codex-rs/codex-backend-openapi-models/src/models/mod.rs b/codex-rs/codex-backend-openapi-models/src/models/mod.rs index f0504a9c8..c3f4e6bee 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/mod.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/mod.rs @@ -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; diff --git a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs index 56d4a8ee1..066a81b52 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs @@ -30,6 +30,13 @@ pub struct RateLimitStatusPayload { skip_serializing_if = "Option::is_none" )] pub credits: Option>>, + #[serde( + rename = "spend_control", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub spend_control: Option>>, #[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, } diff --git a/codex-rs/codex-backend-openapi-models/src/models/spend_control_limit_details.rs b/codex-rs/codex-backend-openapi-models/src/models/spend_control_limit_details.rs new file mode 100644 index 000000000..6fc1fc51a --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/spend_control_limit_details.rs @@ -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>, + #[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, + } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/spend_control_status_details.rs b/codex-rs/codex-backend-openapi-models/src/models/spend_control_status_details.rs new file mode 100644 index 000000000..1283acabf --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/spend_control_status_details.rs @@ -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>>, +} + +impl SpendControlStatusDetails { + pub fn new(reached: bool) -> SpendControlStatusDetails { + SpendControlStatusDetails { + reached, + individual_limit: None, + } + } +} diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 14baed937..ec67f765d 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -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, }) diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index ac59cb32d..2663943f8 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -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); } diff --git a/codex-rs/core/src/state/session_tests.rs b/codex-rs/core/src/state/session_tests.rs index b6b5a057a..5f0ae085e 100644 --- a/codex-rs/core/src/state/session_tests.rs +++ b/codex-rs/core/src/state/session_tests.rs @@ -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, }) diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 48ba697aa..c954a954f 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -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 }); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index d9e94204f..41db88f1a 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -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 } diff --git a/codex-rs/memories/write/src/guard_tests.rs b/codex-rs/memories/write/src/guard_tests.rs index 139a8773c..0659aabd1 100644 --- a/codex-rs/memories/write/src/guard_tests.rs +++ b/codex-rs/memories/write/src/guard_tests.rs @@ -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, } diff --git a/codex-rs/protocol/src/error_tests.rs b/codex-rs/protocol/src/error_tests.rs index 11bd26133..bd7a5f005 100644 --- a/codex-rs/protocol/src/error_tests.rs +++ b/codex-rs/protocol/src/error_tests.rs @@ -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, } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7c91223f7..6f95ac8b8 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2009,6 +2009,7 @@ pub struct RateLimitSnapshot { pub primary: Option, pub secondary: Option, pub credits: Option, + pub individual_limit: Option, pub plan_type: Option, pub rate_limit_reached_type: Option, } @@ -2058,6 +2059,14 @@ pub struct CreditsSnapshot { pub balance: Option, } +#[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; diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index 18e6116e5..4aeba59aa 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -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) => { diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index f4e0ec101..2290640ab 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1804,6 +1804,7 @@ mod tests { }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, } diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index df45a11c7..f831b5a00 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -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) { + 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, + 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); diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 04eb543ba..cfc9278de 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -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, } diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 4b13e5380..3d08daa42 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -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, })); diff --git a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs index a90b8dcb2..8654b37d2 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -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, })); diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 0117508ae..b8c9c1653 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -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(); diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index 1146bb8a3..2054f799d 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -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, + /// Optional detail line rendered beneath the progress bar. + details: Option, }, /// Plain text value used for non-window rows. Text(String), @@ -99,6 +103,8 @@ pub(crate) struct RateLimitSnapshotDisplay { pub secondary: Option, /// Optional credits metadata when available. pub credits: Option, + /// Optional effective monthly credit limit from workspace spend controls. + pub individual_limit: Option, } /// Display-ready credits state extracted from protocol snapshots. @@ -112,6 +118,17 @@ pub(crate) struct CreditsSnapshotDisplay { pub balance: Option, } +/// 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, + pub percent_remaining: f64, + pub used: String, + pub limit: String, + pub resets_at: Option, +} + /// 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, + ) -> Option { + 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::::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 { None } +fn format_credit_amount(raw: &str) -> Option { + let value = raw.trim().parse::().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) { diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_enterprise_monthly_credit_limit.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_enterprise_monthly_credit_limit.snap new file mode 100644 index 000000000..765ad8d3a --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_enterprise_monthly_credit_limit.snap @@ -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: │ +│ │ +│ 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 │ +╰───────────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_wraps_enterprise_monthly_credit_details_in_narrow_terminal.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_wraps_enterprise_monthly_credit_details_in_narrow_terminal.snap new file mode 100644 index 000000000..cf6fa2c2e --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_wraps_enterprise_monthly_credit_details_in_narrow_terminal.snap @@ -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: │ +│ │ +│ 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 │ +╰────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 8b4e41ce7..1fc4c4feb 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -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 { } fn sanitize_directory(lines: Vec) -> Vec { + 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, };