diff --git a/tests/entrypoints/openai/parser/test_harmony_utils.py b/tests/entrypoints/openai/parser/test_harmony_utils.py index 7842a1fcd757..21b53dff1507 100644 --- a/tests/entrypoints/openai/parser/test_harmony_utils.py +++ b/tests/entrypoints/openai/parser/test_harmony_utils.py @@ -14,6 +14,7 @@ parse_chat_output, ) from vllm.entrypoints.openai.responses.harmony import ( + response_input_to_harmony, response_previous_input_to_harmony, ) @@ -841,3 +842,89 @@ def test_all_standard_channels_present(self) -> None: assert channel in valid_channels, ( f"{channel} missing when with_custom_tools={with_tools}" ) + + +class TestResponseInputToHarmonyReasoningItem: + """Tests for response_input_to_harmony handling of reasoning input items. + + Per the OpenAI spec, ResponseReasoningItem.content is + Optional[List[Content]] = None. Clients like langchain-openai may omit + this field when constructing multi-turn input from previous responses. + + Reasoning items with content are converted to Harmony messages on the + 'analysis' channel. All content items are concatenated. Items without + content return None (skipped by the caller). + """ + + def test_reasoning_with_single_content(self): + """Test reasoning item with a single content entry.""" + item = { + "type": "reasoning", + "id": "rs_123", + "content": [{"type": "reasoning_text", "text": "Thinking step by step"}], + } + + msg = response_input_to_harmony(item, prev_responses=[]) + + assert msg is not None + assert msg.author.role == Role.ASSISTANT + assert msg.content[0].text == "Thinking step by step" + assert msg.channel == "analysis" + + def test_reasoning_with_multiple_content_items(self): + """Test reasoning item with multiple content entries concatenated.""" + item = { + "type": "reasoning", + "id": "rs_123", + "content": [ + {"type": "reasoning_text", "text": "First, let me analyze"}, + {"type": "reasoning_text", "text": "Second, I should consider"}, + {"type": "reasoning_text", "text": "Finally, the answer is"}, + ], + } + + msg = response_input_to_harmony(item, prev_responses=[]) + + assert msg is not None + assert msg.author.role == Role.ASSISTANT + assert msg.content[0].text == ( + "First, let me analyze\nSecond, I should consider\nFinally, the answer is" + ) + assert msg.channel == "analysis" + + def test_reasoning_without_content_returns_none(self): + """Test reasoning item without content field returns None.""" + item = { + "type": "reasoning", + "id": "rs_123", + "summary": [{"type": "summary_text", "text": "Thinking about math"}], + } + + msg = response_input_to_harmony(item, prev_responses=[]) + + assert msg is None + + def test_reasoning_with_none_content_returns_none(self): + """Test reasoning item with content=None returns None.""" + item = { + "type": "reasoning", + "id": "rs_123", + "content": None, + "summary": [{"type": "summary_text", "text": "Thinking about math"}], + } + + msg = response_input_to_harmony(item, prev_responses=[]) + + assert msg is None + + def test_reasoning_with_empty_content_returns_none(self): + """Test reasoning item with empty content list returns None.""" + item = { + "type": "reasoning", + "id": "rs_123", + "content": [], + } + + msg = response_input_to_harmony(item, prev_responses=[]) + + assert msg is None diff --git a/vllm/entrypoints/openai/responses/harmony.py b/vllm/entrypoints/openai/responses/harmony.py index 460f310926ad..faab2f7f4cc7 100644 --- a/vllm/entrypoints/openai/responses/harmony.py +++ b/vllm/entrypoints/openai/responses/harmony.py @@ -138,8 +138,12 @@ def _parse_chat_format_message(chat_msg: dict) -> list[Message]: def response_input_to_harmony( response_msg: ResponseInputOutputItem, prev_responses: list[ResponseOutputItem | ResponseReasoningItem], -) -> Message: - """Convert a single ResponseInputOutputItem into a Harmony Message.""" +) -> Message | None: + """Convert a single ResponseInputOutputItem into a Harmony Message. + + Returns None for reasoning items with empty or absent content so + the caller can skip them. + """ if not isinstance(response_msg, dict): response_msg = response_msg.model_dump() if "type" not in response_msg or response_msg["type"] == "message": @@ -172,9 +176,13 @@ def response_input_to_harmony( response_msg["output"], ) elif response_msg["type"] == "reasoning": - content = response_msg["content"] - assert len(content) == 1 - msg = Message.from_role_and_content(Role.ASSISTANT, content[0]["text"]) + content = response_msg.get("content") + if content and len(content) >= 1: + reasoning_text = "\n".join(item["text"] for item in content) + msg = Message.from_role_and_content(Role.ASSISTANT, reasoning_text) + msg = msg.with_channel("analysis") + else: + return None elif response_msg["type"] == "function_call": msg = Message.from_role_and_content(Role.ASSISTANT, response_msg["arguments"]) msg = msg.with_channel("commentary") diff --git a/vllm/entrypoints/openai/responses/serving.py b/vllm/entrypoints/openai/responses/serving.py index 3cfb6fffc3ea..ff49259b8871 100644 --- a/vllm/entrypoints/openai/responses/serving.py +++ b/vllm/entrypoints/openai/responses/serving.py @@ -1108,7 +1108,7 @@ def _construct_input_messages_with_harmony( prev_outputs = [] for response_msg in request.input: new_msg = response_input_to_harmony(response_msg, prev_outputs) - if new_msg.author.role != "system": + if new_msg is not None and new_msg.author.role != "system": messages.append(new_msg) # User passes in a tool call request and its output. We need