@@ -41,6 +41,8 @@ use self::translate::reducer::finish_metadata_from_upstream;
4141use self :: translate:: request:: { TranslateOptions , has_hosted_web_search, translate_request} ;
4242
4343const MAX_RETRYABLE_LIVE_STREAM_RETRIES : u32 = 10 ;
44+ const MAX_EMPTY_COMPLETION_RETRIES : u32 = 10 ;
45+ const EMPTY_CODEX_COMPLETION_DETAIL : & str = "empty_codex_completion" ;
4446use self :: translate:: stream:: translate_stream_bytes_with_traffic;
4547
4648// ---------------------------------------------------------------------------
@@ -155,15 +157,37 @@ impl Provider for CodexProvider {
155157 . await ;
156158 }
157159
158- let upstream = match client
159- . post_codex ( & translated, & ctx, Some ( & continuation) )
160- . await
161- {
162- Ok ( r) => r,
163- Err ( e) => {
160+ let mut continuation = Some ( continuation) ;
161+ let mut attempt = 0_u32 ;
162+ let upstream = loop {
163+ let response = match client
164+ . post_codex ( & translated, & ctx, continuation. as_ref ( ) )
165+ . await
166+ {
167+ Ok ( r) => r,
168+ Err ( e) => {
169+ abort_continuation ( ctx. session_id . as_deref ( ) , turn_id) ;
170+ return map_codex_error_to_response ( & e) ;
171+ }
172+ } ;
173+ if !is_empty_codex_success_completion ( & response. body ) {
174+ break response;
175+ }
176+ // A successful terminal event with no output would translate into
177+ // an empty end_turn; retry with full context instead.
178+ let error = empty_buffered_completion_error ( ) ;
179+ drop_live_continuation_for_retry ( & mut continuation) ;
180+ if attempt >= MAX_EMPTY_COMPLETION_RETRIES {
181+ abort_continuation ( ctx. session_id . as_deref ( ) , turn_id) ;
182+ return map_codex_error_to_response ( & error) ;
183+ }
184+ let delay = compute_backoff_delay ( attempt, None ) ;
185+ if delay. exceeds_budget {
164186 abort_continuation ( ctx. session_id . as_deref ( ) , turn_id) ;
165- return map_codex_error_to_response ( & e ) ;
187+ return map_codex_error_to_response ( & error ) ;
166188 }
189+ attempt += 1 ;
190+ sleep ( delay. wait_ms ) . await ;
167191 } ;
168192
169193 if want_stream {
@@ -662,6 +686,45 @@ where
662686 ( headers, Body :: from_stream ( stream) ) . into_response ( )
663687}
664688
689+ fn empty_buffered_completion_error ( ) -> client:: CodexError {
690+ client:: CodexError {
691+ status : 503 ,
692+ message : "Codex completed without producing output" . to_string ( ) ,
693+ detail : Some ( EMPTY_CODEX_COMPLETION_DETAIL . to_string ( ) ) ,
694+ retry_after : None ,
695+ origin : match config:: codex_transport ( ) {
696+ config:: CodexTransport :: Http => client:: CodexErrorOrigin :: BufferedHttp ,
697+ _ => client:: CodexErrorOrigin :: BufferedWebSocket ,
698+ } ,
699+ }
700+ }
701+
702+ /// True when the buffered upstream body ended in a successful terminal event
703+ /// without ever producing semantic output (text, thinking, tool, web search).
704+ fn is_empty_codex_success_completion ( upstream_sse : & [ u8 ] ) -> bool {
705+ use self :: translate:: reducer:: { ReducerEvent , TERM_COMPLETED , TERM_DONE } ;
706+
707+ let Ok ( events) = self :: translate:: reducer:: reduce_upstream_bytes ( upstream_sse) else {
708+ return false ;
709+ } ;
710+ let mut saw_success_terminal = false ;
711+ for event in & events {
712+ match event {
713+ ReducerEvent :: TextStart { .. }
714+ | ReducerEvent :: ThinkingStart { .. }
715+ | ReducerEvent :: ToolStart { .. }
716+ | ReducerEvent :: WebSearch { .. } => return false ,
717+ ReducerEvent :: Finish { terminal_type, .. } => {
718+ if terminal_type == TERM_COMPLETED || terminal_type == TERM_DONE {
719+ saw_success_terminal = true ;
720+ }
721+ }
722+ _ => { }
723+ }
724+ }
725+ saw_success_terminal
726+ }
727+
665728fn is_codex_terminal_event ( payload : & serde_json:: Value ) -> bool {
666729 matches ! (
667730 payload. get( "type" ) . and_then( |v| v. as_str( ) ) ,
@@ -769,6 +832,9 @@ fn map_codex_error_to_response(err: &client::CodexError) -> Response {
769832 if is_context_window_overflow ( message) {
770833 return map_codex_failure_to_response ( message) ;
771834 }
835+ if err. detail . as_deref ( ) == Some ( EMPTY_CODEX_COMPLETION_DETAIL ) {
836+ return json_error ( StatusCode :: SERVICE_UNAVAILABLE , "api_error" , & err. message ) ;
837+ }
772838
773839 match err. status {
774840 401 | 403 => json_error (
@@ -940,6 +1006,93 @@ fn format_auth_saved_output(auth_path: &str, account_id: Option<&str>) -> String
9401006mod tests {
9411007 use super :: * ;
9421008
1009+ fn upstream_sse ( events : & [ serde_json:: Value ] ) -> Vec < u8 > {
1010+ let mut bytes = Vec :: new ( ) ;
1011+ for event in events {
1012+ bytes. extend_from_slice ( format ! ( "data: {event}\n \n " ) . as_bytes ( ) ) ;
1013+ }
1014+ bytes
1015+ }
1016+
1017+ #[ test]
1018+ fn terminal_only_completed_upstream_is_empty_completion ( ) {
1019+ let body = upstream_sse ( & [ serde_json:: json!( {
1020+ "type" : "response.completed" ,
1021+ "response" : { "id" : "resp_1" , "status" : "completed" , "incomplete_details" : null, "usage" : { "input_tokens" : 5 , "output_tokens" : 0 } }
1022+ } ) ] ) ;
1023+ assert ! ( is_empty_codex_success_completion( & body) ) ;
1024+ }
1025+
1026+ #[ test]
1027+ fn terminal_only_done_upstream_is_empty_completion ( ) {
1028+ let body = upstream_sse ( & [ serde_json:: json!( {
1029+ "type" : "response.done" ,
1030+ "response" : { "id" : "resp_1" , "usage" : { } }
1031+ } ) ] ) ;
1032+ assert ! ( is_empty_codex_success_completion( & body) ) ;
1033+ }
1034+
1035+ #[ test]
1036+ fn upstream_with_text_is_not_empty_completion ( ) {
1037+ let body = upstream_sse ( & [
1038+ serde_json:: json!( {
1039+ "type" : "response.output_item.added" ,
1040+ "output_index" : 0 ,
1041+ "item" : { "type" : "message" , "id" : "msg_1" }
1042+ } ) ,
1043+ serde_json:: json!( {
1044+ "type" : "response.output_text.delta" ,
1045+ "output_index" : 0 ,
1046+ "delta" : "hello"
1047+ } ) ,
1048+ serde_json:: json!( {
1049+ "type" : "response.output_item.done" ,
1050+ "output_index" : 0 ,
1051+ "item" : { "type" : "message" }
1052+ } ) ,
1053+ serde_json:: json!( {
1054+ "type" : "response.completed" ,
1055+ "response" : { "id" : "resp_1" , "usage" : { } }
1056+ } ) ,
1057+ ] ) ;
1058+ assert ! ( !is_empty_codex_success_completion( & body) ) ;
1059+ }
1060+
1061+ #[ test]
1062+ fn upstream_with_tool_call_is_not_empty_completion ( ) {
1063+ let body = upstream_sse ( & [
1064+ serde_json:: json!( {
1065+ "type" : "response.output_item.added" ,
1066+ "output_index" : 0 ,
1067+ "item" : { "type" : "function_call" , "call_id" : "call_1" , "name" : "Read" , "arguments" : "" }
1068+ } ) ,
1069+ serde_json:: json!( {
1070+ "type" : "response.output_item.done" ,
1071+ "output_index" : 0 ,
1072+ "item" : { "type" : "function_call" , "call_id" : "call_1" , "name" : "Read" , "arguments" : "{}" }
1073+ } ) ,
1074+ serde_json:: json!( {
1075+ "type" : "response.completed" ,
1076+ "response" : { "id" : "resp_1" , "usage" : { } }
1077+ } ) ,
1078+ ] ) ;
1079+ assert ! ( !is_empty_codex_success_completion( & body) ) ;
1080+ }
1081+
1082+ #[ test]
1083+ fn terminal_only_incomplete_upstream_is_not_empty_completion ( ) {
1084+ let body = upstream_sse ( & [ serde_json:: json!( {
1085+ "type" : "response.incomplete" ,
1086+ "response" : { "id" : "resp_1" , "incomplete_details" : { "reason" : "max_output_tokens" } , "usage" : { } }
1087+ } ) ] ) ;
1088+ assert ! ( !is_empty_codex_success_completion( & body) ) ;
1089+ }
1090+
1091+ #[ test]
1092+ fn upstream_without_terminal_event_is_not_empty_completion ( ) {
1093+ assert ! ( !is_empty_codex_success_completion( & upstream_sse( & [ ] ) ) ) ;
1094+ }
1095+
9431096 fn request_with_tools ( tools : serde_json:: Value ) -> MessagesRequest {
9441097 serde_json:: from_value ( serde_json:: json!( {
9451098 "model" : "gpt-5.6-luna" ,
0 commit comments