Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions tests/entrypoints/openai/parser/test_harmony_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
parse_chat_output,
)
from vllm.entrypoints.openai.responses.harmony import (
response_input_to_harmony,
response_previous_input_to_harmony,
)

Expand Down Expand Up @@ -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
18 changes: 13 additions & 5 deletions vllm/entrypoints/openai/responses/harmony.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion vllm/entrypoints/openai/responses/serving.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down