From 968cbe2bce7cefc312bcaeec9073ddcf0327f1c4 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 12 Jul 2026 16:55:40 +0300 Subject: [PATCH] codex: surface context overflow for compaction Return context-window failures as HTTP 413 request_too_large responses so Claude Code can compact and retry instead of treating them as upstream errors. Apply the mapping to buffered streaming, non-streaming accumulation, early WebSocket failures, and Codex transport errors while retaining upstream status and retry metadata for other failures. --- src/providers/codex/client.rs | 2 +- src/providers/codex/mod.rs | 37 +++++++++++++++++------------- tests/smoke_cutover.rs | 42 +++++++++++++++++++++++++++++++++-- 3 files changed, 63 insertions(+), 18 deletions(-) diff --git a/src/providers/codex/client.rs b/src/providers/codex/client.rs index 73be78a8..07f7231c 100644 --- a/src/providers/codex/client.rs +++ b/src/providers/codex/client.rs @@ -1467,7 +1467,7 @@ mod tests { fn status_error_preserves_buffered_websocket_event_message() { let error = codex_status_error( CodexResponse { - body: br#"data: {"type":"error","error":{"status":400,"message":"bad request"}}\n\n"# + body: b"data: {\"type\":\"error\",\"error\":{\"status\":400,\"message\":\"bad request\"}}\n\n" .to_vec(), status: 400, headers: Vec::new(), diff --git a/src/providers/codex/mod.rs b/src/providers/codex/mod.rs index 1506b21a..e98120b5 100644 --- a/src/providers/codex/mod.rs +++ b/src/providers/codex/mod.rs @@ -175,11 +175,9 @@ impl Provider for CodexProvider { Ok(b) => b, Err(e) => { clear_continuation(ctx.session_id.as_deref()); - return json_error( - StatusCode::BAD_GATEWAY, - "api_error", - format!("Stream translation error: {e}"), - ); + return map_codex_failure_to_response(&format!( + "Stream translation error: {e}" + )); } }; if let Some(monitor) = ctx.monitor.as_ref() { @@ -229,11 +227,7 @@ impl Provider for CodexProvider { } Err(e) => { clear_continuation(ctx.session_id.as_deref()); - json_error( - StatusCode::BAD_GATEWAY, - "api_error", - format!("Accumulation error: {e}"), - ) + map_codex_failure_to_response(&format!("Accumulation error: {e}")) } } } @@ -445,11 +439,7 @@ async fn live_stream_response_once( }; } clear_continuation(ctx.session_id.as_deref()); - return LiveStreamStart::Response(json_error( - StatusCode::BAD_GATEWAY, - "api_error", - message, - )); + return LiveStreamStart::Response(map_codex_failure_to_response(&message)); } }; if !chunk.is_empty() { @@ -753,6 +743,11 @@ fn update_continuation_from_upstream( // --------------------------------------------------------------------------- fn map_codex_error_to_response(err: &client::CodexError) -> Response { + let message = codex_error_message(err); + if is_context_window_overflow(message) { + return map_codex_failure_to_response(message); + } + match err.status { 401 | 403 => json_error( StatusCode::UNAUTHORIZED, @@ -799,6 +794,18 @@ fn map_codex_error_to_response(err: &client::CodexError) -> Response { } } +fn map_codex_failure_to_response(message: &str) -> Response { + if is_context_window_overflow(message) { + json_error(StatusCode::PAYLOAD_TOO_LARGE, "request_too_large", message) + } else { + json_error(StatusCode::BAD_GATEWAY, "api_error", message) + } +} + +fn is_context_window_overflow(message: &str) -> bool { + message.to_ascii_lowercase().contains("context window") +} + fn codex_error_message(err: &client::CodexError) -> &str { err.detail.as_deref().unwrap_or({ if err.status == 0 { diff --git a/tests/smoke_cutover.rs b/tests/smoke_cutover.rs index 7bfa1672..aef8a1ea 100644 --- a/tests/smoke_cutover.rs +++ b/tests/smoke_cutover.rs @@ -653,6 +653,42 @@ async fn smoke_codex_http_messages_uses_mock_upstream() { assert_eq!(sent["stream"], true); } +#[allow(clippy::await_holding_lock)] +#[tokio::test] +async fn smoke_codex_http_context_window_error_requests_compaction() { + let _guard = env_lock(); + let config = TempDir::new().unwrap(); + write_auth(config.path(), "codex"); + + let upstream = spawn_http_upstream(|_body: Value| { + "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"message\":\"input exceeds context window\"}}}\n\n" + .as_bytes() + .to_vec() + }) + .await; + + let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path()); + let _base_url_env = EnvGuard::set("CCP_CODEX_BASE_URL", &upstream); + let _transport_env = EnvGuard::set("CCP_CODEX_TRANSPORT", "http"); + let response = call_messages("gpt-5.5").await; + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + + assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); + let value: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(value["type"], "error"); + assert_eq!(value["error"]["type"], "request_too_large"); + assert!( + value["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("input exceeds context window")), + "response body: {}", + String::from_utf8_lossy(&body) + ); +} + #[allow(clippy::await_holding_lock)] #[tokio::test] async fn smoke_codex_http_traffic_capture_writes_upstream_artifacts() { @@ -887,7 +923,7 @@ async fn smoke_codex_websocket_stream_returns_delta_before_terminal() { #[allow(clippy::await_holding_lock)] #[tokio::test(flavor = "multi_thread")] -async fn smoke_codex_websocket_stream_returns_json_for_early_error() { +async fn smoke_codex_websocket_context_window_error_requests_compaction() { let _guard = env_lock(); let config = TempDir::new().unwrap(); write_auth(config.path(), "codex"); @@ -913,11 +949,13 @@ async fn smoke_codex_websocket_stream_returns_json_for_early_error() { assert_eq!( status, - StatusCode::BAD_GATEWAY, + StatusCode::PAYLOAD_TOO_LARGE, "response body: {}", String::from_utf8_lossy(&body) ); let value: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(value["type"], "error"); + assert_eq!(value["error"]["type"], "request_too_large"); assert_eq!(value["error"]["message"], "input exceeds context window"); }