From 3d1edf918c53fefe84a318e8dc41166e5caed6d8 Mon Sep 17 00:00:00 2001 From: Richard Lee Date: Thu, 16 Apr 2026 15:55:37 -0700 Subject: [PATCH 1/3] Propagate rate limit reached type --- .../schema/json/ServerNotification.json | 20 ++++ .../codex_app_server_protocol.schemas.json | 20 ++++ .../codex_app_server_protocol.v2.schemas.json | 20 ++++ .../AccountRateLimitsUpdatedNotification.json | 20 ++++ .../json/v2/GetAccountRateLimitsResponse.json | 20 ++++ .../typescript/v2/RateLimitReachedType.ts | 5 + .../schema/typescript/v2/RateLimitSnapshot.ts | 3 +- .../schema/typescript/v2/index.ts | 1 + .../app-server-protocol/src/protocol/v2.rs | 56 ++++++++++ .../app-server/src/bespoke_event_handling.rs | 1 + codex-rs/app-server/src/outgoing_message.rs | 4 +- .../app-server/tests/suite/v2/rate_limits.rs | 9 ++ codex-rs/backend-client/src/client.rs | 100 ++++++++++++++++++ codex-rs/codex-api/src/rate_limits.rs | 2 + .../src/models/mod.rs | 2 + .../src/models/rate_limit_status_payload.rs | 33 ++++++ codex-rs/core/src/session/tests.rs | 6 ++ codex-rs/core/src/state/session_tests.rs | 6 ++ codex-rs/core/tests/suite/client.rs | 9 +- .../core/tests/suite/client_websockets.rs | 3 +- codex-rs/protocol/src/error_tests.rs | 1 + codex-rs/protocol/src/protocol.rs | 12 +++ codex-rs/tui/src/app_server_session.rs | 1 + codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + .../src/chatwidget/tests/status_and_layout.rs | 8 ++ codex-rs/tui/src/status/tests.rs | 13 +++ 26 files changed, 370 insertions(+), 6 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/RateLimitReachedType.ts diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 8591e970abb6..49dfa9210c1e 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2046,6 +2046,16 @@ ], "type": "string" }, + "RateLimitReachedType": { + "enum": [ + "rate_limit_reached", + "workspace_owner_credits_depleted", + "workspace_member_credits_depleted", + "workspace_owner_usage_limit_reached", + "workspace_member_usage_limit_reached" + ], + "type": "string" + }, "RateLimitSnapshot": { "properties": { "credits": { @@ -2090,6 +2100,16 @@ } ] }, + "rateLimitReachedType": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimitReachedType" + }, + { + "type": "null" + } + ] + }, "secondary": { "anyOf": [ { 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 059eb5e11dba..36714bf21a23 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 @@ -10668,6 +10668,16 @@ }, "type": "object" }, + "RateLimitReachedType": { + "enum": [ + "rate_limit_reached", + "workspace_owner_credits_depleted", + "workspace_member_credits_depleted", + "workspace_owner_usage_limit_reached", + "workspace_member_usage_limit_reached" + ], + "type": "string" + }, "RateLimitSnapshot": { "properties": { "credits": { @@ -10712,6 +10722,16 @@ } ] }, + "rateLimitReachedType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/RateLimitReachedType" + }, + { + "type": "null" + } + ] + }, "secondary": { "anyOf": [ { 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 56986d5ab5a5..59196263cd81 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 @@ -7420,6 +7420,16 @@ }, "type": "object" }, + "RateLimitReachedType": { + "enum": [ + "rate_limit_reached", + "workspace_owner_credits_depleted", + "workspace_member_credits_depleted", + "workspace_owner_usage_limit_reached", + "workspace_member_usage_limit_reached" + ], + "type": "string" + }, "RateLimitSnapshot": { "properties": { "credits": { @@ -7464,6 +7474,16 @@ } ] }, + "rateLimitReachedType": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimitReachedType" + }, + { + "type": "null" + } + ] + }, "secondary": { "anyOf": [ { 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 67a5f055b367..96fefb228b84 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json @@ -39,6 +39,16 @@ ], "type": "string" }, + "RateLimitReachedType": { + "enum": [ + "rate_limit_reached", + "workspace_owner_credits_depleted", + "workspace_member_credits_depleted", + "workspace_owner_usage_limit_reached", + "workspace_member_usage_limit_reached" + ], + "type": "string" + }, "RateLimitSnapshot": { "properties": { "credits": { @@ -83,6 +93,16 @@ } ] }, + "rateLimitReachedType": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimitReachedType" + }, + { + "type": "null" + } + ] + }, "secondary": { "anyOf": [ { 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 2d34ee47d12e..8f7db24e7fee 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json @@ -39,6 +39,16 @@ ], "type": "string" }, + "RateLimitReachedType": { + "enum": [ + "rate_limit_reached", + "workspace_owner_credits_depleted", + "workspace_member_credits_depleted", + "workspace_owner_usage_limit_reached", + "workspace_member_usage_limit_reached" + ], + "type": "string" + }, "RateLimitSnapshot": { "properties": { "credits": { @@ -83,6 +93,16 @@ } ] }, + "rateLimitReachedType": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimitReachedType" + }, + { + "type": "null" + } + ] + }, "secondary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitReachedType.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitReachedType.ts new file mode 100644 index 000000000000..78f106c905d1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitReachedType.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 RateLimitReachedType = "rate_limit_reached" | "workspace_owner_credits_depleted" | "workspace_member_credits_depleted" | "workspace_owner_usage_limit_reached" | "workspace_member_usage_limit_reached"; 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 0c2ebe1893f7..dc8417a30402 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts @@ -3,6 +3,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { PlanType } from "../PlanType"; import type { CreditsSnapshot } from "./CreditsSnapshot"; +import type { RateLimitReachedType } from "./RateLimitReachedType"; import type { RateLimitWindow } from "./RateLimitWindow"; -export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, planType: PlanType | null, }; +export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, planType: PlanType | null, rateLimitReachedType: RateLimitReachedType | null, }; 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 31371b7124ee..7f72c566f095 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -241,6 +241,7 @@ export type { PluginUninstallParams } from "./PluginUninstallParams"; export type { PluginUninstallResponse } from "./PluginUninstallResponse"; export type { PluginsMigration } from "./PluginsMigration"; export type { ProfileV2 } from "./ProfileV2"; +export type { RateLimitReachedType } from "./RateLimitReachedType"; export type { RateLimitSnapshot } from "./RateLimitSnapshot"; export type { RateLimitWindow } from "./RateLimitWindow"; export type { RawResponseItemCompletedNotification } from "./RawResponseItemCompletedNotification"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 452f0b5fb895..73131b9f55e5 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -70,6 +70,7 @@ use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind; use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; +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::ReadOnlyAccess as CoreReadOnlyAccess; @@ -6482,6 +6483,7 @@ pub struct RateLimitSnapshot { pub secondary: Option, pub credits: Option, pub plan_type: Option, + pub rate_limit_reached_type: Option, } impl From for RateLimitSnapshot { @@ -6493,6 +6495,60 @@ impl From for RateLimitSnapshot { secondary: value.secondary.map(RateLimitWindow::from), credits: value.credits.map(CreditsSnapshot::from), plan_type: value.plan_type, + rate_limit_reached_type: value + .rate_limit_reached_type + .map(RateLimitReachedType::from), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/", rename_all = "snake_case")] +pub enum RateLimitReachedType { + RateLimitReached, + WorkspaceOwnerCreditsDepleted, + WorkspaceMemberCreditsDepleted, + WorkspaceOwnerUsageLimitReached, + WorkspaceMemberUsageLimitReached, +} + +impl From for RateLimitReachedType { + fn from(value: CoreRateLimitReachedType) -> Self { + match value { + CoreRateLimitReachedType::RateLimitReached => Self::RateLimitReached, + CoreRateLimitReachedType::WorkspaceOwnerCreditsDepleted => { + Self::WorkspaceOwnerCreditsDepleted + } + CoreRateLimitReachedType::WorkspaceMemberCreditsDepleted => { + Self::WorkspaceMemberCreditsDepleted + } + CoreRateLimitReachedType::WorkspaceOwnerUsageLimitReached => { + Self::WorkspaceOwnerUsageLimitReached + } + CoreRateLimitReachedType::WorkspaceMemberUsageLimitReached => { + Self::WorkspaceMemberUsageLimitReached + } + } + } +} + +impl From for CoreRateLimitReachedType { + fn from(value: RateLimitReachedType) -> Self { + match value { + RateLimitReachedType::RateLimitReached => Self::RateLimitReached, + RateLimitReachedType::WorkspaceOwnerCreditsDepleted => { + Self::WorkspaceOwnerCreditsDepleted + } + RateLimitReachedType::WorkspaceMemberCreditsDepleted => { + Self::WorkspaceMemberCreditsDepleted + } + RateLimitReachedType::WorkspaceOwnerUsageLimitReached => { + Self::WorkspaceOwnerUsageLimitReached + } + RateLimitReachedType::WorkspaceMemberUsageLimitReached => { + Self::WorkspaceMemberUsageLimitReached + } } } } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 5d19bde70c71..9b2cf57d74ea 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -4138,6 +4138,7 @@ mod tests { balance: Some("5".to_string()), }), plan_type: None, + rate_limit_reached_type: None, }; handle_token_count_event( diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d4e4bde06394..1f02fca32620 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -736,6 +736,7 @@ mod tests { secondary: None, credits: None, plan_type: Some(PlanType::Plus), + rate_limit_reached_type: None, }, }); @@ -754,7 +755,8 @@ mod tests { }, "secondary": null, "credits": null, - "planType": "plus" + "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 203d664940a1..20b1729f11ea 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -7,6 +7,7 @@ use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::LoginAccountResponse; +use codex_app_server_protocol::RateLimitReachedType; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; use codex_app_server_protocol::RequestId; @@ -118,6 +119,9 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { "reset_at": secondary_reset_timestamp, } }, + "rate_limit_reached_type": { + "type": "workspace_member_usage_limit_reached", + }, "additional_rate_limits": [ { "limit_name": "codex_other", @@ -173,6 +177,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { }), credits: None, plan_type: Some(AccountPlanType::Pro), + rate_limit_reached_type: Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached), }, rate_limits_by_limit_id: Some( [ @@ -193,6 +198,9 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { }), credits: None, plan_type: Some(AccountPlanType::Pro), + rate_limit_reached_type: Some( + RateLimitReachedType::WorkspaceMemberUsageLimitReached, + ), }, ), ( @@ -208,6 +216,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { secondary: None, credits: 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 ad461973f974..751e1cb30270 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -1,6 +1,7 @@ use crate::types::CodeTaskDetailsResponse; use crate::types::ConfigFileResponse; use crate::types::PaginatedListTaskListItem; +use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind; use crate::types::RateLimitStatusPayload; use crate::types::TurnAttemptsSiblingTurnsResponse; use anyhow::Result; @@ -9,6 +10,7 @@ use codex_login::CodexAuth; use codex_login::default_client::get_codex_user_agent; use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::protocol::CreditsSnapshot; +use codex_protocol::protocol::RateLimitReachedType; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; use reqwest::StatusCode; @@ -412,12 +414,17 @@ impl Client { payload: RateLimitStatusPayload, ) -> Vec { let plan_type = Some(Self::map_plan_type(payload.plan_type)); + let rate_limit_reached_type = payload + .rate_limit_reached_type + .flatten() + .and_then(|details| Self::map_rate_limit_reached_type(details.kind)); 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), plan_type, + rate_limit_reached_type, )]; if let Some(additional) = payload.additional_rate_limits.flatten() { snapshots.extend(additional.into_iter().map(|details| { @@ -427,6 +434,7 @@ impl Client { details.rate_limit.flatten().map(|rate_limit| *rate_limit), /*credits*/ None, plan_type, + /*rate_limit_reached_type*/ None, ) })); } @@ -439,6 +447,7 @@ impl Client { rate_limit: Option, credits: Option, plan_type: Option, + rate_limit_reached_type: Option, ) -> RateLimitSnapshot { let (primary, secondary) = match rate_limit { Some(details) => ( @@ -454,6 +463,30 @@ impl Client { secondary, credits: Self::map_credits(credits), plan_type, + rate_limit_reached_type, + } + } + + fn map_rate_limit_reached_type( + kind: BackendRateLimitReachedKind, + ) -> Option { + match kind { + BackendRateLimitReachedKind::RateLimitReached => { + Some(RateLimitReachedType::RateLimitReached) + } + BackendRateLimitReachedKind::WorkspaceOwnerCreditsDepleted => { + Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted) + } + BackendRateLimitReachedKind::WorkspaceMemberCreditsDepleted => { + Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) + } + BackendRateLimitReachedKind::WorkspaceOwnerUsageLimitReached => { + Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached) + } + BackendRateLimitReachedKind::WorkspaceMemberUsageLimitReached => { + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached) + } + BackendRateLimitReachedKind::Unknown => None, } } @@ -521,6 +554,8 @@ impl Client { mod tests { use super::*; use codex_backend_openapi_models::models::AdditionalRateLimitDetails; + use codex_backend_openapi_models::models::RateLimitReachedKind; + use codex_backend_openapi_models::models::RateLimitReachedType as BackendRateLimitReachedType; use pretty_assertions::assert_eq; #[test] @@ -574,6 +609,9 @@ mod tests { balance: Some(Some("9.99".to_string())), ..Default::default() }))), + rate_limit_reached_type: Some(Some(Box::new(BackendRateLimitReachedType { + kind: RateLimitReachedKind::WorkspaceMemberCreditsDepleted, + }))), }; let snapshots = Client::rate_limit_snapshots_from_payload(payload); @@ -598,6 +636,10 @@ mod tests { }) ); assert_eq!(snapshots[0].plan_type, Some(AccountPlanType::Pro)); + assert_eq!( + snapshots[0].rate_limit_reached_type, + Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) + ); assert_eq!(snapshots[1].limit_id.as_deref(), Some("codex_other")); assert_eq!(snapshots[1].limit_name.as_deref(), Some("codex_other")); @@ -607,6 +649,7 @@ mod tests { ); assert_eq!(snapshots[1].credits, None); assert_eq!(snapshots[1].plan_type, Some(AccountPlanType::Pro)); + assert_eq!(snapshots[1].rate_limit_reached_type, None); } #[test] @@ -620,6 +663,7 @@ mod tests { rate_limit: None, }])), credits: None, + rate_limit_reached_type: None, }; let snapshots = Client::rate_limit_snapshots_from_payload(payload); @@ -645,6 +689,7 @@ mod tests { secondary: None, credits: None, plan_type: Some(AccountPlanType::Pro), + rate_limit_reached_type: None, }, RateLimitSnapshot { limit_id: Some("codex".to_string()), @@ -657,6 +702,7 @@ mod tests { secondary: None, credits: None, plan_type: Some(AccountPlanType::Pro), + rate_limit_reached_type: None, }, ]; @@ -667,4 +713,58 @@ mod tests { .unwrap_or_else(|| snapshots[0].clone()); assert_eq!(preferred.limit_id.as_deref(), Some("codex")); } + + #[test] + fn usage_payload_maps_every_rate_limit_reached_type() { + let cases = [ + ( + RateLimitReachedKind::RateLimitReached, + Some(RateLimitReachedType::RateLimitReached), + ), + ( + RateLimitReachedKind::WorkspaceOwnerCreditsDepleted, + Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted), + ), + ( + RateLimitReachedKind::WorkspaceMemberCreditsDepleted, + Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted), + ), + ( + RateLimitReachedKind::WorkspaceOwnerUsageLimitReached, + Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached), + ), + ( + RateLimitReachedKind::WorkspaceMemberUsageLimitReached, + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached), + ), + (RateLimitReachedKind::Unknown, None), + ]; + + for (kind, expected) in cases { + let payload = RateLimitStatusPayload { + plan_type: crate::types::PlanType::Plus, + rate_limit: None, + credits: None, + additional_rate_limits: None, + rate_limit_reached_type: Some(Some(Box::new(BackendRateLimitReachedType { kind }))), + }; + + let snapshots = Client::rate_limit_snapshots_from_payload(payload); + assert_eq!(snapshots[0].rate_limit_reached_type, expected); + } + } + + #[test] + fn usage_payload_preserves_absent_rate_limit_reached_type() { + let payload = RateLimitStatusPayload { + plan_type: crate::types::PlanType::Plus, + rate_limit: None, + credits: None, + additional_rate_limits: None, + rate_limit_reached_type: None, + }; + + let snapshots = Client::rate_limit_snapshots_from_payload(payload); + assert_eq!(snapshots[0].rate_limit_reached_type, None); + } } diff --git a/codex-rs/codex-api/src/rate_limits.rs b/codex-rs/codex-api/src/rate_limits.rs index 1c71dc750251..979500cdabc4 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( secondary, credits, plan_type: None, + rate_limit_reached_type: None, }) } @@ -156,6 +157,7 @@ pub fn parse_rate_limit_event(payload: &str) -> Option { secondary, credits, 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 2140c83f9123..c881822b375c 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/mod.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/mod.rs @@ -32,6 +32,8 @@ pub use self::additional_rate_limit_details::AdditionalRateLimitDetails; pub(crate) mod rate_limit_status_payload; pub use self::rate_limit_status_payload::PlanType; +pub use self::rate_limit_status_payload::RateLimitReachedKind; +pub use self::rate_limit_status_payload::RateLimitReachedType; pub use self::rate_limit_status_payload::RateLimitStatusPayload; pub(crate) mod rate_limit_status_details; 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 4d9ec1e364e1..38c8730d912a 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 @@ -37,6 +37,13 @@ pub struct RateLimitStatusPayload { skip_serializing_if = "Option::is_none" )] pub additional_rate_limits: Option>>, + #[serde( + rename = "rate_limit_reached_type", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub rate_limit_reached_type: Option>>, } impl RateLimitStatusPayload { @@ -46,10 +53,36 @@ impl RateLimitStatusPayload { rate_limit: None, credits: None, additional_rate_limits: None, + rate_limit_reached_type: None, } } } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RateLimitReachedType { + #[serde(rename = "type")] + pub kind: RateLimitReachedKind, +} + +#[derive( + Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, Default, +)] +pub enum RateLimitReachedKind { + #[serde(rename = "rate_limit_reached")] + RateLimitReached, + #[serde(rename = "workspace_owner_credits_depleted")] + WorkspaceOwnerCreditsDepleted, + #[serde(rename = "workspace_member_credits_depleted")] + WorkspaceMemberCreditsDepleted, + #[serde(rename = "workspace_owner_usage_limit_reached")] + WorkspaceOwnerUsageLimitReached, + #[serde(rename = "workspace_member_usage_limit_reached")] + WorkspaceMemberUsageLimitReached, + #[serde(rename = "unknown", other)] + #[default] + Unknown, +} + #[derive( Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, Default, )] diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index d2e0d5dcb303..abe96c5490af 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2070,6 +2070,7 @@ async fn set_rate_limits_retains_previous_credits() { balance: Some("10.00".to_string()), }), plan_type: Some(codex_protocol::account::PlanType::Plus), + rate_limit_reached_type: None, }; state.set_rate_limits(initial.clone()); @@ -2088,6 +2089,7 @@ async fn set_rate_limits_retains_previous_credits() { }), credits: None, plan_type: None, + rate_limit_reached_type: None, }; state.set_rate_limits(update.clone()); @@ -2100,6 +2102,7 @@ async fn set_rate_limits_retains_previous_credits() { secondary: update.secondary, credits: initial.credits, plan_type: initial.plan_type, + rate_limit_reached_type: None, }) ); } @@ -2176,6 +2179,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { balance: Some("15.00".to_string()), }), plan_type: Some(codex_protocol::account::PlanType::Plus), + rate_limit_reached_type: None, }; state.set_rate_limits(initial.clone()); @@ -2190,6 +2194,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { secondary: None, credits: None, plan_type: Some(codex_protocol::account::PlanType::Pro), + rate_limit_reached_type: None, }; state.set_rate_limits(update.clone()); @@ -2202,6 +2207,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { secondary: update.secondary, credits: initial.credits, plan_type: update.plan_type, + rate_limit_reached_type: None, }) ); } diff --git a/codex-rs/core/src/state/session_tests.rs b/codex-rs/core/src/state/session_tests.rs index f9c64d0eb20f..927c1fbb70fd 100644 --- a/codex-rs/core/src/state/session_tests.rs +++ b/codex-rs/core/src/state/session_tests.rs @@ -87,6 +87,7 @@ async fn set_rate_limits_defaults_limit_id_to_codex_when_missing() { secondary: None, credits: None, plan_type: None, + rate_limit_reached_type: None, }); assert_eq!( @@ -114,6 +115,7 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc secondary: None, credits: None, plan_type: None, + rate_limit_reached_type: None, }); state.set_rate_limits(RateLimitSnapshot { limit_id: None, @@ -126,6 +128,7 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc secondary: None, credits: None, plan_type: None, + rate_limit_reached_type: None, }); assert_eq!( @@ -157,6 +160,7 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other balance: Some("50".to_string()), }), plan_type: Some(codex_protocol::account::PlanType::Plus), + rate_limit_reached_type: None, }); state.set_rate_limits(RateLimitSnapshot { @@ -170,6 +174,7 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other secondary: None, credits: None, plan_type: None, + rate_limit_reached_type: None, }); assert_eq!( @@ -189,6 +194,7 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other balance: Some("50".to_string()), }), 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 9b0a5587b712..b5087de5a5bf 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -2375,7 +2375,8 @@ async fn token_count_includes_rate_limits_snapshot() { "resets_at": 1704074400 }, "credits": null, - "plan_type": null + "plan_type": null, + "rate_limit_reached_type": null } }) ); @@ -2426,7 +2427,8 @@ async fn token_count_includes_rate_limits_snapshot() { "resets_at": 1704074400 }, "credits": null, - "plan_type": null + "plan_type": null, + "rate_limit_reached_type": null } }) ); @@ -2500,7 +2502,8 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { "resets_at": null }, "credits": null, - "plan_type": null + "plan_type": null, + "rate_limit_reached_type": null }); let submission_id = codex diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index eaa8a9610dd1..07f9f5821f28 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1033,7 +1033,8 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { "resets_at": null }, "credits": null, - "plan_type": null + "plan_type": null, + "rate_limit_reached_type": null } }) ); diff --git a/codex-rs/protocol/src/error_tests.rs b/codex-rs/protocol/src/error_tests.rs index 0b1d1897277d..aef7478607c6 100644 --- a/codex-rs/protocol/src/error_tests.rs +++ b/codex-rs/protocol/src/error_tests.rs @@ -36,6 +36,7 @@ fn rate_limit_snapshot() -> RateLimitSnapshot { }), credits: 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 91f368e398a3..203c3201fa35 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2200,6 +2200,18 @@ pub struct RateLimitSnapshot { pub secondary: Option, pub credits: Option, pub plan_type: Option, + pub rate_limit_reached_type: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum RateLimitReachedType { + RateLimitReached, + WorkspaceOwnerCreditsDepleted, + WorkspaceMemberCreditsDepleted, + WorkspaceOwnerUsageLimitReached, + WorkspaceMemberUsageLimitReached, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 677614907ce5..7db3dd7d39e2 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1188,6 +1188,7 @@ pub(crate) fn app_server_rate_limit_snapshot_to_core( secondary: snapshot.secondary.map(app_server_rate_limit_window_to_core), credits: snapshot.credits.map(app_server_credits_snapshot_to_core), plan_type: snapshot.plan_type, + rate_limit_reached_type: snapshot.rate_limit_reached_type.map(Into::into), } } diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index a7d8a355c50d..f2e3c20c3831 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -106,6 +106,7 @@ pub(super) fn snapshot(percent: f64) -> RateLimitSnapshot { secondary: None, credits: 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 a31b93bb4c42..7c1dd5604247 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -235,6 +235,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { balance: Some("17.5".to_string()), }), plan_type: None, + rate_limit_reached_type: None, })); let initial_balance = chat .rate_limit_snapshots_by_limit_id @@ -254,6 +255,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { secondary: None, credits: None, plan_type: None, + rate_limit_reached_type: None, })); let display = chat @@ -292,6 +294,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { }), credits: None, plan_type: Some(PlanType::Plus), + rate_limit_reached_type: None, })); assert_eq!(chat.plan_type, Some(PlanType::Plus)); @@ -310,6 +313,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { }), credits: None, plan_type: Some(PlanType::Pro), + rate_limit_reached_type: None, })); assert_eq!(chat.plan_type, Some(PlanType::Pro)); @@ -328,6 +332,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { }), credits: None, plan_type: None, + rate_limit_reached_type: None, })); assert_eq!(chat.plan_type, Some(PlanType::Pro)); } @@ -351,6 +356,7 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() { balance: Some("5.00".to_string()), }), plan_type: Some(PlanType::Pro), + rate_limit_reached_type: None, })); chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { @@ -364,6 +370,7 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() { secondary: None, credits: None, plan_type: Some(PlanType::Pro), + rate_limit_reached_type: None, })); let codex = chat @@ -416,6 +423,7 @@ async fn rate_limit_switch_prompt_skips_non_codex_limit() { secondary: None, credits: None, plan_type: None, + rate_limit_reached_type: None, })); assert!(matches!( diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 12b23a38f65d..a537a398485c 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -138,6 +138,7 @@ async fn status_snapshot_includes_reasoning_details() { }), credits: None, plan_type: None, + rate_limit_reached_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -321,6 +322,7 @@ async fn status_snapshot_includes_monthly_limit() { secondary: None, credits: None, plan_type: None, + rate_limit_reached_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -372,6 +374,7 @@ async fn status_snapshot_shows_unlimited_credits() { balance: None, }), 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()); @@ -421,6 +424,7 @@ async fn status_snapshot_shows_positive_credits() { balance: Some("12.5".to_string()), }), 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()); @@ -470,6 +474,7 @@ async fn status_snapshot_hides_zero_credits() { balance: Some("0".to_string()), }), 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()); @@ -517,6 +522,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { balance: None, }), 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()); @@ -622,6 +628,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { secondary: None, credits: None, plan_type: None, + rate_limit_reached_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -735,6 +742,7 @@ async fn status_snapshot_shows_refreshing_limits_notice() { }), credits: None, plan_type: None, + rate_limit_reached_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -805,6 +813,7 @@ async fn status_snapshot_includes_credits_and_limits() { balance: Some("37.5".to_string()), }), plan_type: None, + rate_limit_reached_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -858,6 +867,7 @@ async fn status_snapshot_shows_unavailable_limits_message() { secondary: None, credits: None, plan_type: None, + rate_limit_reached_type: None, }; let captured_at = chrono::Local .with_ymd_and_hms(2024, 6, 7, 8, 9, 10) @@ -914,6 +924,7 @@ async fn status_snapshot_treats_refreshing_empty_limits_as_unavailable() { secondary: None, credits: None, plan_type: None, + rate_limit_reached_type: None, }; let captured_at = chrono::Local .with_ymd_and_hms(2024, 6, 7, 8, 9, 10) @@ -984,6 +995,7 @@ async fn status_snapshot_shows_stale_limits_message() { }), credits: None, plan_type: None, + rate_limit_reached_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let now = captured_at + ChronoDuration::minutes(20); @@ -1054,6 +1066,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { balance: Some("80".to_string()), }), plan_type: None, + rate_limit_reached_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let now = captured_at + ChronoDuration::minutes(20); From 0b0f89dd998f03f4a725845812a7e815eac467e0 Mon Sep 17 00:00:00 2001 From: Richard Lee Date: Thu, 16 Apr 2026 16:31:50 -0700 Subject: [PATCH 2/3] codex: address PR review feedback (#18227) --- codex-rs/backend-client/src/types.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 22e0984cda1e..d8d24ab9fce4 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -2,6 +2,7 @@ pub use codex_backend_openapi_models::models::ConfigFileResponse; pub use codex_backend_openapi_models::models::CreditStatusDetails; pub use codex_backend_openapi_models::models::PaginatedListTaskListItem; pub use codex_backend_openapi_models::models::PlanType; +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; From 4edc250d09eb1c3bbbaa7d8ed2ed8b1e4af6c856 Mon Sep 17 00:00:00 2001 From: Richard Lee Date: Thu, 16 Apr 2026 22:38:31 -0700 Subject: [PATCH 3/3] codex: address PR review feedback (#18227) --- codex-rs/backend-client/src/client.rs | 6 +++--- .../src/models/rate_limit_status_payload.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 751e1cb30270..84fbffb690f4 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -609,9 +609,9 @@ mod tests { balance: Some(Some("9.99".to_string())), ..Default::default() }))), - rate_limit_reached_type: Some(Some(Box::new(BackendRateLimitReachedType { + rate_limit_reached_type: Some(Some(BackendRateLimitReachedType { kind: RateLimitReachedKind::WorkspaceMemberCreditsDepleted, - }))), + })), }; let snapshots = Client::rate_limit_snapshots_from_payload(payload); @@ -746,7 +746,7 @@ mod tests { rate_limit: None, credits: None, additional_rate_limits: None, - rate_limit_reached_type: Some(Some(Box::new(BackendRateLimitReachedType { kind }))), + rate_limit_reached_type: Some(Some(BackendRateLimitReachedType { kind })), }; let snapshots = Client::rate_limit_snapshots_from_payload(payload); 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 38c8730d912a..56d4a8ee1049 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 @@ -43,7 +43,7 @@ pub struct RateLimitStatusPayload { with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none" )] - pub rate_limit_reached_type: Option>>, + pub rate_limit_reached_type: Option>, } impl RateLimitStatusPayload {