From cce6f6dd6045cd7d2d1664b7ab7fc01e9df7a076 Mon Sep 17 00:00:00 2001 From: "richard-for-bugen[bot]" <342439978+richard-for-bugen[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:26:35 +0000 Subject: [PATCH] [Rust Frontend] Accept refusal content parts in assistant messages OpenAI's chat API allows `refusal` parts in assistant message content (`ChatCompletionContentPartRefusalParam`), and the Python frontend replays them as text. The Rust frontend rejected them with a deserialization error. Lower them onto assistant text blocks, include them in the `echo` text like Python, and reject them in other roles with OpenAI's message. Co-authored-by: Claude Opus 5.5 Signed-off-by: richard-for-bugen[bot] <342439978+richard-for-bugen[bot]@users.noreply.github.com> --- .../routes/openai/chat_completions/convert.rs | 65 ++++++++++++++++++- .../server/src/routes/openai/utils/types.rs | 4 ++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/rust/src/server/src/routes/openai/chat_completions/convert.rs b/rust/src/server/src/routes/openai/chat_completions/convert.rs index 251cbcd59dc0..c1b63ea42d83 100644 --- a/rust/src/server/src/routes/openai/chat_completions/convert.rs +++ b/rust/src/server/src/routes/openai/chat_completions/convert.rs @@ -271,7 +271,9 @@ fn extract_last_assistant_content(messages: &[ChatMessage]) -> Option { MessageContent::Parts(parts) => parts .iter() .filter_map(|p| match p { - ContentPart::Text { text } => Some(text.as_str()), + ContentPart::Text { text } | ContentPart::Refusal { refusal: text } => { + Some(text.as_str()) + } _ => None, }) .collect::>() @@ -346,6 +348,9 @@ fn convert_content(content: MessageContent) -> Result { .into_iter() .map(|part| match part { ContentPart::Text { text } => Ok(ChatContentPart::text(text)), + ContentPart::Refusal { .. } => bail_invalid_request!( + "Only assistant messages can have a 'refusal' content type." + ), ContentPart::ImageUrl { image_url, uuid } => Ok(ChatContentPart::ImageUrl { image_url: image_url.url, detail: image_url.detail, @@ -380,7 +385,9 @@ fn convert_assistant_text_blocks( MessageContent::Parts(parts) => parts .into_iter() .map(|part| match part { - ContentPart::Text { text } => Ok(AssistantContentBlock::Text { text }), + ContentPart::Text { text } | ContentPart::Refusal { refusal: text } => { + Ok(AssistantContentBlock::Text { text }) + } _ => bail_invalid_request!( "Only text content parts are supported for assistant messages." ), @@ -1380,6 +1387,60 @@ mod tests { .assert_eq(&error.to_error_response().error.message); } + #[test] + fn prepare_chat_request_replays_assistant_refusal_parts_as_text() { + let request: ChatCompletionRequest = serde_json::from_value(json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": [ + {"type": "refusal", "refusal": "I can't help with that."}, + ]}, + ], + "echo": true, + })) + .expect("refusal parts parse in assistant messages"); + + let prepared = prepare_chat_request( + request, + &served(&["Qwen/Qwen1.5-0.5B-Chat"]), + ResolvedRequestContext::default(), + ) + .expect("request is valid"); + + assert_eq!( + prepared.chat_request.messages[1], + VllmChatMessage::assistant_blocks(vec![AssistantContentBlock::Text { + text: "I can't help with that.".to_string(), + }]) + ); + assert_eq!( + prepared.options.echo.as_deref(), + Some("I can't help with that.") + ); + } + + #[test] + fn prepare_chat_request_rejects_refusal_parts_outside_assistant_messages() { + let request: ChatCompletionRequest = serde_json::from_value(json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "messages": [{"role": "user", "content": [ + {"type": "refusal", "refusal": "no"}, + ]}], + })) + .expect("refusal parts parse in any message"); + + let error = prepare_chat_request( + request, + &served(&["Qwen/Qwen1.5-0.5B-Chat"]), + ResolvedRequestContext::default(), + ) + .unwrap_err(); + + expect!["Only assistant messages can have a 'refusal' content type."] + .assert_eq(&error.to_error_response().error.message); + } + #[test] fn prepare_chat_request_accepts_assistant_reasoning_history() { let message = ChatCompletionMessage { diff --git a/rust/src/server/src/routes/openai/utils/types.rs b/rust/src/server/src/routes/openai/utils/types.rs index 5d868f045dd2..6997d37fda2e 100644 --- a/rust/src/server/src/routes/openai/utils/types.rs +++ b/rust/src/server/src/routes/openai/utils/types.rs @@ -218,6 +218,10 @@ pub fn validate_top_p_value(top_p: f32) -> Result<(), validator::ValidationError pub enum ContentPart { #[serde(rename = "text")] Text { text: String }, + /// A refusal the model gave earlier. Valid only in assistant messages, + /// where it is replayed as text. + #[serde(rename = "refusal")] + Refusal { refusal: String }, #[serde(rename = "image_url")] ImageUrl { image_url: ImageUrl,