[Bugfix] Responses API assistant EasyInputMessageParam input (#44361)

Signed-off-by: Yifan Zong <yzong@redhat.com> Co-authored-by: Ben Browning <bbrownin@redhat.com>

yzong-rh committed Jun 23, 2026 at 08:54 UTC 2a675a7b9fce6577e8eab937f67c7c1973a54f7f
4 files changed +93 -13
tests/entrypoints/openai/responses/test_function_call_parsing.py
+50 -1
@@ -5,7 +5,7 @@
5 import json
6
7 import pytest
8 -from openai.types.responses import ResponseFunctionToolCall
8 +from openai.types.responses import ResponseFunctionToolCall, ResponseOutputMessage
9
10 from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
11
@@ -328,3 +328,52 @@ def test_validator_handles_empty_iterator():
328
329 request = ResponsesRequest(**mock_data)
330 assert request.input == []
331 +
332 +
333 +def test_assistant_string_content_stays_easyinput():
334 + """EasyInput assistant message with plain string content is not
335 + coerced into a ResponseOutputMessage."""
336 + request_data = {
337 + "model": "test-model",
338 + "input": [
339 + {"type": "message", "role": "assistant", "content": "hello"},
340 + ],
341 + }
342 +
343 + request = ResponsesRequest(**request_data)
344 +
345 + item = request.input[0]
346 + assert isinstance(item, dict), (
347 + "String-content assistant message should remain a dict (EasyInput), "
348 + f"got {type(item)}"
349 + )
350 + assert item.get("content") == "hello"
351 + assert "id" not in item
352 + assert "status" not in item
353 +
354 +
355 +def test_assistant_output_style_content_coerced():
356 + """Assistant message whose content is output-message-shaped (list of
357 + output_text items) should be coerced to ResponseOutputMessage."""
358 + request_data = {
359 + "model": "test-model",
360 + "input": [
361 + {
362 + "type": "message",
363 + "role": "assistant",
364 + "content": [{"type": "output_text", "text": "world"}],
365 + },
366 + ],
367 + }
368 +
369 + request = ResponsesRequest(**request_data)
370 +
371 + item = request.input[0]
372 + assert isinstance(item, ResponseOutputMessage), (
373 + "Output-style assistant message should be coerced to "
374 + f"ResponseOutputMessage, got {type(item)}"
375 + )
376 + assert item.content[0].text == "world"
377 + assert item.content[0].annotations == []
378 + assert item.status == "completed"
379 + assert item.id.startswith("msg_")
tests/entrypoints/openai/responses/test_responses_utils.py
+10
@@ -782,6 +782,16 @@ class TestConstructChatMessagesCombinePolicy:
782 ["call_123", "call_456"],
783 id="reasoning-output-tool-call",
784 ),
785 + pytest.param(
786 + [
787 + make_reasoning_item(content_text="Let me think"),
788 + {"type": "message", "role": "assistant", "content": "Hello"},
789 + ],
790 + "Hello",
791 + "Let me think",
792 + None,
793 + id="reasoning-easyinput-assistant",
794 + ),
795 ],
796 )
797 def test_assistant_side_items_merge_until_tool_output(
vllm/entrypoints/openai/responses/protocol.py
+19 -12
@@ -525,23 +525,30 @@ class ResponsesRequest(OpenAIBaseModel):
525 processed_input.append(item)
526
527 elif item_type == "message" and item.get("role") == "assistant":
528 + content = item.get("content")
529 + if not isinstance(content, list):
530 + # String content is a valid EasyInputMessageParam,
531 + # do not coerce it to ResponseOutputMessage
532 + processed_input.append(item)
533 + continue
534 +
535 + original_item = item
536 item = dict(item)
537 if "id" not in item:
538 item["id"] = f"msg_{random_uuid()}"
539 if "status" not in item:
540 item["status"] = "completed"
541 # ResponseOutputText requires annotations
534 - if isinstance(item.get("content"), list):
535 - new_content = []
536 - for c in item["content"]:
537 - if (
538 - isinstance(c, dict)
539 - and c.get("type") == "output_text"
540 - and "annotations" not in c
541 - ):
542 - c = {**c, "annotations": []}
543 - new_content.append(c)
544 - item["content"] = new_content
542 + new_content = []
543 + for c in content:
544 + if (
545 + isinstance(c, dict)
546 + and c.get("type") == "output_text"
547 + and "annotations" not in c
548 + ):
549 + c = {**c, "annotations": []}
550 + new_content.append(c)
551 + item["content"] = new_content
552 try:
553 processed_input.append(ResponseOutputMessage(**item))
554 except ValidationError:
@@ -549,7 +556,7 @@ class ResponsesRequest(OpenAIBaseModel):
556 "Failed to parse assistant message to ResponseOutputMessage, "
557 "leaving for Pydantic validation"
558 )
552 - processed_input.append(item)
559 + processed_input.append(original_item)
560
561 else:
562 processed_input.append(item)
vllm/entrypoints/openai/responses/utils.py
+14
@@ -306,6 +306,20 @@ def _construct_message_from_response_item(
306 content=item.get("output"),
307 tool_call_id=item.get("call_id"),
308 )
309 + elif isinstance(item, dict) and item.get("role") == "assistant":
310 + content = item.get("content")
311 + text: str | None = None
312 + if isinstance(content, str):
313 + text = content
314 + elif isinstance(content, list) and content:
315 + text = content[0].get("text")
316 + if text is not None:
317 + if prev_assistant_msg:
318 + previous_content = prev_assistant_msg.get("content")
319 + if previous_content is None:
320 + prev_assistant_msg["content"] = text
321 + return None
322 + return {"role": "assistant", "content": text}
323 return item # type: ignore[arg-type]
324
325