diff --git a/crates/protocol/src/stream.rs b/crates/protocol/src/stream.rs index e51b68dce..ca13c4907 100644 --- a/crates/protocol/src/stream.rs +++ b/crates/protocol/src/stream.rs @@ -433,7 +433,7 @@ 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, + Some("content_filter" | "refusal") => StopReason::ContentFilter, Some("stop" | "end_turn" | "stop_sequence") | None => StopReason::EndTurn, Some(_) => StopReason::Unknown, } @@ -490,6 +490,15 @@ mod tests { ); } + #[test] + fn folds_anthropic_refusal_as_content_filter() { + let agg = fold(vec![LlmResponseChunk::MessageStop { + reason: Some("refusal".to_string()), + }]); + + assert_eq!(agg.outputs[0].stop_reason, Some(StopReason::ContentFilter)); + } + #[test] fn aggregates_normalized_chunks_inside_stream_event() { let response = LlmResponse::Stream(Box::pin(stream::iter([Ok( diff --git a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs index 7a8e79c9e..b52475e3d 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs @@ -340,17 +340,20 @@ impl FormatCodec for AnthropicMessagesCodec { let content = output .map(|output| encode_anthropic_content(&output.content)) .unwrap_or_else(|| vec![json!({"type": "text", "text": ""})]); + let normalized_stop_reason = output.and_then(|output| output.stop_reason); let body = json!({ "id": response.id.clone().unwrap_or_else(|| "msg_switchyard".to_string()), "type": "message", "role": "assistant", "model": response.model.clone().unwrap_or_else(|| "unknown".to_string()), "content": content, - "stop_reason": output - .and_then(|output| output.stop_reason) + "stop_reason": normalized_stop_reason .map(anthropic_stop_reason) .unwrap_or("end_turn"), "stop_sequence": Value::Null, + "stop_details": normalized_stop_reason + .map(anthropic_stop_details) + .unwrap_or(Value::Null), "usage": encode_anthropic_usage(&response.usage), }); Ok(EncodedResponse { @@ -977,6 +980,7 @@ fn map_anthropic_stop_reason(reason: Option<&str>) -> StopReason { match reason { Some("max_tokens") => StopReason::MaxTokens, Some("tool_use") => StopReason::ToolUse, + Some("refusal") => StopReason::ContentFilter, Some("end_turn") | None => StopReason::EndTurn, _ => StopReason::Unknown, } @@ -987,9 +991,19 @@ fn anthropic_stop_reason(reason: StopReason) -> &'static str { match reason { StopReason::MaxTokens => "max_tokens", StopReason::ToolUse => "tool_use", - StopReason::EndTurn - | StopReason::ContentFilter - | StopReason::Error - | StopReason::Unknown => "end_turn", + StopReason::ContentFilter => "refusal", + StopReason::EndTurn | StopReason::Error | StopReason::Unknown => "end_turn", + } +} + +// Emits the metadata object required by Anthropic refusal responses. +fn anthropic_stop_details(reason: StopReason) -> Value { + match reason { + StopReason::ContentFilter => json!({ + "type": "refusal", + "category": Value::Null, + "explanation": Value::Null, + }), + _ => Value::Null, } } diff --git a/crates/switchyard-translation/src/codecs/anthropic/stream.rs b/crates/switchyard-translation/src/codecs/anthropic/stream.rs index 450d796e1..be8898ca1 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/stream.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/stream.rs @@ -265,6 +265,7 @@ fn finish_anthropic_stream(state: &mut StreamTranslationState) -> Vec { "delta": { "stop_reason": anthropic_stop_reason(state.stop_reason.as_deref()), "stop_sequence": Value::Null, + "stop_details": anthropic_stop_details(state.stop_reason.as_deref()), }, "usage": anthropic_stream_usage(state), })); @@ -572,13 +573,28 @@ fn anthropic_stop_reason(reason: Option<&str>) -> String { match reason { Some("length") => "max_tokens".to_string(), Some("tool_calls") | Some("function_call") => "tool_use".to_string(), - Some("end_turn") | Some("max_tokens") | Some("tool_use") | Some("stop_sequence") => { - reason.unwrap_or("end_turn").to_string() - } + Some("content_filter") => "refusal".to_string(), + Some("end_turn") + | Some("max_tokens") + | Some("tool_use") + | Some("stop_sequence") + | Some("refusal") => reason.unwrap_or("end_turn").to_string(), _ => "end_turn".to_string(), } } +// Emits the metadata object required by Anthropic refusal events. +fn anthropic_stop_details(reason: Option<&str>) -> Value { + match reason { + Some("content_filter" | "refusal") => json!({ + "type": "refusal", + "category": Value::Null, + "explanation": Value::Null, + }), + _ => Value::Null, + } +} + // Converts a streamed tool input fragment into a string delta. fn tool_input_delta(value: &Value) -> Option { match value { diff --git a/crates/switchyard-translation/src/codecs/openai_chat/stream.rs b/crates/switchyard-translation/src/codecs/openai_chat/stream.rs index b4c6273b1..978dc5c85 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/stream.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/stream.rs @@ -397,6 +397,7 @@ fn openai_finish_reason(reason: Option<&str>) -> String { Some("end_turn") | Some("stop_sequence") | None => "stop".to_string(), Some("max_tokens") => "length".to_string(), Some("tool_use") => "tool_calls".to_string(), + Some("refusal") => "content_filter".to_string(), Some(other) => other.to_string(), } } diff --git a/crates/switchyard-translation/tests/response_translation.rs b/crates/switchyard-translation/tests/response_translation.rs index cc863ece3..c5f4220ac 100644 --- a/crates/switchyard-translation/tests/response_translation.rs +++ b/crates/switchyard-translation/tests/response_translation.rs @@ -600,3 +600,64 @@ fn incomplete_responses_source_survives_translation() -> TestResult { assert_eq!(output["choices"][0]["finish_reason"], "length"); Ok(()) } + +// Verifies a moderation stop reaches Anthropic clients as `refusal` rather than +// being reported as a normal `end_turn`. +#[test] +fn openai_content_filter_translates_to_anthropic_refusal() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "id": "chatcmpl-test", + "model": "gpt-4o", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": "Partial answer"}, + "finish_reason": "content_filter" + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} + }); + + let output = engine + .translate_response( + WireFormat::OpenAiChat, + WireFormat::AnthropicMessages, + &body, + &TranslationPolicy::default(), + )? + .body; + + assert_eq!(output["stop_reason"], "refusal"); + assert_eq!( + output["stop_details"], + json!({"type": "refusal", "category": null, "explanation": null}) + ); + Ok(()) +} + +// Verifies the same distinction survives the other direction, so an Anthropic +// `refusal` is not flattened when re-encoded for an OpenAI client. +#[test] +fn anthropic_refusal_translates_to_openai_content_filter() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [{"type": "text", "text": "Partial answer"}], + "stop_reason": "refusal", + "usage": {"input_tokens": 10, "output_tokens": 5} + }); + + let output = engine + .translate_response( + WireFormat::AnthropicMessages, + WireFormat::OpenAiChat, + &body, + &TranslationPolicy::default(), + )? + .body; + + assert_eq!(output["choices"][0]["finish_reason"], "content_filter"); + Ok(()) +} diff --git a/crates/switchyard-translation/tests/stream_translation.rs b/crates/switchyard-translation/tests/stream_translation.rs index 3aeed7159..e2b58705c 100644 --- a/crates/switchyard-translation/tests/stream_translation.rs +++ b/crates/switchyard-translation/tests/stream_translation.rs @@ -303,6 +303,43 @@ fn openai_chat_stream_event_translates_to_anthropic_message_events() -> TestResu Ok(()) } +// Verifies streamed moderation stops are not reported to Anthropic clients as normal turns. +#[test] +fn openai_content_filter_stream_translates_to_anthropic_refusal() -> TestResult { + let engine = TranslationEngine::default(); + let mut state = + StreamTranslationState::new(WireFormat::OpenAiChat, WireFormat::AnthropicMessages); + let chunk = json!({ + "id": "chatcmpl-test", + "object": "chat.completion.chunk", + "model": "gpt-4o", + "choices": [{ + "index": 0, + "delta": {}, + "finish_reason": "content_filter" + }] + }); + + let mut events = engine.translate_event( + &mut state, + WireFormat::OpenAiChat, + WireFormat::AnthropicMessages, + &chunk, + )?; + events.extend(engine.finish_stream(&mut state, WireFormat::AnthropicMessages)?); + + let terminal = events + .iter() + .find(|event| event["type"] == "message_delta") + .ok_or("missing Anthropic terminal delta")?; + assert_eq!(terminal["delta"]["stop_reason"], "refusal"); + assert_eq!( + terminal["delta"]["stop_details"], + json!({"type": "refusal", "category": null, "explanation": null}) + ); + Ok(()) +} + // Verifies Anthropic usage and stop events become terminal OpenAI chunks. #[test] fn anthropic_stream_usage_and_stop_translate_to_openai_chunks() -> TestResult { @@ -337,6 +374,29 @@ fn anthropic_stream_usage_and_stop_translate_to_openai_chunks() -> TestResult { Ok(()) } +// Verifies streamed Anthropic refusals remain distinguishable for OpenAI clients. +#[test] +fn anthropic_refusal_stream_translates_to_openai_content_filter() -> TestResult { + let engine = TranslationEngine::default(); + let mut state = + StreamTranslationState::new(WireFormat::AnthropicMessages, WireFormat::OpenAiChat); + let delta = json!({ + "type": "message_delta", + "delta": {"stop_reason": "refusal"}, + "usage": {"output_tokens": 1} + }); + + let events = engine.translate_event( + &mut state, + WireFormat::AnthropicMessages, + WireFormat::OpenAiChat, + &delta, + )?; + + assert_eq!(events[0]["choices"][0]["finish_reason"], "content_filter"); + Ok(()) +} + // Verifies Chat target streams expose the served model while retaining source identity. #[test] fn anthropic_to_openai_chat_uses_served_model_without_losing_source_model() -> TestResult {