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
This commit is contained in:
Charlie Marsh
2026-11-10 00:55:01 +00:00
committed by copyberry
parent 4b345a8de9
commit d9446b4b01
3 changed files with 19 additions and 20 deletions
+16 -20
View File
@@ -91,13 +91,22 @@ pub(crate) fn parse_terminal_provider_error(
) -> Option<ApiError> {
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,
+1
View File
@@ -680,6 +680,7 @@ async fn process_sse_with_treatment(
if matches!(
error,
ApiError::FlexUnavailable
| ApiError::InvalidRequest { .. }
| ApiError::SessionBudgetExceeded
| ApiError::AccessProgramNotEnabled { .. }
| ApiError::ContentPolicyViolation { .. }
@@ -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",