diff --git a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs index 988cd9768..2669e2b5a 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs @@ -284,6 +284,11 @@ impl FormatCodec for OpenAiChatCodec { .and_then(Value::as_object) .cloned() .unwrap_or_default(); + let content_is_null = message.get("content").is_none_or(Value::is_null); + let refusal = message + .get("refusal") + .and_then(Value::as_str) + .filter(|text| !text.is_empty()); let mut content = decode_openai_content( message.get("content").unwrap_or(&Value::Null), WireFormat::OpenAiChat, @@ -292,6 +297,18 @@ impl FormatCodec for OpenAiChatCodec { "$.choices[0].message.content", )?; prepend_openai_reasoning_blocks(&mut content, &message); + if let Some(text) = refusal { + // Null content normally creates an empty placeholder, but the sibling refusal + // field is the actual assistant content for a structured refusal. + if content_is_null { + content.retain( + |block| !matches!(block, ContentBlock::Text { text } if text.is_empty()), + ); + } + 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( @@ -303,12 +320,16 @@ 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 { 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 6972850e9..618230747 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/stream.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/stream.rs @@ -134,6 +134,15 @@ fn decode_openai_chat_stream( text: text.to_string(), }); } + if let Some(text) = delta.get("refusal").and_then(Value::as_str) + && !text.is_empty() + { + 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() { @@ -159,6 +168,12 @@ fn decode_openai_chat_stream( } } if let Some(reason) = choice.get("finish_reason").and_then(Value::as_str) { + 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 f261b5050..6b34c65bd 100644 --- a/crates/switchyard-translation/tests/response_translation.rs +++ b/crates/switchyard-translation/tests/response_translation.rs @@ -7,7 +7,9 @@ pub mod common; use pretty_assertions::assert_eq; use serde_json::json; -use switchyard_translation::{TranslationEngine, TranslationPolicy, WireFormat}; +use switchyard_translation::{ + ContentBlock, StopReason, TranslationEngine, TranslationPolicy, WireFormat, +}; use common::{ REASONING_MODEL, normalized_policy, shell_tool_call, text_and_encrypted_reasoning_details, @@ -54,6 +56,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 3c043d59a..68b7690a0 100644 --- a/crates/switchyard-translation/tests/stream_translation.rs +++ b/crates/switchyard-translation/tests/stream_translation.rs @@ -400,6 +400,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 {