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/3] 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/3] 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/3] 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(()) }