diff --git a/tests/entrypoints/openai/responses/test_harmony_utils.py b/tests/entrypoints/openai/responses/test_harmony_utils.py index f1434ce2bd58..b9540c5c1313 100644 --- a/tests/entrypoints/openai/responses/test_harmony_utils.py +++ b/tests/entrypoints/openai/responses/test_harmony_utils.py @@ -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, @@ -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: @@ -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 diff --git a/vllm/entrypoints/openai/responses/harmony.py b/vllm/entrypoints/openai/responses/harmony.py index 8dee0d993d5f..19fdfcf99893 100644 --- a/vllm/entrypoints/openai/responses/harmony.py +++ b/vllm/entrypoints/openai/responses/harmony.py @@ -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, @@ -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) @@ -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 [] diff --git a/vllm/entrypoints/openai/responses/streaming_events.py b/vllm/entrypoints/openai/responses/streaming_events.py index 7447347fba66..472c0f79a9b3 100644 --- a/vllm/entrypoints/openai/responses/streaming_events.py +++ b/vllm/entrypoints/openai/responses/streaming_events.py @@ -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, @@ -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 @@ -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)