diff --git a/crates/protocol/src/stream.rs b/crates/protocol/src/stream.rs index c59a66f77..76b90a7df 100644 --- a/crates/protocol/src/stream.rs +++ b/crates/protocol/src/stream.rs @@ -506,7 +506,9 @@ fn stop_reason_from_str(reason: Option<&str>) -> StopReason { match reason { Some("length" | "max_tokens") => StopReason::MaxTokens, Some("tool_calls" | "function_call" | "tool_use") => StopReason::ToolUse, - Some("content_filter") => StopReason::ContentFilter, + // Anthropic spells a content-policy stop `refusal`; the buffered codec + // already maps it (anthropic/buffered.rs, map_anthropic_stop_reason). + Some("content_filter" | "refusal") => StopReason::ContentFilter, Some("stop" | "end_turn" | "stop_sequence") | None => StopReason::EndTurn, Some(_) => StopReason::Unknown, } @@ -563,6 +565,33 @@ mod tests { ); } + #[test] + fn stop_reason_spellings_normalize_the_same_streamed_as_buffered() { + // A stop reason has to mean the same thing whether the response arrived + // buffered or was folded out of a stream. The expectations here are the + // ones the codecs apply to a buffered body. + for (reason, expected) in [ + ("length", StopReason::MaxTokens), + ("max_tokens", StopReason::MaxTokens), + ("tool_calls", StopReason::ToolUse), + ("tool_use", StopReason::ToolUse), + ("content_filter", StopReason::ContentFilter), + ("refusal", StopReason::ContentFilter), + ("stop", StopReason::EndTurn), + ("end_turn", StopReason::EndTurn), + ] { + let agg = fold(vec![LlmResponseChunk::MessageStop { + reason: Some(reason.to_string()), + }]); + + assert_eq!( + agg.outputs.first().and_then(|output| output.stop_reason), + Some(expected), + "stop reason {reason:?}" + ); + } + } + #[test] fn aggregates_normalized_chunks_inside_stream_event() { let response = LlmResponse::Stream(Box::pin(stream::iter([Ok(