Skip to content
Open
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
57 changes: 57 additions & 0 deletions tests/entrypoints/openai/responses/test_harmony_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for vllm.entrypoints.openai.responses.harmony."""

from types import SimpleNamespace

from openai.types.responses import (
ResponseFunctionToolCall,
ResponseOutputMessage,
Expand All @@ -10,11 +12,20 @@
from openai.types.responses.response_output_item import McpCall
from openai_harmony import Author, Message, Role, TextContent

from vllm.entrypoints.openai.parser.harmony_utils import (
get_encoding,
get_streamable_parser_for_assistant,
)
from vllm.entrypoints.openai.responses.harmony import (
harmony_to_response_output,
parser_state_to_response_output,
response_previous_input_to_harmony,
)
from vllm.entrypoints.openai.responses.streaming_events import (
StreamingState,
emit_content_delta_events,
emit_previous_item_done_events,
)


class TestResponsePreviousInputToHarmony:
Expand Down Expand Up @@ -631,3 +642,49 @@ def test_parser_state_to_response_output_analysis_channel() -> None:
assert len(builtin_items) == 1
assert not isinstance(builtin_items[0], McpCall)
assert builtin_items[0].type == "reasoning"


class TestConstrainedOutputWithoutRecipient:
@staticmethod
def _parse(text: str):
parser = get_streamable_parser_for_assistant()
for token in get_encoding().encode(text, allowed_special="all"):
parser.process(token)
return parser

def test_completed_output(self) -> None:
parser = self._parse(
'<|channel|>final <|constrain|>json<|message|>{"result":true}<|return|>',
)

message = parser.messages[0]
output_items = harmony_to_response_output(message)
assert len(output_items) == 1
assert isinstance(output_items[0], ResponseOutputMessage)
assert output_items[0].content[0].text == '{"result":true}'

state = StreamingState(
current_content_index=0,
current_item_id="msg_test",
)
events = emit_previous_item_done_events(message, state)
assert "response.output_text.done" in [event.type for event in events]

def test_in_progress_output(self) -> None:
parser = self._parse(
'<|channel|>final <|constrain|>json<|message|>{"result":true}',
)

output_items = parser_state_to_response_output(parser)
assert len(output_items) == 1
assert isinstance(output_items[0], ResponseOutputMessage)
assert output_items[0].content[0].text == '{"result":true}'

context = SimpleNamespace(
last_content_delta=parser.current_content,
parser=parser,
function_tool_names=None,
)
events = emit_content_delta_events(context, StreamingState())
event_types = [event.type for event in events]
assert "response.output_text.delta" in event_types
11 changes: 9 additions & 2 deletions vllm/entrypoints/openai/responses/harmony.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,13 @@ def _parse_message_no_recipient(
# ---------------------------------------------------------------------------


def get_response_output_recipient(recipient: str | None) -> str | None:
"""Exclude constrained formats misparsed as recipients by older Harmony."""
if recipient and recipient.startswith("<|constrain|>"):
return None
return recipient


def harmony_to_response_output(
message: Message,
function_tool_names: frozenset[str] | None = None,
Expand All @@ -439,7 +446,7 @@ def harmony_to_response_output(
return []

output_items: list[ResponseOutputItem] = []
recipient = message.recipient
recipient = get_response_output_recipient(message.recipient)

if recipient is not None:
# Browser tool calls (browser.search, browser.open, browser.find)
Expand Down Expand Up @@ -478,7 +485,7 @@ def parser_state_to_response_output(
return []
if parser.current_role != Role.ASSISTANT:
return []
current_recipient = parser.current_recipient
current_recipient = get_response_output_recipient(parser.current_recipient)
if current_recipient is not None and current_recipient.startswith("browser."):
return []

Expand Down
5 changes: 3 additions & 2 deletions vllm/entrypoints/openai/responses/streaming_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
is_function_recipient,
)
from vllm.entrypoints.openai.responses.context import StreamingHarmonyContext
from vllm.entrypoints.openai.responses.harmony import get_response_output_recipient
from vllm.entrypoints.openai.responses.protocol import (
ResponseReasoningPartAddedEvent,
ResponseReasoningPartDoneEvent,
Expand Down Expand Up @@ -571,7 +572,7 @@ def emit_content_delta_events(
return []

channel = ctx.parser.current_channel
recipient = ctx.parser.current_recipient
recipient = get_response_output_recipient(ctx.parser.current_recipient)

if channel in ("final", "commentary") and recipient is None:
# Preambles (commentary with no recipient) and final messages
Expand Down Expand Up @@ -605,7 +606,7 @@ def emit_previous_item_done_events(
Harmony parser's message object and delegates to shared leaf helpers.
"""
text = previous_item.content[0].text
if previous_item.recipient is not None:
if get_response_output_recipient(previous_item.recipient) is not None:
# Deal with tool call
if is_function_recipient(previous_item.recipient, function_tool_names):
function_name = extract_function_from_recipient(previous_item.recipient)
Expand Down
Loading