diff --git a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs index d90423518..35e48c134 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs @@ -300,6 +300,10 @@ impl FormatCodec for OpenAiChatCodec { .and_then(Value::as_object) .cloned() .unwrap_or_default(); + let refusal = message + .get("refusal") + .and_then(Value::as_str) + .filter(|text| !text.is_empty()); let mut content = match message.get("content") { // decode_openai_content would turn absent response text into an empty text block. None | Some(Value::Null) => Vec::new(), @@ -312,6 +316,11 @@ impl FormatCodec for OpenAiChatCodec { )?, }; prepend_openai_reasoning_blocks(&mut content, &message); + if let Some(text) = refusal { + content.push(ContentBlock::Refusal { + text: text.to_string(), + }); + } if let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) { for (index, tool_call) in tool_calls.iter().enumerate() { if let Some(call) = decode_openai_tool_call( @@ -323,6 +332,12 @@ impl FormatCodec for OpenAiChatCodec { } } } + let finish_reason = choice.get("finish_reason").and_then(Value::as_str); + let stop_reason = if refusal.is_some() && matches!(finish_reason, Some("stop") | None) { + StopReason::ContentFilter + } else { + map_openai_finish_reason(finish_reason) + }; response.outputs.push(ResponseOutput { url_citations: message .get("annotations") @@ -336,9 +351,7 @@ impl FormatCodec for OpenAiChatCodec { .collect(), role: Role::Assistant, content, - stop_reason: Some(map_openai_finish_reason( - choice.get("finish_reason").and_then(Value::as_str), - )), + stop_reason: Some(stop_reason), }); } diff --git a/crates/switchyard-translation/src/codecs/openai_chat/stream.rs b/crates/switchyard-translation/src/codecs/openai_chat/stream.rs index 7a902627b..c86749728 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/stream.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/stream.rs @@ -137,6 +137,17 @@ fn decode_openai_chat_stream( text: text.to_string(), }); } + if let Some(text) = delta.get("refusal").and_then(Value::as_str) + && !text.is_empty() + { + // Refusal deltas mark the whole message as a content-filter stop. This state + // persists across chunks so a later `finish_reason: "stop"` cannot overwrite it. + state.stop_reason = Some("content_filter".to_string()); + out.push(LlmResponseChunk::TextDelta { + index: 0, + text: text.to_string(), + }); + } if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) { for tool_call in tool_calls { if let Some(tool_call) = tool_call.as_object() { @@ -181,6 +192,12 @@ fn decode_openai_chat_stream( arguments_delta: None, }); } + let reason = + if reason == "stop" && state.stop_reason.as_deref() == Some("content_filter") { + "content_filter" + } else { + reason + }; out.push(LlmResponseChunk::MessageStop { reason: Some(reason.to_string()), }); diff --git a/crates/switchyard-translation/tests/response_translation.rs b/crates/switchyard-translation/tests/response_translation.rs index 4280fa3f1..fc5d5c0c4 100644 --- a/crates/switchyard-translation/tests/response_translation.rs +++ b/crates/switchyard-translation/tests/response_translation.rs @@ -8,7 +8,7 @@ pub mod common; use pretty_assertions::assert_eq; use serde_json::json; use switchyard_translation::{ - PreservationPolicy, TranslationEngine, TranslationPolicy, WireFormat, + ContentBlock, PreservationPolicy, StopReason, TranslationEngine, TranslationPolicy, WireFormat, }; use common::{ @@ -255,6 +255,55 @@ fn openai_chat_response_translates_to_anthropic_message() -> TestResult { Ok(()) } +// Verifies a Chat Completions refusal survives both neutral decoding and Anthropic encoding. +#[test] +fn openai_chat_refusal_decodes_and_translates_to_anthropic() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "id": "chatcmpl-refusal", + "model": "gpt-4o", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": null, + "refusal": "I cannot help with that request." + }, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} + }); + + let decoded = + engine.decode_response(WireFormat::OpenAiChat, &body, &TranslationPolicy::default())?; + let output = decoded.response.first_output().ok_or("missing output")?; + assert_eq!( + output.content, + vec![ContentBlock::Refusal { + text: "I cannot help with that request.".to_string() + }] + ); + assert_eq!(output.stop_reason, Some(StopReason::ContentFilter)); + + let translated = engine + .encode_response( + WireFormat::AnthropicMessages, + &decoded.response, + &TranslationPolicy::default(), + )? + .body; + assert_eq!( + translated["content"], + json!([{"type": "text", "text": "I cannot help with that request."}]) + ); + assert_eq!(translated["stop_reason"], "refusal"); + assert_eq!( + translated["stop_details"], + json!({"type": "refusal", "category": null, "explanation": null}) + ); + Ok(()) +} + // Verifies Anthropic message responses map to OpenAI Chat completions. #[test] fn anthropic_message_response_translates_to_openai_chat_completion() -> TestResult { diff --git a/crates/switchyard-translation/tests/stream_translation.rs b/crates/switchyard-translation/tests/stream_translation.rs index 01e9cc0da..3169245a9 100644 --- a/crates/switchyard-translation/tests/stream_translation.rs +++ b/crates/switchyard-translation/tests/stream_translation.rs @@ -548,6 +548,63 @@ fn openai_chat_stream_event_translates_to_anthropic_message_events() -> TestResu Ok(()) } +// Verifies Chat Completions refusal deltas remain visible and terminate as refusals. +#[test] +fn openai_chat_refusal_stream_translates_to_anthropic() -> TestResult { + let engine = TranslationEngine::default(); + let mut state = + StreamTranslationState::new(WireFormat::OpenAiChat, WireFormat::AnthropicMessages); + let refusal = json!({ + "id": "chatcmpl-refusal", + "object": "chat.completion.chunk", + "model": "gpt-4o", + "choices": [{ + "index": 0, + "delta": {"refusal": "I cannot help with that request."}, + "finish_reason": null + }] + }); + + let mut events = engine.translate_event( + &mut state, + WireFormat::OpenAiChat, + WireFormat::AnthropicMessages, + &refusal, + )?; + let text_delta = events + .iter() + .find(|event| event["type"] == "content_block_delta") + .ok_or("missing refusal text delta")?; + assert_eq!( + text_delta["delta"]["text"], + "I cannot help with that request." + ); + + let terminal = json!({ + "id": "chatcmpl-refusal", + "object": "chat.completion.chunk", + "model": "gpt-4o", + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}] + }); + events.extend(engine.translate_event( + &mut state, + WireFormat::OpenAiChat, + WireFormat::AnthropicMessages, + &terminal, + )?); + events.extend(engine.finish_stream(&mut state, WireFormat::AnthropicMessages)?); + let message_delta = events + .iter() + .find(|event| event["type"] == "message_delta") + .ok_or("missing Anthropic terminal delta")?; + assert_eq!(message_delta["delta"]["stop_reason"], "refusal"); + assert_eq!( + message_delta["delta"]["stop_details"], + json!({"type": "refusal", "category": null, "explanation": null}) + ); + Ok(()) +} + // Restores Anthropic-safe IDs before emitting OpenAI tool-call deltas. #[test] fn anthropic_stream_tool_id_is_restored_for_openai_chat() -> TestResult {