From cd0993750824603248fec469eda4ba757ca77719 Mon Sep 17 00:00:00 2001 From: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:43:10 +0530 Subject: [PATCH 1/4] fix(translation): report content filter stops as Anthropic refusal StopReason::ContentFilter was encoded as "end_turn" for Anthropic clients, so a response stopped by moderation was indistinguishable from a normal completion. The OpenAI codec already round trips this value, which left the Anthropic codec as the only place it was lost. Map ContentFilter to "refusal", which Anthropic documents for classifier interventions, and decode "refusal" back into ContentFilter so the value survives a round trip. Also map an inbound "refusal" to OpenAI's "content_filter" in the streaming encoder so both directions stay symmetric. Error and Unknown still map to "end_turn" and are left alone here. Closes #369 Signed-off-by: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com> --- .../src/codecs/anthropic/buffered.rs | 7 +-- .../src/codecs/anthropic/stream.rs | 9 ++- .../src/codecs/openai_chat/stream.rs | 1 + .../tests/response_translation.rs | 57 +++++++++++++++++++ 4 files changed, 67 insertions(+), 7 deletions(-) diff --git a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs index 7a8e79c9e..06b4e8a2c 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs @@ -977,6 +977,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 +988,7 @@ 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", } } diff --git a/crates/switchyard-translation/src/codecs/anthropic/stream.rs b/crates/switchyard-translation/src/codecs/anthropic/stream.rs index 450d796e1..ab708f2e7 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/stream.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/stream.rs @@ -572,9 +572,12 @@ 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(), } } 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..8ea3dc21e 100644 --- a/crates/switchyard-translation/tests/response_translation.rs +++ b/crates/switchyard-translation/tests/response_translation.rs @@ -600,3 +600,60 @@ 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"); + 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(()) +} From 906d6552d07b56ef45af796f22ac529912ebab36 Mon Sep 17 00:00:00 2001 From: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:12:32 +0530 Subject: [PATCH 2/4] fix(protocol): normalize Anthropic refusal streams Signed-off-by: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com> --- crates/protocol/src/stream.rs | 11 +++- .../tests/stream_translation.rs | 56 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) 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/tests/stream_translation.rs b/crates/switchyard-translation/tests/stream_translation.rs index 3aeed7159..cc73c58e4 100644 --- a/crates/switchyard-translation/tests/stream_translation.rs +++ b/crates/switchyard-translation/tests/stream_translation.rs @@ -303,6 +303,39 @@ 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"); + 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 +370,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 { From 80f79032fabda89021da65ca342018d0471151be Mon Sep 17 00:00:00 2001 From: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:16:03 +0530 Subject: [PATCH 3/4] fix(translation): include Anthropic refusal details Signed-off-by: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com> --- .../src/codecs/anthropic/buffered.rs | 19 +++++++++++++++++-- .../src/codecs/anthropic/stream.rs | 13 +++++++++++++ .../tests/response_translation.rs | 4 ++++ .../tests/stream_translation.rs | 4 ++++ 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs index 06b4e8a2c..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 { @@ -992,3 +995,15 @@ fn anthropic_stop_reason(reason: StopReason) -> &'static str { 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 ab708f2e7..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), })); @@ -582,6 +583,18 @@ fn anthropic_stop_reason(reason: Option<&str>) -> 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/tests/response_translation.rs b/crates/switchyard-translation/tests/response_translation.rs index 8ea3dc21e..c5f4220ac 100644 --- a/crates/switchyard-translation/tests/response_translation.rs +++ b/crates/switchyard-translation/tests/response_translation.rs @@ -627,6 +627,10 @@ fn openai_content_filter_translates_to_anthropic_refusal() -> TestResult { .body; assert_eq!(output["stop_reason"], "refusal"); + assert_eq!( + output["stop_details"], + json!({"type": "refusal", "category": null, "explanation": null}) + ); Ok(()) } diff --git a/crates/switchyard-translation/tests/stream_translation.rs b/crates/switchyard-translation/tests/stream_translation.rs index cc73c58e4..e2b58705c 100644 --- a/crates/switchyard-translation/tests/stream_translation.rs +++ b/crates/switchyard-translation/tests/stream_translation.rs @@ -333,6 +333,10 @@ fn openai_content_filter_stream_translates_to_anthropic_refusal() -> TestResult .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(()) } From de767e2426e801e3975c75c3577d9112f9f2a6d0 Mon Sep 17 00:00:00 2001 From: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:09:19 +0530 Subject: [PATCH 4/4] fix(translation): preserve refusal details and keep the IR provider-neutral Signed-off-by: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com> --- crates/protocol/src/stream.rs | 11 +-- .../src/codecs/anthropic/buffered.rs | 29 ++++-- .../src/codecs/anthropic/stream.rs | 40 +++++--- .../src/codecs/stream.rs | 3 + .../tests/response_translation.rs | 51 ++++++---- .../tests/stream_translation.rs | 99 +++++++++++-------- 6 files changed, 142 insertions(+), 91 deletions(-) diff --git a/crates/protocol/src/stream.rs b/crates/protocol/src/stream.rs index ca13c4907..e51b68dce 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" | "refusal") => StopReason::ContentFilter, + Some("content_filter") => StopReason::ContentFilter, Some("stop" | "end_turn" | "stop_sequence") | None => StopReason::EndTurn, Some(_) => StopReason::Unknown, } @@ -490,15 +490,6 @@ 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 b52475e3d..b55140880 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs @@ -352,7 +352,9 @@ impl FormatCodec for AnthropicMessagesCodec { .unwrap_or("end_turn"), "stop_sequence": Value::Null, "stop_details": normalized_stop_reason - .map(anthropic_stop_details) + .map(|reason| { + anthropic_stop_details(reason, response.extensions.fields.get("stop_details")) + }) .unwrap_or(Value::Null), "usage": encode_anthropic_usage(&response.usage), }); @@ -996,14 +998,25 @@ fn anthropic_stop_reason(reason: StopReason) -> &'static str { } } -// Emits the metadata object required by Anthropic refusal responses. -fn anthropic_stop_details(reason: StopReason) -> Value { +// 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 => json!({ - "type": "refusal", - "category": Value::Null, - "explanation": Value::Null, - }), + 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 be8898ca1..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,7 +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()), + "stop_details": anthropic_stop_details( + state.stop_reason.as_deref(), + state.stop_details.as_ref(), + ), }, "usage": anthropic_stream_usage(state), })); @@ -573,23 +582,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("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(), + 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 required by Anthropic refusal events. -fn anthropic_stop_details(reason: Option<&str>) -> Value { +// 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") => json!({ - "type": "refusal", - "category": Value::Null, - "explanation": Value::Null, + Some("content_filter" | "refusal") => source.cloned().unwrap_or_else(|| { + json!({ + "type": "refusal", + "category": Value::Null, + "explanation": Value::Null, + }) }), _ => Value::Null, } 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 c5f4220ac..65c0c3dd9 100644 --- a/crates/switchyard-translation/tests/response_translation.rs +++ b/crates/switchyard-translation/tests/response_translation.rs @@ -601,12 +601,16 @@ fn incomplete_responses_source_survives_translation() -> TestResult { Ok(()) } -// Verifies a moderation stop reaches Anthropic clients as `refusal` rather than -// being reported as a normal `end_turn`. +// Verifies a moderation stop stays distinguishable from a normal turn in both +// directions, and that a named refusal category survives re-encoding. #[test] -fn openai_content_filter_translates_to_anthropic_refusal() -> TestResult { +fn content_filter_and_refusal_translate_across_formats() -> TestResult { let engine = TranslationEngine::default(); - let body = json!({ + + // 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": [{ @@ -616,48 +620,59 @@ fn openai_content_filter_translates_to_anthropic_refusal() -> TestResult { }], "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} }); - let output = engine .translate_response( WireFormat::OpenAiChat, WireFormat::AnthropicMessages, - &body, + &openai, &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!({ + // 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, - &body, + &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 e2b58705c..2eb0774d7 100644 --- a/crates/switchyard-translation/tests/stream_translation.rs +++ b/crates/switchyard-translation/tests/stream_translation.rs @@ -303,43 +303,6 @@ 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 { @@ -374,26 +337,78 @@ fn anthropic_stream_usage_and_stop_translate_to_openai_chunks() -> TestResult { Ok(()) } -// Verifies streamed Anthropic refusals remain distinguishable for OpenAI clients. +// Verifies streamed moderation stops stay distinguishable from normal turns in both +// directions, and that a named refusal category survives re-encoding. #[test] -fn anthropic_refusal_stream_translates_to_openai_content_filter() -> TestResult { +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"}, + "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(()) }