diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 8cd1bca5..3a0bcb9b 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -447,6 +447,8 @@ impl ModelClient { return Err(StreamAttemptError::Fatal(codex_err)); } else if error.r#type.as_deref() == Some("usage_not_included") { return Err(StreamAttemptError::Fatal(CodexErr::UsageNotIncluded)); + } else if is_quota_exceeded_error(&error) { + return Err(StreamAttemptError::Fatal(CodexErr::QuotaExceeded)); } } } @@ -844,6 +846,8 @@ async fn process_sse( Ok(error) => { if is_context_window_error(&error) { response_error = Some(CodexErr::ContextWindowExceeded); + } else if is_quota_exceeded_error(&error) { + response_error = Some(CodexErr::QuotaExceeded); } else { let delay = try_parse_retry_after(&error); let message = error.message.clone().unwrap_or_default(); @@ -975,6 +979,10 @@ fn is_context_window_error(error: &Error) -> bool { error.code.as_deref() == Some("context_length_exceeded") } +fn is_quota_exceeded_error(error: &Error) -> bool { + error.code.as_deref() == Some("insufficient_quota") +} + #[cfg(test)] mod tests { use super::*; @@ -1307,6 +1315,41 @@ mod tests { } } + #[tokio::test] + async fn quota_exceeded_error_is_fatal() { + let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_fatal_quota","object":"response","created_at":1759771626,"status":"failed","background":false,"error":{"code":"insufficient_quota","message":"You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors."},"incomplete_details":null}}"#; + + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + let provider = ModelProviderInfo { + name: "test".to_string(), + base_url: Some("https://test.com".to_string()), + env_key: Some("TEST_API_KEY".to_string()), + env_key_instructions: None, + experimental_bearer_token: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: Some(0), + stream_max_retries: Some(0), + stream_idle_timeout_ms: Some(1000), + requires_openai_auth: false, + }; + + let otel_event_manager = otel_event_manager(); + + let events = collect_events(&[sse1.as_bytes()], provider, otel_event_manager).await; + + assert_eq!(events.len(), 1); + + match &events[0] { + Err(err @ CodexErr::QuotaExceeded) => { + assert_eq!(err.to_string(), CodexErr::QuotaExceeded.to_string()); + } + other => panic!("unexpected quota exceeded event: {other:?}"), + } + } + // ──────────────────────────── // Table-driven test from `main` // ──────────────────────────── diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 64cd0023..70728ba6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1928,6 +1928,7 @@ async fn run_turn( return Err(CodexErr::UsageLimitReached(e)); } Err(CodexErr::UsageNotIncluded) => return Err(CodexErr::UsageNotIncluded), + Err(e @ CodexErr::QuotaExceeded) => return Err(e), Err(e @ CodexErr::RefreshTokenFailed(_)) => return Err(e), Err(e) => { // Use the configured provider-specific stream retry budget. diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 6ca8970e..10e936f1 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -109,6 +109,9 @@ pub enum CodexErr { #[error("{0}")] ConnectionFailed(ConnectionFailedError), + #[error("Quota exceeded. Check your plan and billing details.")] + QuotaExceeded, + #[error( "To use Codex with your ChatGPT plan, upgrade to Plus: https://openai.com/chatgpt/pricing." )] diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index bec4f942..a8841674 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -26,6 +26,7 @@ mod model_overrides; mod model_tools; mod otel; mod prompt_caching; +mod quota_exceeded; mod read_file; mod resume; mod review; diff --git a/codex-rs/core/tests/suite/quota_exceeded.rs b/codex-rs/core/tests/suite/quota_exceeded.rs new file mode 100644 index 00000000..4cb634c1 --- /dev/null +++ b/codex-rs/core/tests/suite/quota_exceeded.rs @@ -0,0 +1,75 @@ +use std::time::Duration; + +use anyhow::Result; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event_with_timeout; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn quota_exceeded_emits_single_error_event() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex(); + + mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + json!({ + "type": "response.failed", + "response": { + "id": "resp-1", + "error": { + "code": "insufficient_quota", + "message": "You exceeded your current quota, please check your plan and billing details." + } + } + }), + ]), + ) + .await; + + let test = builder.build(&server).await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "quota?".into(), + }], + }) + .await + .unwrap(); + + let mut error_events = 0; + + loop { + let event = + wait_for_event_with_timeout(&test.codex, |_| true, Duration::from_secs(5)).await; + + match event { + EventMsg::Error(err) => { + error_events += 1; + assert_eq!( + err.message, + "Quota exceeded. Check your plan and billing details." + ); + } + EventMsg::TaskComplete(_) => break, + _ => {} + } + } + + assert_eq!(error_events, 1, "expected exactly one Codex:Error event"); + + Ok(()) +}