From d9446b4b01aa1b9aa8c918014965ea8c68a0165d Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 9 Nov 2026 23:15:19 +0000 Subject: [PATCH] Treat invalid access program stream errors as terminal (#60481) ## Why An `invalid_access_program` or `unsupported_access_program` streaming error should end the stream immediately, even if a later `response.completed` event arrives or the error includes a retry header. ## What changed Map both error codes to `ApiError::InvalidRequest` and stop processing the response stream on invalid requests. Preserve a nonblank provider message, falling back to an explanation that the requested access program cannot be used with the model. ## Testing Extend the terminal-error test to cover both codes in `error` and `response.failed` events, verifying that retry headers are ignored and the stream ends before a later completion event. GitOrigin-RevId: 5431a2babec64eae5cdf6c138b98e91e84778e79 --- codex-rs/codex-api/src/error.rs | 36 +++++++++---------- codex-rs/codex-api/src/sse/responses.rs | 1 + .../src/sse/responses_error_tests.rs | 2 ++ 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/codex-rs/codex-api/src/error.rs b/codex-rs/codex-api/src/error.rs index 349781cda6..287d01fce5 100644 --- a/codex-rs/codex-api/src/error.rs +++ b/codex-rs/codex-api/src/error.rs @@ -91,13 +91,22 @@ pub(crate) fn parse_terminal_provider_error( ) -> Option { let error = error?; let code = error.get("code")?.as_str()?; + let message = error + .get("message") + .and_then(Value::as_str) + .filter(|message| !message.trim().is_empty()); match (code, status) { + // A non-streaming 400 already follows the ordinary invalid-request path. + ("invalid_access_program" | "unsupported_access_program", None) => { + Some(ApiError::InvalidRequest { + message: message + .unwrap_or("The requested access program cannot be used with this model.") + .to_owned(), + }) + } ("content_policy_violation", None | Some(StatusCode::BAD_REQUEST)) => { Some(ApiError::ContentPolicyViolation { - message: error - .get("message") - .and_then(Value::as_str) - .filter(|message| !message.trim().is_empty()) + message: message .unwrap_or("The input was rejected by the content policy.") .to_owned(), status, @@ -105,10 +114,7 @@ pub(crate) fn parse_terminal_provider_error( } // HTTP 403s retain their request ID and headers in the API bridge. ("access_program_not_enabled", None) => Some(ApiError::AccessProgramNotEnabled { - message: error - .get("message") - .and_then(Value::as_str) - .filter(|message| !message.trim().is_empty()) + message: message .unwrap_or("The requested access program is not enabled.") .to_owned(), }), @@ -125,12 +131,7 @@ pub(crate) fn parse_terminal_provider_error( None | Some(StatusCode::BAD_REQUEST), ) => Some(ApiError::InvalidAudioInput { per_recording: code != "audio_too_large", - message: error - .get("message") - .and_then(Value::as_str) - .filter(|message| !message.trim().is_empty()) - .unwrap_or("Invalid audio input.") - .to_owned(), + message: message.unwrap_or("Invalid audio input.").to_owned(), }), ("flex_unavailable", None | Some(StatusCode::TOO_MANY_REQUESTS)) => { Some(ApiError::FlexUnavailable) @@ -139,12 +140,7 @@ pub(crate) fn parse_terminal_provider_error( "model_not_found", None | Some(StatusCode::BAD_REQUEST | StatusCode::FORBIDDEN | StatusCode::NOT_FOUND), ) => Some(ApiError::ModelNotFound { - message: error - .get("message") - .and_then(Value::as_str) - .filter(|message| !message.trim().is_empty()) - .unwrap_or(code) - .to_owned(), + message: message.unwrap_or(code).to_owned(), status, }), _ => None, diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 7768eec16c..a4ef2b6aea 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -680,6 +680,7 @@ async fn process_sse_with_treatment( if matches!( error, ApiError::FlexUnavailable + | ApiError::InvalidRequest { .. } | ApiError::SessionBudgetExceeded | ApiError::AccessProgramNotEnabled { .. } | ApiError::ContentPolicyViolation { .. } diff --git a/codex-rs/codex-api/src/sse/responses_error_tests.rs b/codex-rs/codex-api/src/sse/responses_error_tests.rs index 7bf11b10f9..09d8ce9015 100644 --- a/codex-rs/codex-api/src/sse/responses_error_tests.rs +++ b/codex-rs/codex-api/src/sse/responses_error_tests.rs @@ -115,6 +115,8 @@ async fn terminal_failure_with_retry_header_ends_stream_immediately() { for (code, expected) in [ ("flex_unavailable", CodexErrorInfo::FlexUnavailable), ("budget_reached", CodexErrorInfo::SessionBudgetExceeded), + ("invalid_access_program", CodexErrorInfo::Other), + ("unsupported_access_program", CodexErrorInfo::Other), ("content_policy_violation", CodexErrorInfo::InvalidPrompt), ( "access_program_not_enabled",