[Refactor] Deprecate ResponsesParser wrapper, inline parsing into ParsableContext (#45431)

Signed-off-by: sfeng33 <4florafeng@gmail.com>
This commit is contained in:
Flora Feng
2026-06-12 16:15:41 -04:00
committed by GitHub
parent aab639c705
commit 6e4a547176
5 changed files with 169 additions and 308 deletions
@@ -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="<think>Let me check</think>The answer is 42"))
ctx = _make_context(_ReasoningOnlyParser)
ctx.append_output(
_make_request_output(text="<think>Let me check</think>The 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="<think>Just thinking</think>"))
ctx = _make_context(_ReasoningOnlyParser)
ctx.append_output(_make_request_output(text="<think>Just thinking</think>"))
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
+1 -1
View File
@@ -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(
@@ -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
+93 -25
View File
@@ -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.")
+3 -3
View File
@@ -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)