@@ -4,7 +4,7 @@ use tokio_stream::StreamExt;
44use crate :: reasoning:: { Reasoning , ReasoningFull } ;
55use crate :: {
66 ArcSender , ChatCompletionMessage , ChatCompletionMessageFull , ChatResponse , ChatResponseContent ,
7- ToolCallFull , ToolCallPart , Usage ,
7+ FinishReason , ToolCallFull , ToolCallPart , Usage ,
88} ;
99
1010/// Extension trait for ResultStream to provide additional functionality
@@ -259,6 +259,14 @@ impl ResultStreamExt<anyhow::Error> for crate::BoxStream<ChatCompletionMessage,
259259 // Get phase from the last message that has one
260260 let phase = messages. iter ( ) . rev ( ) . find_map ( |message| message. phase ) ;
261261
262+ // A refusal/content-filter finish is deterministic - the provider
263+ // will return the same result for the same request - so it must not
264+ // enter the retry loop (issue #3624). Tool calls alongside a
265+ // content-filter finish are left to the normal flow.
266+ if finish_reason == Some ( FinishReason :: ContentFilter ) && tool_calls. is_empty ( ) {
267+ return Err ( crate :: Error :: Refusal . into ( ) ) ;
268+ }
269+
262270 // Check for empty completion - map to retryable error for retry
263271 if content. trim ( ) . is_empty ( )
264272 && tool_calls. is_empty ( )
@@ -1221,6 +1229,43 @@ mod tests {
12211229 assert_eq ! ( actual, expected) ;
12221230 }
12231231
1232+ #[ tokio:: test]
1233+ async fn test_into_full_refusal_is_non_retryable_error ( ) {
1234+ // A refusal/content-filter finish is deterministic: retrying the same
1235+ // request yields the same refusal. It must NOT surface as the
1236+ // retryable EmptyCompletion error (issue #3624).
1237+ let messages = vec ! [ Ok ( ChatCompletionMessage :: assistant( Content :: part( "" ) )
1238+ . finish_reason( FinishReason :: ContentFilter ) ) ] ;
1239+ let fixture: BoxStream < ChatCompletionMessage , anyhow:: Error > =
1240+ Box :: pin ( tokio_stream:: iter ( messages) ) ;
1241+
1242+ let actual = fixture. into_full ( false ) . await . unwrap_err ( ) ;
1243+
1244+ let domain_error = actual. downcast_ref :: < crate :: Error > ( ) . unwrap ( ) ;
1245+ assert ! ( matches!( domain_error, crate :: Error :: Refusal ) ) ;
1246+ }
1247+
1248+ #[ tokio:: test]
1249+ async fn test_into_full_refusal_with_partial_content_is_error ( ) {
1250+ // Mid-stream refusals can arrive after partial output. The partial
1251+ // text has already been streamed to the UI; the turn still must end
1252+ // with the refusal error rather than looping.
1253+ let messages = vec ! [
1254+ Ok ( ChatCompletionMessage :: assistant( Content :: part(
1255+ "I was about to say" ,
1256+ ) ) ) ,
1257+ Ok ( ChatCompletionMessage :: assistant( Content :: part( "" ) )
1258+ . finish_reason( FinishReason :: ContentFilter ) ) ,
1259+ ] ;
1260+ let fixture: BoxStream < ChatCompletionMessage , anyhow:: Error > =
1261+ Box :: pin ( tokio_stream:: iter ( messages) ) ;
1262+
1263+ let actual = fixture. into_full ( false ) . await . unwrap_err ( ) ;
1264+
1265+ let domain_error = actual. downcast_ref :: < crate :: Error > ( ) . unwrap ( ) ;
1266+ assert ! ( matches!( domain_error, crate :: Error :: Refusal ) ) ;
1267+ }
1268+
12241269 #[ tokio:: test]
12251270 async fn test_into_full_empty_completion_with_tool_calls_should_not_error ( ) {
12261271 // Fixture: Create a stream with empty content but with tool calls
0 commit comments