Skip to content

[Rust Frontend] Accept refusal content parts in assistant messages - #65621

Merged
BugenZhao merged 2 commits into
vllm-project:mainfrom
BugenZhao:bz/rust-parity-refusal-content-parts
Nov 5, 2026
Merged

BugenZhao merged 2 commits into
vllm-project:mainfrom
BugenZhao:bz/rust-parity-refusal-content-parts

Conversation

@BugenZhao

@BugenZhao BugenZhao commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

Overview

OpenAI's chat API allows a refusal content part in assistant messages (ChatCompletionContentPartRefusalParam; the assistant content array is text | refusal), so a client that replays a model's refusal as structured history sends it. The Python frontend accepts it and replays the refusal string as text. The Rust frontend rejects the whole request at deserialization (unknown variant 'refusal'), because ContentPart only knows text, the three media URLs and input_audio.

This PR adds ContentPart::Refusal and lowers it where the role is known.

Claims

  • Assistant messages: a refusal part becomes an AssistantContentBlock::Text, like a text part, so every renderer sees it as the turn's text (the same choice the Rust Responses conversion already makes for refusal input parts). echo includes it, as Python's echo of the parsed last message does.
  • Other roles: 400 with OpenAI's message, "Only assistant messages can have a 'refusal' content type."

Validation

Python vllm launch render vs Rust vllm-rs render with this commit, both on Qwen/Qwen3-0.6B (HF renderer), POST /v1/chat/completions/render (measured on the same commit over main 39dbf51839):

  • assistant [{"type":"refusal","refusal":"I can't."}]: identical token IDs on both, and identical to the same turn sent as "content": "I can't.".
  • user [{"type":"refusal",...}]: Python renders it as text; Rust returns the 400 above (intended, per OpenAI; see Details).

New tests in vllm-server routes::openai::chat_completions::convert, both failing on main (the request does not deserialize):

  • prepare_chat_request_replays_assistant_refusal_parts_as_text: a JSON request whose assistant message is one refusal part (OpenAI's documented shape), with echo; asserts one assistant text block and the echo text.
  • prepare_chat_request_rejects_refusal_parts_outside_assistant_messages: a user refusal part returns OpenAI's message.

Commands from .buildkite/scripts/run-rust-frontend-cargo-ci.sh, on this branch (main c58e7f6d05 + this commit):

cargo fmt --manifest-path rust/Cargo.toml --all -- --check   # ok
cargo sort --workspace --check rust   # ok
cargo deny --manifest-path rust/Cargo.toml --config rust/deny.toml check bans   # ok
cargo clippy --manifest-path rust/Cargo.toml --workspace --all-targets --all-features --locked -- -D warnings   # ok
cargo nextest run --manifest-path rust/Cargo.toml --workspace --all-features --locked --no-fail-fast
# 2545 tests run: 2545 passed, 13 skipped

Model evaluation: none run. The change only affects requests that were previously rejected, and the token-ID comparison above checks the rendered prompt directly.

Details

Layering. The role-dependent rule lives in the server's DTO lowering (convert_assistant_text_blocks / convert_content in chat_completions/convert.rs), the only place that knows the role; vllm-chat already has the text block it maps onto.

Scope and intentional deviations from Python. Python also accepts input_text, output_text, input_image, thinking and bare-string parts on chat completions, but only because its chat parser is shared with the Responses path (MM_PARSER_MAP in vllm/renderers/chat_utils/chat_message.py). A live probe of the OpenAI API (2026-11-03, one request per case) settles which are contract:

Part on /v1/chat/completions OpenAI Python Rust before Rust after
refusal in an assistant message 200 text 400 text
refusal in a user message 400 "Only assistant messages can have a 'refusal' content type." text 400 (serde) 400 (OpenAI's message)
input_text, output_text (any role) 400 "Invalid value" text 400 400 (unchanged)
input_image 400 "Invalid value" image 400 400 (unchanged)
bare string in the parts list 400 "expected an object" text 400 400 (unchanged)

So only the assistant refusal part is implemented, and a refusal part outside assistant messages follows OpenAI rather than Python. Clients with Responses-shaped content can use the Rust /v1/responses route (#63902), which accepts all of these. Python's Mistral thinking part (#21333) is out of scope here.

Harmony. Python's Harmony path (gpt-oss) drops an assistant refusal part (harmony_utils.py, TODO: Support refusal); Rust's Harmony renderer keeps it as text like every other renderer.

Known difference not changed here. An assistant message with several text-like parts is joined with "\n" by Python and concatenated by Rust's HF renderer ([text "Sorry.", refusal "I can't."] renders Sorry.\nI can't. vs Sorry.I can't.). The same already holds for multiple text parts and is a separate fix.

Not a duplicate. No open PR adds refusal content parts to the Rust frontend. Open PRs mentioning refusal parts target Python paths: Harmony (#55479) and Responses input reconstruction (#49130, #48237). #56648 also extends Rust chat content types but adds image_embeds in vllm-chat, not refusal parts.

AI assistance (Claude Code) was used to prepare this change; I reviewed every changed line.


Pull Request Checklist
  • I used vLLM's /pr-checklist skill. (Mandatory for agents, optional for humans).

  • AI assistance was used during the creation of this PR.

  • Design Fit: Minimizes impact on core components, reuses existing functionality, and justifies added complexity.

  • Testing and Validation: Validates the change and ensures any added tests are meaningful and reliable, with CI coverage or documented CI resource constraints and validation performed outside CI.

  • Code Quality and Style: Keeps code and comments clear and concise, and updates relevant documentation and examples.

  • Pull Request Contents: Includes a brief summary and relevant links, supports claims with evidence, explains root causes and implementation trade-offs, and follows the contributing guide.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CJckCAkBsrWKV6peRwSdih

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 <noreply@anthropic.com>
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
@BugenZhao
BugenZhao requested a review from njhill as a code owner November 4, 2026 08:25

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@BugenZhao BugenZhao added the ready ONLY add when PR is ready to merge/full CI is needed label Nov 5, 2026
@BugenZhao

Copy link
Copy Markdown
Member Author

/ci run

@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

✅ Triggered Buildkite CI #100214 for commit 77f9f08281f9.

@BugenZhao
BugenZhao merged commit 29e0386 into vllm-project:main Nov 5, 2026
29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready ONLY add when PR is ready to merge/full CI is needed rust

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants