diff --git a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs index 7a8e79c9e..b55140880 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs @@ -340,17 +340,22 @@ 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(|reason| { + anthropic_stop_details(reason, response.extensions.fields.get("stop_details")) + }) + .unwrap_or(Value::Null), "usage": encode_anthropic_usage(&response.usage), }); Ok(EncodedResponse { @@ -977,6 +982,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 +993,30 @@ 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 Anthropic pairs with a `refusal` stop reason. +// +// An Anthropic source keeps its `stop_details` in provider extensions, so replay that +// object and preserve the named policy category and its explanation. Only a refusal +// synthesized from a provider that reports no category falls back to the null form, +// which Anthropic documents as the normal value for a refusal that maps to no named +// category. +fn anthropic_stop_details(reason: StopReason, source: Option<&Value>) -> Value { + match reason { + StopReason::ContentFilter => source + .filter(|details| !details.is_null()) + .cloned() + .unwrap_or_else(|| { + 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..d9cbb4e33 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/stream.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/stream.rs @@ -117,6 +117,12 @@ fn decode_anthropic_stream( // Remember the provider stop reason: Anthropic delivers it here, on // `message_delta`, while the terminal `message_stop` carries none of its own. state.stop_reason = Some(stop_reason.to_string()); + state.stop_details = object + .get("delta") + .and_then(Value::as_object) + .and_then(|delta| delta.get("stop_details")) + .filter(|details| !details.is_null()) + .cloned(); out.push(LlmResponseChunk::MessageStop { reason: Some(stop_reason.to_string()), }); @@ -265,6 +271,10 @@ 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(), + state.stop_details.as_ref(), + ), }, "usage": anthropic_stream_usage(state), })); @@ -572,13 +582,33 @@ 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") => "refusal".to_string(), + Some(reason @ ("end_turn" | "max_tokens" | "tool_use" | "stop_sequence")) => { + reason.to_string() } _ => "end_turn".to_string(), } } +// Emits the metadata object Anthropic pairs with a `refusal` stop reason. +// +// A source that already reported `stop_details` carries the named policy category and +// its explanation, so replay that object verbatim. Only a refusal synthesized from a +// provider that reports no category falls back to the null form, which Anthropic +// documents as the normal value for a refusal that maps to no named category. +fn anthropic_stop_details(reason: Option<&str>, source: Option<&Value>) -> Value { + match reason { + Some("content_filter" | "refusal") => source.cloned().unwrap_or_else(|| { + 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/src/codecs/stream.rs b/crates/switchyard-translation/src/codecs/stream.rs index bcd586a27..69dbad978 100644 --- a/crates/switchyard-translation/src/codecs/stream.rs +++ b/crates/switchyard-translation/src/codecs/stream.rs @@ -42,6 +42,9 @@ pub struct StreamTranslationState { pub(crate) output_tokens_seen: u64, pub(crate) saw_backend_usage: bool, pub(crate) stop_reason: Option, + /// Refusal metadata carried by the source, replayed instead of a synthesized + /// object so a named policy category and its explanation are not flattened away. + pub(crate) stop_details: Option, pub(crate) emitted_message_delta: bool, pub(crate) next_content_index: usize, diff --git a/crates/switchyard-translation/tests/response_translation.rs b/crates/switchyard-translation/tests/response_translation.rs index cc863ece3..65c0c3dd9 100644 --- a/crates/switchyard-translation/tests/response_translation.rs +++ b/crates/switchyard-translation/tests/response_translation.rs @@ -600,3 +600,79 @@ fn incomplete_responses_source_survives_translation() -> TestResult { assert_eq!(output["choices"][0]["finish_reason"], "length"); Ok(()) } + +// Verifies a moderation stop stays distinguishable from a normal turn in both +// directions, and that a named refusal category survives re-encoding. +#[test] +fn content_filter_and_refusal_translate_across_formats() -> TestResult { + let engine = TranslationEngine::default(); + + // An OpenAI moderation stop reaches Anthropic clients as `refusal`, not `end_turn`. + // OpenAI reports no policy category, so the refusal carries the null form that + // Anthropic documents for a refusal mapping to no named category. + let openai = 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, + &openai, + &TranslationPolicy::default(), + )? + .body; + assert_eq!(output["stop_reason"], "refusal"); + assert_eq!( + output["stop_details"], + json!({"type": "refusal", "category": null, "explanation": null}) + ); + + // The distinction survives the other direction rather than being flattened. + let anthropic = json!({ + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [{"type": "text", "text": "Partial answer"}], + "stop_reason": "refusal", + "stop_details": { + "type": "refusal", + "category": "cyber", + "explanation": "This request was declined because it could enable cyber harm." + }, + "usage": {"input_tokens": 10, "output_tokens": 5} + }); + let output = engine + .translate_response( + WireFormat::AnthropicMessages, + WireFormat::OpenAiChat, + &anthropic, + &TranslationPolicy::default(), + )? + .body; + assert_eq!(output["choices"][0]["finish_reason"], "content_filter"); + + // Re-encoding a refusal keeps the category the source named instead of + // replacing it with the null form used for an unnamed refusal. + let output = engine + .translate_response( + WireFormat::AnthropicMessages, + WireFormat::AnthropicMessages, + &anthropic, + &TranslationPolicy { + preservation: switchyard_translation::PreservationPolicy::Disabled, + ..TranslationPolicy::default() + }, + )? + .body; + assert_eq!(output["stop_reason"], "refusal"); + assert_eq!(output["stop_details"]["category"], "cyber"); + Ok(()) +} diff --git a/crates/switchyard-translation/tests/stream_translation.rs b/crates/switchyard-translation/tests/stream_translation.rs index 3aeed7159..2eb0774d7 100644 --- a/crates/switchyard-translation/tests/stream_translation.rs +++ b/crates/switchyard-translation/tests/stream_translation.rs @@ -337,6 +337,81 @@ fn anthropic_stream_usage_and_stop_translate_to_openai_chunks() -> TestResult { Ok(()) } +// Verifies streamed moderation stops stay distinguishable from normal turns in both +// directions, and that a named refusal category survives re-encoding. +#[test] +fn content_filter_and_refusal_streams_translate_across_formats() -> TestResult { + let engine = TranslationEngine::default(); + + // An OpenAI moderation stop reaches Anthropic clients as `refusal`, carrying the + // null form Anthropic documents for a refusal mapping to no named category. + 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}) + ); + + // The distinction survives the other direction rather than being flattened. + let mut state = + StreamTranslationState::new(WireFormat::AnthropicMessages, WireFormat::OpenAiChat); + let delta = json!({ + "type": "message_delta", + "delta": { + "stop_reason": "refusal", + "stop_details": { + "type": "refusal", + "category": "cyber", + "explanation": "This request was declined because it could enable cyber harm." + } + }, + "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"); + + // Re-encoding a streamed refusal keeps the category the source named. + let mut state = + StreamTranslationState::new(WireFormat::AnthropicMessages, WireFormat::AnthropicMessages); + let mut events = engine.translate_event( + &mut state, + WireFormat::AnthropicMessages, + WireFormat::AnthropicMessages, + &delta, + )?; + 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"]["category"], "cyber"); + 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 {