Treat model-not-found provider errors as terminal failures (#56445)

## Why

Provider errors with code `model_not_found` should end the turn immediately and preserve the provider's message instead of triggering retries.

## What changed

- Recognize `model_not_found` in HTTP 400, 403, and 404 responses and in streamed `error` and `response.failed` events.
- Preserve the provider message and any available HTTP status, and expose `modelNotFound` through the app-server protocol and generated schemas and SDK types.

## Testing

Add coverage for HTTP classification, statusless stream errors, and terminal failures without retries across HTTP, SSE, and WebSocket responses. Verify that `thread/read` preserves the error reported by the live notification.

GitOrigin-RevId: 88f824ac03c8c9669915b1e40c67b76c08326fbe
This commit is contained in:
Charlie Marsh
2026-10-23 11:43:14 +00:00
committed by copyberry
parent ca9d103f39
commit 60adfa8513
39 changed files with 190 additions and 67 deletions
+4 -2
View File
@@ -4,7 +4,7 @@ use crate::common::ResponseStream;
use crate::common::SafetyBuffering;
use crate::common::SafetyBufferingTreatment;
use crate::error::ApiError;
use crate::error::parse_flex_unavailable;
use crate::error::parse_terminal_provider_error;
use crate::rate_limits::parse_all_rate_limits;
use crate::safety_buffering::treatment_from_headers;
use crate::telemetry::SseTelemetry;
@@ -347,7 +347,9 @@ pub fn process_responses_event(
) -> std::result::Result<Option<ResponseEvent>, ResponsesEventError> {
match event.kind.as_str() {
"error" => {
if let Some(error) = event.error.as_ref().and_then(parse_flex_unavailable) {
if let Some(error) =
parse_terminal_provider_error(event.error.as_ref(), /*status*/ None)
{
return Err(ResponsesEventError::Api(error));
}
}
@@ -2,7 +2,7 @@
//! Only rate-limit codes derive retry delays from plaintext messages.
use crate::error::ApiError;
use crate::error::parse_flex_unavailable;
use crate::error::parse_terminal_provider_error;
use crate::responses_headers::json_headers_to_http_headers;
use codex_http_client::RetryAfter;
use codex_protocol::protocol::MisalignmentErrorDetails;
@@ -29,7 +29,7 @@ pub(super) fn parse_failed_response(response: Option<Value>) -> ApiError {
.and_then(|error| error.get("headers"))
.and_then(Value::as_object)
.and_then(|headers| RetryAfter::from_headers(&json_headers_to_http_headers(headers)));
if let Some(error) = error.and_then(parse_flex_unavailable) {
if let Some(error) = parse_terminal_provider_error(error, /*status*/ None) {
return error;
}
let Some(error) = error.and_then(|error| serde_json::from_value::<Error>(error.clone()).ok())
@@ -61,6 +61,25 @@ async fn flex_failure_with_retry_header_ends_stream_immediately() {
}
}
#[test]
fn statusless_model_errors_have_no_http_status() {
let error = json!({"code": "model_not_found"});
for event in [
json!({"type": "error", "error": error}),
json!({"type": "response.failed", "response": {"error": error}}),
] {
let event = serde_json::from_value(event).unwrap();
let error = map_api_error(process_responses_event(event).unwrap_err().into_api_error());
assert_eq!(
(
error.to_codex_protocol_error(),
error.http_status_code_value()
),
(CodexErrorInfo::ModelNotFound, None)
);
}
}
/// Error headers beat rate-limit message advice; invalid ones fall back.
#[tokio::test(start_paused = true)]
async fn rate_limit_plaintext_loses_to_valid_error_header() {