diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index d533ba0ded..c5583bc004 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1025,6 +1025,7 @@ pub(crate) struct CodexCompactionEventParams { pub(crate) status: CompactionStatus, pub(crate) codex_error_kind: Option, pub(crate) codex_error_http_status_code: Option, + pub(crate) usage_limit_window_minutes: Option, pub(crate) active_context_tokens_before: i64, pub(crate) active_context_tokens_after: i64, pub(crate) retained_image_count: Option, @@ -1110,6 +1111,8 @@ pub(crate) struct CodexTurnEventParams { pub(crate) turn_error: Option, pub(crate) codex_error_kind: Option, pub(crate) codex_error_http_status_code: Option, + /// The server-selected window responsible for a usage limit, when known. + pub(crate) usage_limit_window_minutes: Option, pub(crate) steer_count: Option, pub(crate) total_tool_call_count: Option, pub(crate) shell_command_count: Option, @@ -1389,6 +1392,7 @@ pub(crate) fn codex_compaction_event_params( status: input.status, codex_error_kind: input.codex_error_kind, codex_error_http_status_code: input.codex_error_http_status_code, + usage_limit_window_minutes: input.usage_limit_window_minutes, active_context_tokens_before: input.active_context_tokens_before, active_context_tokens_after: input.active_context_tokens_after, retained_image_count: input.retained_image_count, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 4042b1fe08..442fb00dd5 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -18,6 +18,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; use codex_protocol::error::CodexErr; pub use codex_protocol::error::CodexErrKind; +use codex_protocol::error::CodexErrorDetails; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; @@ -280,6 +281,7 @@ impl TurnCodexErrorFact { pub(crate) struct TurnCodexError { pub(crate) kind: CodexErrKind, pub(crate) http_status_code: Option, + pub(crate) usage_limit_window_minutes: Option, } impl TurnCodexError { @@ -287,6 +289,10 @@ impl TurnCodexError { Self { kind: error.into(), http_status_code: error.http_status_code_value(), + usage_limit_window_minutes: match error.details() { + CodexErrorDetails::UsageLimitReached(error) => error.limit_window_minutes, + _ => None, + }, } } } @@ -498,6 +504,7 @@ pub struct CodexCompactionEvent { pub status: CompactionStatus, pub codex_error_kind: Option, pub codex_error_http_status_code: Option, + pub usage_limit_window_minutes: Option, pub active_context_tokens_before: i64, pub active_context_tokens_after: i64, pub retained_image_count: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 29d1448397..d9a0090188 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -3708,6 +3708,7 @@ fn codex_turn_event_params( turn_error: completed.turn_error, codex_error_kind: codex_error.map(|error| error.kind), codex_error_http_status_code: codex_error.and_then(|error| error.http_status_code), + usage_limit_window_minutes: codex_error.and_then(|error| error.usage_limit_window_minutes), steer_count: Some(turn_state.steer_count), total_tool_call_count: Some(turn_state.tool_counts.total), shell_command_count: Some(turn_state.tool_counts.shell_command), diff --git a/codex-rs/analytics/src/tests/suite/approvals_tests.rs b/codex-rs/analytics/src/tests/suite/approvals_tests.rs index 22a56c03de..78f7189a56 100644 --- a/codex-rs/analytics/src/tests/suite/approvals_tests.rs +++ b/codex-rs/analytics/src/tests/suite/approvals_tests.rs @@ -878,6 +878,7 @@ async fn guardian_events_keep_thread_source_and_originator_with_explicit_turn_co status: CompactionStatus::Completed, codex_error_kind: None, codex_error_http_status_code: None, + usage_limit_window_minutes: None, active_context_tokens_before: 131_000, active_context_tokens_after: 64_000, retained_image_count: None, diff --git a/codex-rs/analytics/src/tests/suite/threads_tests.rs b/codex-rs/analytics/src/tests/suite/threads_tests.rs index ac9f9c0262..ba4e70d011 100644 --- a/codex-rs/analytics/src/tests/suite/threads_tests.rs +++ b/codex-rs/analytics/src/tests/suite/threads_tests.rs @@ -398,6 +398,7 @@ async fn thread_originator_overrides_shared_connection_across_thread_events() { status: CompactionStatus::Completed, codex_error_kind: None, codex_error_http_status_code: None, + usage_limit_window_minutes: None, active_context_tokens_before: 131_000, active_context_tokens_after: 64_000, retained_image_count: None, diff --git a/codex-rs/analytics/src/tests/suite/turns/compaction_tests.rs b/codex-rs/analytics/src/tests/suite/turns/compaction_tests.rs index 68d20000b5..fa4c642152 100644 --- a/codex-rs/analytics/src/tests/suite/turns/compaction_tests.rs +++ b/codex-rs/analytics/src/tests/suite/turns/compaction_tests.rs @@ -45,6 +45,7 @@ fn compaction_event_serializes_expected_shape() { status: CompactionStatus::Completed, codex_error_kind: None, codex_error_http_status_code: None, + usage_limit_window_minutes: None, active_context_tokens_before: 120_000, active_context_tokens_after: 18_000, retained_image_count: None, @@ -98,6 +99,7 @@ fn compaction_event_serializes_expected_shape() { "status": "completed", "codex_error_kind": null, "codex_error_http_status_code": null, + "usage_limit_window_minutes": null, "active_context_tokens_before": 120000, "active_context_tokens_after": 18000, "retained_image_count": null, @@ -194,6 +196,7 @@ async fn compaction_event_ingests_custom_fact() { status: CompactionStatus::Failed, codex_error_kind: Some(CodexErrKind::ContextWindowExceeded), codex_error_http_status_code: None, + usage_limit_window_minutes: None, active_context_tokens_before: 131_000, active_context_tokens_after: 131_000, retained_image_count: None, diff --git a/codex-rs/analytics/src/tests/suite/turns/events_tests.rs b/codex-rs/analytics/src/tests/suite/turns/events_tests.rs index 1c75ef98d1..25dfdd4a0e 100644 --- a/codex-rs/analytics/src/tests/suite/turns/events_tests.rs +++ b/codex-rs/analytics/src/tests/suite/turns/events_tests.rs @@ -38,6 +38,7 @@ use codex_app_server_protocol::ThreadRealtimeStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; use codex_app_server_protocol::WebSearchItem; use codex_protocol::error::CodexErr; +use codex_protocol::error::UsageLimitReachedError; use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::ThreadSource; use pretty_assertions::assert_eq; @@ -148,6 +149,7 @@ fn turn_event_serializes_expected_shape() { turn_error: None, codex_error_kind: None, codex_error_http_status_code: None, + usage_limit_window_minutes: None, steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, @@ -238,6 +240,7 @@ fn turn_event_serializes_expected_shape() { "turn_error": null, "codex_error_kind": null, "codex_error_http_status_code": null, + "usage_limit_window_minutes": null, "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, @@ -811,6 +814,97 @@ async fn turn_lifecycle_emits_failed_turn_event() { payload["event_params"]["codex_error_http_status_code"], json!(null) ); + assert_eq!( + payload["event_params"]["usage_limit_window_minutes"], + json!(null) + ); +} + +#[tokio::test] +async fn turn_event_reports_decisive_usage_limit_window() { + let usage_limit = |limit_window_minutes| { + CodexErr::UsageLimitReached(UsageLimitReachedError { + plan_type: None, + resets_at: None, + rate_limits: None, + promo_message: None, + rate_limit_reached_type: None, + limit_window_minutes, + }) + }; + let cases = [ + ( + usage_limit(Some(300)), + codex_app_server_protocol::CodexErrorInfo::UsageLimitExceeded, + json!(["usageLimitExceeded", "usage_limit_reached", 300]), + ), + ( + usage_limit(Some(10_080)), + codex_app_server_protocol::CodexErrorInfo::UsageLimitExceeded, + json!(["usageLimitExceeded", "usage_limit_reached", 10080]), + ), + ( + usage_limit(None), + codex_app_server_protocol::CodexErrorInfo::UsageLimitExceeded, + json!(["usageLimitExceeded", "usage_limit_reached", null]), + ), + ( + CodexErr::InvalidRequest("invalid request".to_string()), + codex_app_server_protocol::CodexErrorInfo::BadRequest, + json!(["badRequest", "invalid_request", null]), + ), + ]; + + for (error, error_info, expected) in cases { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnCodexError(Box::new( + TurnCodexErrorFact::from_codex_err( + "thread-2".to_string(), + "turn-2".to_string(), + &error, + ), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(error_info), + ))), + &mut out, + ) + .await; + + let [event] = out.as_slice() else { + panic!("expected one turn event"); + }; + let payload = serde_json::to_value(event).expect("serialize turn event"); + let params = &payload["event_params"]; + assert_eq!( + json!([ + params["turn_error"], + params["codex_error_kind"], + params["usage_limit_window_minutes"], + ]), + expected + ); + } } #[tokio::test] diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 560cb26cf7..32a3ca8d8c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -59,6 +59,71 @@ use wiremock::matchers::path_regex; const SERVICE_VERSION: &str = "0.0.0-test"; +#[tokio::test] +async fn usage_limit_window_reaches_turn_analytics() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(ResponseTemplate::new(429).set_body_json(json!({ + "error": { + "type": "usage_limit_reached", + "message": "usage limit reached", + "plan_type": "pro", + "limit_window_minutes": 300 + } + }))) + .expect(1) + .mount(&server) + .await; + + let codex_home = TempDir::new()?; + MockResponsesConfig::new(&server.uri()) + .with_root_config(&format!("chatgpt_base_url = \"{}\"", server.uri())) + .with_provider_config("supports_websockets = false") + .write(codex_home.path())?; + mount_analytics_capture(&server, codex_home.path()).await?; + + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_managed_config() + .build_initialized() + .await?; + let thread = app_server + .start_thread(ThreadStartParams::default()) + .await? + .thread; + let completed = app_server + .start_turn_and_wait_for_completion(TurnStartParams { + thread_id: thread.id, + input: vec![UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + + let event = wait_for_matching_analytics_event(&server, Duration::from_secs(30), |event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == completed.turn.id + }) + .await?; + let params = &event["event_params"]; + assert_eq!( + json!([ + params["status"], + params["turn_error"], + params["codex_error_kind"], + params["usage_limit_window_minutes"], + ]), + json!(["failed", "usageLimitExceeded", "usage_limit_reached", 300]) + ); + + Ok(()) +} + #[tokio::test] async fn guardian_review_turns_and_tools_reach_analytics() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/app-server/tests/suite/v2/compaction.rs b/codex-rs/app-server/tests/suite/v2/compaction.rs index 9f3d1a0e61..2f3f895977 100644 --- a/codex-rs/app-server/tests/suite/v2/compaction.rs +++ b/codex-rs/app-server/tests/suite/v2/compaction.rs @@ -34,9 +34,14 @@ use codex_utils_absolute_path::test_support::PathExt; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; +use serde_json::json; use tempfile::TempDir; use test_case::test_case; use tokio::time::timeout; +use wiremock::ResponseTemplate; + +use super::analytics::mount_analytics_capture; +use super::analytics::wait_for_matching_analytics_event; // macOS and Windows Bazel CI can spend tens of seconds starting app-server // subprocesses or processing test RPCs under load. @@ -54,6 +59,96 @@ enum CompactionRoute { Remote, } +#[test_case(CompactionRoute::Local, Some(300), 429, "usage_limit_reached"; "local five hour")] +#[test_case(CompactionRoute::Remote, Some(10_080), 429, "usage_limit_reached"; "remote weekly")] +#[test_case(CompactionRoute::Local, None, 429, "usage_limit_reached"; "missing window")] +#[test_case(CompactionRoute::Remote, Some(300), 400, "invalid_request"; "other error")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn compaction_error_window_reaches_analytics( + route: CompactionRoute, + window: Option, + status: u16, + expected_error: &str, +) -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let seed = responses::sse(vec![ + responses::ev_assistant_message("seed", "FIRST_REPLY"), + responses::ev_completed_with_tokens("seed", /*total_tokens*/ 120), + ]); + let mut error = json!({ + "type": if status == 429 { "usage_limit_reached" } else { "invalid_request_error" }, + "message": "request failed", + }); + if let Some(window) = window { + error["limit_window_minutes"] = json!(window); + } + let _requests = responses::mount_response_sequence( + &server, + vec![ + responses::sse_response(seed), + ResponseTemplate::new(status).set_body_json(json!({ "error": error })), + ], + ) + .await; + let codex_home = TempDir::new()?; + let mut config = compaction_config(&server.uri(), /*auto_compact_limit*/ 1_000_000) + .with_root_config(&format!("chatgpt_base_url = \"{}\"", server.uri())); + if let CompactionRoute::Remote = route { + config = config + .with_provider_name("OpenAI") + .with_provider_config("requires_openai_auth = true"); + } + config.write(codex_home.path())?; + mount_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .without_managed_config() + .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) + .await?; + let thread_id = start_thread(&mut mcp).await?; + send_turn_and_wait(&mut mcp, &thread_id, "seed history").await?; + let compact_id = mcp + .send_thread_compact_start_request(ThreadCompactStartParams { + thread_id: thread_id.clone(), + }) + .await?; + let _: ThreadCompactStartResponse = + timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(compact_id)).await??; + + let event = wait_for_matching_analytics_event(&server, DEFAULT_READ_TIMEOUT, |event| { + event["event_type"] == "codex_compaction_event" + && event["event_params"]["thread_id"] == thread_id + }) + .await?; + let params = &event["event_params"]; + let implementation = match route { + CompactionRoute::Local => "responses", + CompactionRoute::Remote => "responses_compaction_v2", + }; + let expected_window = if status == 429 { window } else { None }; + assert_eq!( + json!([ + params["implementation"], + params["phase"], + params["status"], + params["codex_error_kind"], + params["usage_limit_window_minutes"], + ]), + json!([ + implementation, + "standalone_turn", + "failed", + expected_error, + expected_window + ]) + ); + Ok(()) +} + #[test_case(CompactionRoute::Local; "local")] #[test_case(CompactionRoute::Remote; "streamed remote")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/codex-rs/codex-api/src/api_bridge.rs b/codex-rs/codex-api/src/api_bridge.rs index 0570bbf0a2..73bc7a787b 100644 --- a/codex-rs/codex-api/src/api_bridge.rs +++ b/codex-rs/codex-api/src/api_bridge.rs @@ -204,6 +204,12 @@ fn map_api_error_details(err: ApiError) -> CodexErr { return CodexErr::UsageLimitReached(UsageLimitReachedError { plan_type: err.error.plan_type, resets_at, + limit_window_minutes: err + .error + .limit_window_minutes + .as_ref() + .and_then(Value::as_u64) + .and_then(|minutes| u16::try_from(minutes).ok()), rate_limits: rate_limits.map(Box::new), promo_message, rate_limit_reached_type, @@ -340,4 +346,5 @@ struct UsageErrorBody { error_type: Option, plan_type: Option, resets_at: Option, + limit_window_minutes: Option, } diff --git a/codex-rs/codex-api/src/api_bridge_tests.rs b/codex-rs/codex-api/src/api_bridge_tests.rs index e8088cebc6..252d2f2ed8 100644 --- a/codex-rs/codex-api/src/api_bridge_tests.rs +++ b/codex-rs/codex-api/src/api_bridge_tests.rs @@ -457,6 +457,48 @@ fn map_api_error_distinguishes_http_quota_errors_from_rate_limits() { } } +#[test] +fn map_api_error_preserves_optional_usage_limit_window() { + let cases = [ + (None, None), + (Some(serde_json::json!(null)), None), + (Some(serde_json::json!(300)), Some(300)), + (Some(serde_json::json!(10080)), Some(10080)), + (Some(serde_json::json!(-1)), None), + (Some(serde_json::json!(65536)), None), + (Some(serde_json::json!("300")), None), + (Some(serde_json::json!({"minutes": 300})), None), + ]; + + for (window, expected) in cases { + let mut body = serde_json::json!({ + "error": { + "type": "usage_limit_reached", + "plan_type": "pro", + } + }); + if let Some(window) = window.as_ref() { + body["error"]["limit_window_minutes"] = window.clone(); + } + let err = map_api_error(ApiError::Transport(TransportError::Http { + retry_after: None, + status: http::StatusCode::TOO_MANY_REQUESTS, + url: None, + headers: None, + body: Some(body.to_string()), + })); + + let CodexErrorDetails::UsageLimitReached(usage_limit) = err.details() else { + panic!("expected usage-limit error for {window:?}, got {err:?}"); + }; + assert_eq!(usage_limit.limit_window_minutes, expected, "{window:?}"); + assert_eq!( + err.to_codex_protocol_error(), + CodexErrorInfo::UsageLimitExceeded + ); + } +} + #[test] fn map_api_error_maps_usage_limit_limit_name_header() { let mut headers = HeaderMap::new(); diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index 56debdc6ad..16a0151690 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -1057,6 +1057,33 @@ mod tests { assert!(body.contains("The usage limit has been reached")); } + #[test] + fn wrapped_websocket_usage_limit_preserves_optional_window() { + for (window, expected) in [(Some(10080), Some(10080)), (None, None)] { + let mut payload = json!({ + "type": "error", + "status": 429, + "error": { + "type": "usage_limit_reached", + "plan_type": "pro" + } + }); + if let Some(window) = window { + payload["error"]["limit_window_minutes"] = json!(window); + } + let payload = payload.to_string(); + let wrapped = parse_wrapped_websocket_error_event(&payload).expect("websocket error"); + let api_error = map_wrapped_websocket_error_event(wrapped, payload).expect("API error"); + let err = crate::api_bridge::map_api_error(api_error); + let codex_protocol::error::CodexErrorDetails::UsageLimitReached(usage_limit) = + err.details() + else { + panic!("expected usage-limit error, got {err:?}"); + }; + assert_eq!(usage_limit.limit_window_minutes, expected); + } + } + #[test] fn parse_wrapped_websocket_error_event_ignores_non_error_payloads() { let payload = json!({ diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index f9fe78dbaf..af14e64eb0 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -475,6 +475,10 @@ impl CompactionAnalyticsAttempt { codex_error_kind: codex_error.map(Into::into), codex_error_http_status_code: codex_error .and_then(CodexErr::http_status_code_value), + usage_limit_window_minutes: codex_error.and_then(|error| match error.details() { + CodexErrorDetails::UsageLimitReached(error) => error.limit_window_minutes, + _ => None, + }), active_context_tokens_before, active_context_tokens_after, retained_image_count, diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs index 9cb401f29f..2f4180acd0 100644 --- a/codex-rs/protocol/src/error.rs +++ b/codex-rs/protocol/src/error.rs @@ -676,6 +676,8 @@ impl std::fmt::Display for RetryLimitReachedError { pub struct UsageLimitReachedError { pub plan_type: Option, pub resets_at: Option>, + /// Server-selected window responsible for the limit, in minutes. + pub limit_window_minutes: Option, pub rate_limits: Option>, pub promo_message: Option, pub rate_limit_reached_type: Option, diff --git a/codex-rs/protocol/src/error_tests.rs b/codex-rs/protocol/src/error_tests.rs index 837feef512..ae46d941bd 100644 --- a/codex-rs/protocol/src/error_tests.rs +++ b/codex-rs/protocol/src/error_tests.rs @@ -156,6 +156,7 @@ fn with_now_override(now: DateTime, f: impl FnOnce() -> T) -> T { #[test] fn usage_limit_reached_error_formats_plus_plan() { let err = UsageLimitReachedError { + limit_window_minutes: None, plan_type: Some(PlanType::Known(KnownPlan::Plus)), resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), @@ -195,6 +196,7 @@ fn usage_limit_reached_error_formats_rate_limit_reached_types() { for (rate_limit_reached_type, expected) in cases { let err = UsageLimitReachedError { + limit_window_minutes: None, plan_type: Some(PlanType::Known(KnownPlan::Plus)), resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), @@ -318,6 +320,7 @@ fn sandbox_denied_reports_exit_code_when_no_output_available() { #[test] fn usage_limit_reached_error_formats_free_plan() { let err = UsageLimitReachedError { + limit_window_minutes: None, plan_type: Some(PlanType::Known(KnownPlan::Free)), resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), @@ -333,6 +336,7 @@ fn usage_limit_reached_error_formats_free_plan() { #[test] fn usage_limit_reached_error_formats_go_plan() { let err = UsageLimitReachedError { + limit_window_minutes: None, plan_type: Some(PlanType::Known(KnownPlan::Go)), resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), @@ -348,6 +352,7 @@ fn usage_limit_reached_error_formats_go_plan() { #[test] fn usage_limit_reached_error_formats_default_when_none() { let err = UsageLimitReachedError { + limit_window_minutes: None, plan_type: None, resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), @@ -367,6 +372,7 @@ fn usage_limit_reached_error_formats_team_plan() { with_now_override(base, move || { let expected_time = format_retry_timestamp(&resets_at); let err = UsageLimitReachedError { + limit_window_minutes: None, plan_type: Some(PlanType::Known(KnownPlan::Team)), resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), @@ -388,6 +394,7 @@ fn usage_limit_reached_error_formats_business_plan_without_reset() { KnownPlan::EnterpriseCbpAutomation, ] { let err = UsageLimitReachedError { + limit_window_minutes: None, plan_type: Some(PlanType::Known(plan)), resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), @@ -404,6 +411,7 @@ fn usage_limit_reached_error_formats_business_plan_without_reset() { #[test] fn usage_limit_reached_error_formats_self_serve_business_prolite_plan() { let err = UsageLimitReachedError { + limit_window_minutes: None, plan_type: Some(PlanType::Known(KnownPlan::SelfServeBusinessProLite)), resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), @@ -419,6 +427,7 @@ fn usage_limit_reached_error_formats_self_serve_business_prolite_plan() { #[test] fn usage_limit_reached_error_formats_self_serve_business_usage_based_plan() { let err = UsageLimitReachedError { + limit_window_minutes: None, plan_type: Some(PlanType::Known(KnownPlan::SelfServeBusinessUsageBased)), resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), @@ -434,6 +443,7 @@ fn usage_limit_reached_error_formats_self_serve_business_usage_based_plan() { #[test] fn usage_limit_reached_error_formats_enterprise_cbp_usage_based_plan() { let err = UsageLimitReachedError { + limit_window_minutes: None, plan_type: Some(PlanType::Known(KnownPlan::EnterpriseCbpUsageBased)), resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), @@ -455,6 +465,7 @@ fn usage_limit_reached_error_formats_default_for_other_plans() { KnownPlan::EduPro, ] { let err = UsageLimitReachedError { + limit_window_minutes: None, plan_type: Some(PlanType::Known(plan)), resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), @@ -475,6 +486,7 @@ fn usage_limit_reached_error_formats_pro_plan_with_reset() { with_now_override(base, move || { let expected_time = format_retry_timestamp(&resets_at); let err = UsageLimitReachedError { + limit_window_minutes: None, plan_type: Some(PlanType::Known(KnownPlan::Pro)), resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), @@ -495,6 +507,7 @@ fn usage_limit_reached_error_hides_upsell_for_non_codex_limit_name() { with_now_override(base, move || { let expected_time = format_retry_timestamp(&resets_at); let err = UsageLimitReachedError { + limit_window_minutes: None, plan_type: Some(PlanType::Known(KnownPlan::Plus)), resets_at: Some(resets_at), rate_limits: Some(Box::new(RateLimitSnapshot { @@ -522,6 +535,7 @@ fn usage_limit_reached_includes_minutes_when_available() { with_now_override(base, move || { let expected_time = format_retry_timestamp(&resets_at); let err = UsageLimitReachedError { + limit_window_minutes: None, plan_type: None, resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), @@ -666,6 +680,7 @@ fn usage_limit_reached_includes_hours_and_minutes() { with_now_override(base, move || { let expected_time = format_retry_timestamp(&resets_at); let err = UsageLimitReachedError { + limit_window_minutes: None, plan_type: Some(PlanType::Known(KnownPlan::Plus)), resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), @@ -687,6 +702,7 @@ fn usage_limit_reached_includes_days_hours_minutes() { with_now_override(base, move || { let expected_time = format_retry_timestamp(&resets_at); let err = UsageLimitReachedError { + limit_window_minutes: None, plan_type: None, resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), @@ -705,6 +721,7 @@ fn usage_limit_reached_less_than_minute() { with_now_override(base, move || { let expected_time = format_retry_timestamp(&resets_at); let err = UsageLimitReachedError { + limit_window_minutes: None, plan_type: None, resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), @@ -723,6 +740,7 @@ fn usage_limit_reached_with_promo_message() { with_now_override(base, move || { let expected_time = format_retry_timestamp(&resets_at); let err = UsageLimitReachedError { + limit_window_minutes: None, plan_type: None, resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())),