diff --git a/tests/entrypoints/openai/test_responses_parser_unified.py b/tests/entrypoints/openai/responses/test_parsable_context_unit.py similarity index 66% rename from tests/entrypoints/openai/test_responses_parser_unified.py rename to tests/entrypoints/openai/responses/test_parsable_context_unit.py index 231ccf34fc..0aadfbe99d 100644 --- a/tests/entrypoints/openai/test_responses_parser_unified.py +++ b/tests/entrypoints/openai/responses/test_parsable_context_unit.py @@ -1,10 +1,9 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Unit tests for ResponsesParser with the unified Parser interface. +"""Unit tests for ParsableContext's parsing behavior. -These tests verify that ResponsesParser correctly delegates to the unified -Parser (via parse) instead of calling separate ReasoningParser / ToolParser -instances directly. +These tests verify that ParsableContext correctly delegates to the unified +Parser (via parse) and properly builds response output items. """ from collections.abc import Sequence @@ -18,12 +17,9 @@ from vllm.entrypoints.openai.engine.protocol import ( FunctionCall, ToolCall, ) -from vllm.entrypoints.openai.parser.responses_parser import ( - ResponsesParser, - get_responses_parser_for_simple_context, -) +from vllm.entrypoints.openai.responses.context import ParsableContext from vllm.entrypoints.openai.responses.protocol import ResponsesRequest -from vllm.outputs import CompletionOutput +from vllm.outputs import CompletionOutput, RequestOutput from vllm.parser.abstract_parser import DelegatingParser pytestmark = pytest.mark.skip_global_cleanup @@ -162,32 +158,42 @@ def _make_request(**overrides) -> ResponsesRequest: return ResponsesRequest.model_validate(defaults) -def _make_output( +def _make_request_output( text: str = "Hello, world!", token_ids: Sequence[int] = (1, 2, 3), finish_reason: str = "stop", -) -> CompletionOutput: - return CompletionOutput( - index=0, - text=text, - token_ids=list(token_ids), - cumulative_logprob=None, - logprobs=None, - finish_reason=finish_reason, +) -> RequestOutput: + return RequestOutput( + request_id="test", + prompt=None, + prompt_token_ids=[], + prompt_logprobs=None, + outputs=[ + CompletionOutput( + index=0, + text=text, + token_ids=list(token_ids), + cumulative_logprob=None, + logprobs=None, + finish_reason=finish_reason, + ) + ], + finished=True, ) -def _make_parser(parser_cls, **overrides): +def _make_context(parser_cls, **overrides): defaults = dict( tokenizer=MagicMock(), parser_cls=parser_cls, response_messages=[], request=_make_request(), + available_tools=None, chat_template=None, chat_template_content_format="auto", ) defaults.update(overrides) - return ResponsesParser(**defaults) + return ParsableContext(**defaults) # --------------------------------------------------------------------------- @@ -197,22 +203,22 @@ def _make_parser(parser_cls, **overrides): def test_process_text_with_parser(): """Parser with no reasoning/tools returns a single message item.""" - parser = _make_parser(_NoOpParser) - parser.process(_make_output(text="Hello!")) + ctx = _make_context(_NoOpParser) + ctx.append_output(_make_request_output(text="Hello!")) - assert len(parser.response_messages) == 1 - msg = parser.response_messages[0] + assert len(ctx.response_messages) == 1 + msg = ctx.response_messages[0] assert msg.type == "message" assert msg.content[0].text == "Hello!" def test_process_text_without_parser(): """parser_cls=None falls back to plain text wrapping.""" - parser = _make_parser(None) - parser.process(_make_output(text="Hello!")) + ctx = _make_context(None) + ctx.append_output(_make_request_output(text="Hello!")) - assert len(parser.response_messages) == 1 - msg = parser.response_messages[0] + assert len(ctx.response_messages) == 1 + msg = ctx.response_messages[0] assert msg.type == "message" assert msg.content[0].text == "Hello!" @@ -224,18 +230,18 @@ def test_process_text_without_parser(): def test_process_empty_text_without_parser(): """Empty text with no parser produces no output items.""" - parser = _make_parser(None) - parser.process(_make_output(text="")) + ctx = _make_context(None) + ctx.append_output(_make_request_output(text="")) - assert len(parser.response_messages) == 0 + assert len(ctx.response_messages) == 0 def test_process_empty_text_with_parser(): """Empty text with parser produces no output items.""" - parser = _make_parser(_NoOpParser) - parser.process(_make_output(text="")) + ctx = _make_context(_NoOpParser) + ctx.append_output(_make_request_output(text="")) - assert len(parser.response_messages) == 0 + assert len(ctx.response_messages) == 0 # --------------------------------------------------------------------------- @@ -245,26 +251,28 @@ def test_process_empty_text_with_parser(): def test_process_extracts_reasoning(): """Parser that finds reasoning produces both reasoning and message items.""" - parser = _make_parser(_ReasoningOnlyParser) - parser.process(_make_output(text="Let me checkThe answer is 42")) + ctx = _make_context(_ReasoningOnlyParser) + ctx.append_output( + _make_request_output(text="Let me checkThe answer is 42") + ) - types = [m.type for m in parser.response_messages] + types = [m.type for m in ctx.response_messages] assert "reasoning" in types assert "message" in types - reasoning_item = next(m for m in parser.response_messages if m.type == "reasoning") + reasoning_item = next(m for m in ctx.response_messages if m.type == "reasoning") assert reasoning_item.content[0].text == "Let me check" - message_item = next(m for m in parser.response_messages if m.type == "message") + message_item = next(m for m in ctx.response_messages if m.type == "message") assert message_item.content[0].text == "The answer is 42" def test_process_reasoning_only_no_content(): """When reasoning consumes all text, only a reasoning item is produced.""" - parser = _make_parser(_ReasoningOnlyParser) - parser.process(_make_output(text="Just thinking")) + ctx = _make_context(_ReasoningOnlyParser) + ctx.append_output(_make_request_output(text="Just thinking")) - types = [m.type for m in parser.response_messages] + types = [m.type for m in ctx.response_messages] assert "reasoning" in types assert "message" not in types @@ -286,13 +294,13 @@ def test_process_extracts_tool_calls(): } ], ) - parser = _make_parser(_ToolCallingParser, request=request, enable_auto_tools=True) - parser.process(_make_output(text="calling tool")) + ctx = _make_context(_ToolCallingParser, request=request, enable_auto_tools=True) + ctx.append_output(_make_request_output(text="calling tool")) - types = [m.type for m in parser.response_messages] + types = [m.type for m in ctx.response_messages] assert "function_call" in types - tool_item = next(m for m in parser.response_messages if m.type == "function_call") + tool_item = next(m for m in ctx.response_messages if m.type == "function_call") assert tool_item.name == "get_weather" assert tool_item.arguments == '{"location": "Paris"}' assert tool_item.status == "completed" @@ -304,15 +312,15 @@ def test_process_extracts_tool_calls(): def test_finish_reason_tracked(): - """finish_reason from CompletionOutput is stored on the parser.""" - parser = _make_parser(_NoOpParser) - assert parser.finish_reason is None + """finish_reason from CompletionOutput is stored on the context.""" + ctx = _make_context(_NoOpParser) + assert ctx.finish_reason is None - parser.process(_make_output(finish_reason="stop")) - assert parser.finish_reason == "stop" + ctx.append_output(_make_request_output(finish_reason="stop")) + assert ctx.finish_reason == "stop" - parser.process(_make_output(finish_reason="length")) - assert parser.finish_reason == "length" + ctx.append_output(_make_request_output(finish_reason="length")) + assert ctx.finish_reason == "length" # --------------------------------------------------------------------------- @@ -321,62 +329,27 @@ def test_finish_reason_tracked(): def test_multi_turn_accumulation(): - """Multiple process() calls accumulate response_messages.""" - parser = _make_parser(_NoOpParser) + """Multiple append_output() calls accumulate response_messages.""" + ctx = _make_context(_NoOpParser) - parser.process(_make_output(text="First turn")) - parser.process(_make_output(text="Second turn")) + ctx.append_output(_make_request_output(text="First turn")) + ctx.append_output(_make_request_output(text="Second turn")) - assert len(parser.response_messages) == 2 - texts = [m.content[0].text for m in parser.response_messages] + assert len(ctx.response_messages) == 2 + texts = [m.content[0].text for m in ctx.response_messages] assert texts == ["First turn", "Second turn"] def test_num_init_messages_offset(): """Initial messages are preserved and offset works correctly.""" init_messages = [MagicMock(type="message")] - parser = _make_parser(_NoOpParser, response_messages=init_messages) + ctx = _make_context(_NoOpParser, response_messages=init_messages) - assert parser.num_init_messages == 1 + assert ctx.num_init_messages == 1 - parser.process(_make_output(text="New output")) + ctx.append_output(_make_request_output(text="New output")) - assert len(parser.response_messages) == 2 - items = parser.make_response_output_items_from_parsable_context() + assert len(ctx.response_messages) == 2 + items = ctx.make_response_output_items() assert len(items) == 1 assert items[0].type == "message" - - -# --------------------------------------------------------------------------- -# Tests: factory function -# --------------------------------------------------------------------------- - - -def test_factory_function_creates_parser(): - """get_responses_parser_for_simple_context returns a working parser.""" - rp = get_responses_parser_for_simple_context( - tokenizer=MagicMock(), - parser_cls=_NoOpParser, - response_messages=[], - request=_make_request(), - chat_template=None, - chat_template_content_format="auto", - ) - assert isinstance(rp, ResponsesParser) - - rp.process(_make_output(text="Works!")) - assert len(rp.response_messages) == 1 - - -def test_factory_function_none_parser(): - """Factory function works with parser_cls=None.""" - rp = get_responses_parser_for_simple_context( - tokenizer=MagicMock(), - parser_cls=None, - response_messages=[], - request=_make_request(), - chat_template=None, - chat_template_content_format="auto", - ) - assert isinstance(rp, ResponsesParser) - assert rp.parser_instance is None diff --git a/vllm/entrypoints/mcp/tool.py b/vllm/entrypoints/mcp/tool.py index 9533a1b2d2..cd25aef087 100644 --- a/vllm/entrypoints/mcp/tool.py +++ b/vllm/entrypoints/mcp/tool.py @@ -159,7 +159,7 @@ class HarmonyPythonTool(Tool): assert isinstance(context, ParsableContext) - last_msg = context.parser.response_messages[-1] + last_msg = context.response_messages[-1] args = json.loads(last_msg.arguments) last_msg_harmony = Message( diff --git a/vllm/entrypoints/openai/parser/responses_parser.py b/vllm/entrypoints/openai/parser/responses_parser.py deleted file mode 100644 index 810019a053..0000000000 --- a/vllm/entrypoints/openai/parser/responses_parser.py +++ /dev/null @@ -1,180 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -import logging -from typing import Any - -from openai.types.responses import ResponseFunctionToolCall, ResponseOutputItem -from openai.types.responses.response_function_tool_call_output_item import ( - ResponseFunctionToolCallOutputItem, -) -from openai.types.responses.response_output_item import McpCall -from openai.types.responses.response_output_message import ResponseOutputMessage -from openai.types.responses.response_output_text import ResponseOutputText - -from vllm.entrypoints.chat_utils import ChatTemplateContentFormatOption -from vllm.entrypoints.openai.responses.protocol import ( - ResponseInputOutputItem, - ResponsesRequest, -) -from vllm.entrypoints.openai.responses.utils import build_response_output_items -from vllm.entrypoints.serve.utils.constants import MCP_PREFIX -from vllm.outputs import CompletionOutput -from vllm.parser.abstract_parser import Parser -from vllm.tokenizers import TokenizerLike -from vllm.utils import random_uuid - -logger = logging.getLogger(__name__) - - -class ResponsesParser: - """Incremental parser over completion tokens with reasoning support.""" - - def __init__( - self, - *, - tokenizer: TokenizerLike, - parser_cls: type[Parser] | None, - response_messages: list[ResponseInputOutputItem], - request: ResponsesRequest, - chat_template: str | None, - chat_template_content_format: ChatTemplateContentFormatOption, - enable_auto_tools: bool = False, - tool_call_id_type: str = "random", - ): - self.response_messages: list[ResponseInputOutputItem] = ( - # TODO: initial messages may not be properly typed - response_messages - ) - self.num_init_messages = len(response_messages) - self.tokenizer = tokenizer - self.request = request - - self.parser_instance: Parser | None = None - if parser_cls is not None: - chat_template_kwargs = _effective_chat_template_kwargs( - request, - chat_template=chat_template, - chat_template_content_format=chat_template_content_format, - ) - - self.parser_instance = parser_cls( - tokenizer, - tools=request.tools, - chat_template_kwargs=chat_template_kwargs, - ) - - self.enable_auto_tools = enable_auto_tools - self.tool_call_id_type = tool_call_id_type - - # Store the last finish_reason to determine response status - self.finish_reason: str | None = None - - def process(self, output: CompletionOutput) -> "ResponsesParser": - # Store the finish_reason from the output - self.finish_reason = output.finish_reason - - if self.parser_instance is not None: - reasoning, content, tool_calls = self.parser_instance.parse( - output.text, - self.request, - enable_auto_tools=self.enable_auto_tools, - ) - output_items = build_response_output_items( - reasoning=reasoning, - content=content, - tool_calls=tool_calls, - tool_call_id_type=self.tool_call_id_type, - ) - self.response_messages.extend(output_items) - else: - # No parser configured, treat entire output as text content - if output.text: - self.response_messages.append( - ResponseOutputMessage( - type="message", - id=f"msg_{random_uuid()}", - status="completed", - role="assistant", - content=[ - ResponseOutputText( - annotations=[], # TODO - type="output_text", - text=output.text, - logprobs=None, # TODO - ) - ], - ) - ) - - return self - - def make_response_output_items_from_parsable_context( - self, - ) -> list[ResponseOutputItem]: - """Given a list of sentences, construct ResponseOutput Items.""" - response_messages = self.response_messages[self.num_init_messages :] - output_messages: list[ResponseOutputItem] = [] - for message in response_messages: - if not isinstance(message, ResponseFunctionToolCallOutputItem): - output_messages.append(message) - else: - if len(output_messages) == 0: - raise ValueError( - "Cannot have a FunctionToolCallOutput before FunctionToolCall." - ) - if isinstance(output_messages[-1], ResponseFunctionToolCall): - mcp_message = McpCall( - id=f"{MCP_PREFIX}{random_uuid()}", - arguments=output_messages[-1].arguments, - name=output_messages[-1].name, - server_label=output_messages[ - -1 - ].name, # TODO: store the server label - type="mcp_call", - status="completed", - output=message.output, - # TODO: support error output - ) - output_messages[-1] = mcp_message - - return output_messages - - -def get_responses_parser_for_simple_context( - *, - tokenizer: TokenizerLike, - parser_cls: type[Parser] | None, - response_messages: list[ResponseInputOutputItem], - request: ResponsesRequest, - chat_template: str | None, - chat_template_content_format: ChatTemplateContentFormatOption, - enable_auto_tools: bool = False, - tool_call_id_type: str = "random", -) -> ResponsesParser: - """Factory function to create a ResponsesParser with - optional unified parser. - - Returns: - ResponsesParser instance configured with the provided parser - """ - return ResponsesParser( - tokenizer=tokenizer, - parser_cls=parser_cls, - response_messages=response_messages, - request=request, - chat_template=chat_template, - chat_template_content_format=chat_template_content_format, - enable_auto_tools=enable_auto_tools, - tool_call_id_type=tool_call_id_type, - ) - - -def _effective_chat_template_kwargs( - request: ResponsesRequest, - chat_template: str | None, - chat_template_content_format: ChatTemplateContentFormatOption, -) -> dict[str, Any]: - return request.build_chat_params( - default_template=chat_template, - default_template_content_format=chat_template_content_format, - ).chat_template_kwargs diff --git a/vllm/entrypoints/openai/responses/context.py b/vllm/entrypoints/openai/responses/context.py index e72032c24a..9679b732a7 100644 --- a/vllm/entrypoints/openai/responses/context.py +++ b/vllm/entrypoints/openai/responses/context.py @@ -10,9 +10,13 @@ from contextlib import AsyncExitStack from dataclasses import replace from typing import TYPE_CHECKING, Any, Final, Union +from openai.types.responses import ResponseFunctionToolCall, ResponseOutputItem from openai.types.responses.response_function_tool_call_output_item import ( ResponseFunctionToolCallOutputItem, ) +from openai.types.responses.response_output_item import McpCall +from openai.types.responses.response_output_message import ResponseOutputMessage +from openai.types.responses.response_output_text import ResponseOutputText from openai.types.responses.tool import Mcp from openai_harmony import Author, Message, Role, StreamState, TextContent @@ -30,15 +34,15 @@ from vllm.entrypoints.openai.parser.harmony_utils import ( get_streamable_parser_for_assistant, render_for_completion, ) -from vllm.entrypoints.openai.parser.responses_parser import ( - get_responses_parser_for_simple_context, -) from vllm.entrypoints.openai.responses.protocol import ( ResponseInputOutputItem, ResponseRawMessageAndToken, ResponsesRequest, ) -from vllm.entrypoints.openai.responses.utils import construct_tool_dicts +from vllm.entrypoints.openai.responses.utils import ( + build_response_output_items, + construct_tool_dicts, +) from vllm.entrypoints.serve.utils.constants import MCP_PREFIX from vllm.outputs import RequestOutput from vllm.parser.abstract_parser import Parser @@ -286,16 +290,24 @@ class ParsableContext(ConversationContext): # not implemented yet for ParsableContext self.all_turn_metrics: list[TurnMetrics] = [] - self.parser = get_responses_parser_for_simple_context( - tokenizer=tokenizer, - parser_cls=parser_cls, - response_messages=response_messages, - request=request, - chat_template=chat_template, - chat_template_content_format=chat_template_content_format, - enable_auto_tools=enable_auto_tools, - tool_call_id_type=tool_call_id_type, - ) + self.response_messages: list[ResponseInputOutputItem] = response_messages + self.num_init_messages = len(response_messages) + self.finish_reason: str | None = None + self.enable_auto_tools = enable_auto_tools + self.tool_call_id_type = tool_call_id_type + + self.parser_instance: Parser | None = None + if parser_cls is not None: + chat_template_kwargs = request.build_chat_params( + default_template=chat_template, + default_template_content_format=chat_template_content_format, + ).chat_template_kwargs + self.parser_instance = parser_cls( + tokenizer, + tools=request.tools, + chat_template_kwargs=chat_template_kwargs, + ) + self.parser_cls = parser_cls self.request = request @@ -318,11 +330,44 @@ class ParsableContext(ConversationContext): self.num_output_tokens += len(output.outputs[0].token_ids or []) if output.kv_transfer_params is not None: self.kv_transfer_params = output.kv_transfer_params - self.parser.process(output.outputs[0]) - output_token_ids = output.outputs[0].token_ids or [] - self._accumulated_token_ids.extend(output_token_ids) - # only store if enable_response_messages is True, save memory + completion = output.outputs[0] + self.finish_reason = completion.finish_reason + + if self.parser_instance is not None: + reasoning, content, tool_calls = self.parser_instance.parse( + completion.text, + self.request, + enable_auto_tools=self.enable_auto_tools, + ) + self.response_messages.extend( + build_response_output_items( + reasoning=reasoning, + content=content, + tool_calls=tool_calls, + tool_call_id_type=self.tool_call_id_type, + ) + ) + elif completion.text: + self.response_messages.append( + ResponseOutputMessage( + type="message", + id=f"msg_{random_uuid()}", + status="completed", + role="assistant", + content=[ + ResponseOutputText( + annotations=[], + type="output_text", + text=completion.text, + logprobs=None, + ) + ], + ) + ) + + self._accumulated_token_ids.extend(completion.token_ids or []) + if self.request.enable_response_messages: output_prompt = output.prompt or "" output_prompt_token_ids = output.prompt_token_ids or [] @@ -342,18 +387,18 @@ class ParsableContext(ConversationContext): ) self.output_messages.append( ResponseRawMessageAndToken( - message=output.outputs[0].text, - tokens=output.outputs[0].token_ids, + message=completion.text, + tokens=completion.token_ids, ) ) def append_tool_output(self, output: list[ResponseInputOutputItem]) -> None: - self.parser.response_messages.extend(output) + self.response_messages.extend(output) def need_builtin_tool_call(self) -> bool: """Return true if the last message is a builtin tool call that the request has enabled.""" - last_message = self.parser.response_messages[-1] + last_message = self.response_messages[-1] if last_message.type != "function_call": return False if last_message.name in ("code_interpreter", "python"): @@ -457,12 +502,12 @@ class ParsableContext(ConversationContext): return [message] async def call_tool(self) -> list[ResponseInputOutputItem]: - if not self.parser.response_messages: + if not self.response_messages: return [] - last_msg = self.parser.response_messages[-1] + last_msg = self.response_messages[-1] # change this to a mcp_ function call last_msg.id = f"{MCP_PREFIX}{random_uuid()}" - self.parser.response_messages[-1] = last_msg + self.response_messages[-1] = last_msg if last_msg.name == "code_interpreter": return await self.call_python_tool(self._tool_sessions["python"], last_msg) elif last_msg.name == "web_search_preview": @@ -473,6 +518,29 @@ class ParsableContext(ConversationContext): ) return [] + def make_response_output_items(self) -> list[ResponseOutputItem]: + response_messages = self.response_messages[self.num_init_messages :] + output_messages: list[ResponseOutputItem] = [] + for message in response_messages: + if not isinstance(message, ResponseFunctionToolCallOutputItem): + output_messages.append(message) + else: + if len(output_messages) == 0: + raise ValueError( + "Cannot have a FunctionToolCallOutput before FunctionToolCall." + ) + if isinstance(output_messages[-1], ResponseFunctionToolCall): + output_messages[-1] = McpCall( + id=f"{MCP_PREFIX}{random_uuid()}", + arguments=output_messages[-1].arguments, + name=output_messages[-1].name, + server_label=output_messages[-1].name, + type="mcp_call", + status="completed", + output=message.output, + ) + return output_messages + def render_for_completion(self): raise NotImplementedError("Should not be called.") diff --git a/vllm/entrypoints/openai/responses/serving.py b/vllm/entrypoints/openai/responses/serving.py index 5b830cf6dc..9d95ccc0cb 100644 --- a/vllm/entrypoints/openai/responses/serving.py +++ b/vllm/entrypoints/openai/responses/serving.py @@ -702,7 +702,7 @@ class OpenAIServingResponses(OpenAIServing): elif isinstance(context, ParsableContext): (engine_input,) = await self._render_next_turn( context.request, - context.parser.response_messages, + context.response_messages, context.tool_dicts, context.parser_cls, context.chat_template, @@ -805,7 +805,7 @@ class OpenAIServingResponses(OpenAIServing): else: status = "incomplete" elif isinstance(context, ParsableContext): - output = context.parser.make_response_output_items_from_parsable_context() + output = context.make_response_output_items() if request.enable_response_messages: input_messages = context.input_messages @@ -816,7 +816,7 @@ class OpenAIServingResponses(OpenAIServing): num_tool_output_tokens = 0 # Check finish reason from the parser - if context.parser.finish_reason == "length": + if context.finish_reason == "length": status = "incomplete" else: assert isinstance(context, SimpleContext)