@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / e30313220c
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 2, 06:00 UTC

[Parser] Migrate `ResponsesParser` to unified `Parser` interface (#42977)

Signed-off-by: Alberto Perdomo <aperdomo@redhat.com>

alberto committed Jun 2, 2026 at 09:50 UTC e30313220c43fcaa74cab666092844eceb8e39d7
4 files changed +442 -94
tests/entrypoints/openai/test_responses_parser_unified.py new
+382
@@ -0,0 +1,382 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Unit tests for ResponsesParser with the unified Parser interface.
4 +
5 +These tests verify that ResponsesParser correctly delegates to the unified
6 +Parser (via extract_response_outputs) instead of calling separate
7 +ReasoningParser / ToolParser instances directly.
8 +"""
9 +
10 +from collections.abc import Sequence
11 +from unittest.mock import MagicMock
12 +
13 +import pytest
14 +
15 +from vllm.entrypoints.openai.engine.protocol import (
16 + DeltaMessage,
17 + ExtractedToolCallInformation,
18 + FunctionCall,
19 + ToolCall,
20 +)
21 +from vllm.entrypoints.openai.parser.responses_parser import (
22 + ResponsesParser,
23 + get_responses_parser_for_simple_context,
24 +)
25 +from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
26 +from vllm.outputs import CompletionOutput
27 +from vllm.parser.abstract_parser import DelegatingParser
28 +
29 +pytestmark = pytest.mark.skip_global_cleanup
30 +
31 +
32 +# ---------------------------------------------------------------------------
33 +# Test parser stubs
34 +# ---------------------------------------------------------------------------
35 +
36 +
37 +class _NoOpParser(DelegatingParser):
38 + """Parser that extracts no reasoning and no tool calls."""
39 +
40 + def is_reasoning_end(self, input_ids: list[int]) -> bool:
41 + return False
42 +
43 + def extract_content_ids(self, input_ids: list[int]) -> list[int]:
44 + return input_ids
45 +
46 + def extract_reasoning(self, model_output, request):
47 + return None, model_output
48 +
49 + def extract_reasoning_streaming(self, *args, **kwargs):
50 + return None
51 +
52 + def extract_tool_calls(self, model_output, request):
53 + return ExtractedToolCallInformation(
54 + tools_called=False, tool_calls=[], content=model_output
55 + )
56 +
57 + def extract_tool_calls_streaming(self, *args, **kwargs):
58 + return None
59 +
60 + def parse_delta(self, *args, **kwargs) -> DeltaMessage | None:
61 + return None
62 +
63 +
64 +class _ReasoningOnlyParser(DelegatingParser):
65 + """Parser that extracts reasoning but no tool calls."""
66 +
67 + def is_reasoning_end(self, input_ids: list[int]) -> bool:
68 + return False
69 +
70 + def extract_content_ids(self, input_ids: list[int]) -> list[int]:
71 + return input_ids
72 +
73 + def extract_reasoning(self, model_output, request):
74 + if "<think>" in model_output and "</think>" in model_output:
75 + start = model_output.index("<think>") + len("<think>")
76 + end = model_output.index("</think>")
77 + reasoning = model_output[start:end]
78 + content = model_output[end + len("</think>") :]
79 + return reasoning, content.strip() or None
80 + return None, model_output
81 +
82 + def extract_reasoning_streaming(self, *args, **kwargs):
83 + return None
84 +
85 + def extract_tool_calls(self, model_output, request):
86 + return ExtractedToolCallInformation(
87 + tools_called=False, tool_calls=[], content=model_output
88 + )
89 +
90 + def extract_tool_calls_streaming(self, *args, **kwargs):
91 + return None
92 +
93 + def parse_delta(self, *args, **kwargs) -> DeltaMessage | None:
94 + return None
95 +
96 +
97 +class _StubToolParser:
98 + """Minimal tool parser stub that always returns a hardcoded tool call."""
99 +
100 + supports_required_and_named = False
101 +
102 + def __init__(self, tokenizer=None, tools=None):
103 + pass
104 +
105 + def extract_tool_calls(self, model_output, request):
106 + return ExtractedToolCallInformation(
107 + tools_called=True,
108 + tool_calls=[
109 + ToolCall(
110 + id="call_123",
111 + type="function",
112 + function=FunctionCall(
113 + name="get_weather",
114 + arguments='{"location": "Paris"}',
115 + ),
116 + )
117 + ],
118 + content=None,
119 + )
120 +
121 + def extract_tool_calls_streaming(self, *args, **kwargs):
122 + return None
123 +
124 + def adjust_request(self, request):
125 + return request
126 +
127 +
128 +class _ToolCallingParser(DelegatingParser):
129 + """Parser that extracts a hardcoded tool call from any input."""
130 +
131 + def __init__(self, tokenizer, *args, **kwargs):
132 + super().__init__(tokenizer)
133 + self._tool_parser = _StubToolParser()
134 +
135 + def is_reasoning_end(self, input_ids: list[int]) -> bool:
136 + return False
137 +
138 + def extract_content_ids(self, input_ids: list[int]) -> list[int]:
139 + return input_ids
140 +
141 + def extract_reasoning(self, model_output, request):
142 + return None, model_output
143 +
144 + def extract_reasoning_streaming(self, *args, **kwargs):
145 + return None
146 +
147 + def extract_tool_calls_streaming(self, *args, **kwargs):
148 + return None
149 +
150 + def parse_delta(self, *args, **kwargs) -> DeltaMessage | None:
151 + return None
152 +
153 +
154 +# ---------------------------------------------------------------------------
155 +# Helpers
156 +# ---------------------------------------------------------------------------
157 +
158 +
159 +def _make_request(**overrides) -> ResponsesRequest:
160 + defaults = {"model": "test-model", "input": "test"}
161 + defaults.update(overrides)
162 + return ResponsesRequest.model_validate(defaults)
163 +
164 +
165 +def _make_output(
166 + text: str = "Hello, world!",
167 + token_ids: Sequence[int] = (1, 2, 3),
168 + finish_reason: str = "stop",
169 +) -> CompletionOutput:
170 + return CompletionOutput(
171 + index=0,
172 + text=text,
173 + token_ids=list(token_ids),
174 + cumulative_logprob=None,
175 + logprobs=None,
176 + finish_reason=finish_reason,
177 + )
178 +
179 +
180 +def _make_parser(parser_cls, **overrides):
181 + defaults = dict(
182 + tokenizer=MagicMock(),
183 + parser_cls=parser_cls,
184 + response_messages=[],
185 + request=_make_request(),
186 + chat_template=None,
187 + chat_template_content_format="auto",
188 + )
189 + defaults.update(overrides)
190 + return ResponsesParser(**defaults)
191 +
192 +
193 +# ---------------------------------------------------------------------------
194 +# Tests: basic text passthrough
195 +# ---------------------------------------------------------------------------
196 +
197 +
198 +def test_process_text_with_parser():
199 + """Parser with no reasoning/tools returns a single message item."""
200 + parser = _make_parser(_NoOpParser)
201 + parser.process(_make_output(text="Hello!"))
202 +
203 + assert len(parser.response_messages) == 1
204 + msg = parser.response_messages[0]
205 + assert msg.type == "message"
206 + assert msg.content[0].text == "Hello!"
207 +
208 +
209 +def test_process_text_without_parser():
210 + """parser_cls=None falls back to plain text wrapping."""
211 + parser = _make_parser(None)
212 + parser.process(_make_output(text="Hello!"))
213 +
214 + assert len(parser.response_messages) == 1
215 + msg = parser.response_messages[0]
216 + assert msg.type == "message"
217 + assert msg.content[0].text == "Hello!"
218 +
219 +
220 +# ---------------------------------------------------------------------------
221 +# Tests: empty / whitespace output
222 +# ---------------------------------------------------------------------------
223 +
224 +
225 +def test_process_empty_text_without_parser():
226 + """Empty text with no parser produces no output items."""
227 + parser = _make_parser(None)
228 + parser.process(_make_output(text=""))
229 +
230 + assert len(parser.response_messages) == 0
231 +
232 +
233 +def test_process_empty_text_with_parser():
234 + """Empty text with parser produces no output items."""
235 + parser = _make_parser(_NoOpParser)
236 + parser.process(_make_output(text=""))
237 +
238 + assert len(parser.response_messages) == 0
239 +
240 +
241 +# ---------------------------------------------------------------------------
242 +# Tests: reasoning extraction
243 +# ---------------------------------------------------------------------------
244 +
245 +
246 +def test_process_extracts_reasoning():
247 + """Parser that finds reasoning produces both reasoning and message items."""
248 + parser = _make_parser(_ReasoningOnlyParser)
249 + parser.process(_make_output(text="<think>Let me check</think>The answer is 42"))
250 +
251 + types = [m.type for m in parser.response_messages]
252 + assert "reasoning" in types
253 + assert "message" in types
254 +
255 + reasoning_item = next(m for m in parser.response_messages if m.type == "reasoning")
256 + assert reasoning_item.content[0].text == "Let me check"
257 +
258 + message_item = next(m for m in parser.response_messages if m.type == "message")
259 + assert message_item.content[0].text == "The answer is 42"
260 +
261 +
262 +def test_process_reasoning_only_no_content():
263 + """When reasoning consumes all text, only a reasoning item is produced."""
264 + parser = _make_parser(_ReasoningOnlyParser)
265 + parser.process(_make_output(text="<think>Just thinking</think>"))
266 +
267 + types = [m.type for m in parser.response_messages]
268 + assert "reasoning" in types
269 + assert "message" not in types
270 +
271 +
272 +# ---------------------------------------------------------------------------
273 +# Tests: tool call extraction
274 +# ---------------------------------------------------------------------------
275 +
276 +
277 +def test_process_extracts_tool_calls():
278 + """Parser that finds tool calls produces function_call items."""
279 + request = _make_request(
280 + tool_choice="auto",
281 + tools=[
282 + {
283 + "type": "function",
284 + "name": "get_weather",
285 + "parameters": {"type": "object", "properties": {}},
286 + }
287 + ],
288 + )
289 + parser = _make_parser(_ToolCallingParser, request=request, enable_auto_tools=True)
290 + parser.process(_make_output(text="calling tool"))
291 +
292 + types = [m.type for m in parser.response_messages]
293 + assert "function_call" in types
294 +
295 + tool_item = next(m for m in parser.response_messages if m.type == "function_call")
296 + assert tool_item.name == "get_weather"
297 + assert tool_item.arguments == '{"location": "Paris"}'
298 + assert tool_item.status == "completed"
299 +
300 +
301 +# ---------------------------------------------------------------------------
302 +# Tests: finish_reason tracking
303 +# ---------------------------------------------------------------------------
304 +
305 +
306 +def test_finish_reason_tracked():
307 + """finish_reason from CompletionOutput is stored on the parser."""
308 + parser = _make_parser(_NoOpParser)
309 + assert parser.finish_reason is None
310 +
311 + parser.process(_make_output(finish_reason="stop"))
312 + assert parser.finish_reason == "stop"
313 +
314 + parser.process(_make_output(finish_reason="length"))
315 + assert parser.finish_reason == "length"
316 +
317 +
318 +# ---------------------------------------------------------------------------
319 +# Tests: multi-turn accumulation
320 +# ---------------------------------------------------------------------------
321 +
322 +
323 +def test_multi_turn_accumulation():
324 + """Multiple process() calls accumulate response_messages."""
325 + parser = _make_parser(_NoOpParser)
326 +
327 + parser.process(_make_output(text="First turn"))
328 + parser.process(_make_output(text="Second turn"))
329 +
330 + assert len(parser.response_messages) == 2
331 + texts = [m.content[0].text for m in parser.response_messages]
332 + assert texts == ["First turn", "Second turn"]
333 +
334 +
335 +def test_num_init_messages_offset():
336 + """Initial messages are preserved and offset works correctly."""
337 + init_messages = [MagicMock(type="message")]
338 + parser = _make_parser(_NoOpParser, response_messages=init_messages)
339 +
340 + assert parser.num_init_messages == 1
341 +
342 + parser.process(_make_output(text="New output"))
343 +
344 + assert len(parser.response_messages) == 2
345 + items = parser.make_response_output_items_from_parsable_context()
346 + assert len(items) == 1
347 + assert items[0].type == "message"
348 +
349 +
350 +# ---------------------------------------------------------------------------
351 +# Tests: factory function
352 +# ---------------------------------------------------------------------------
353 +
354 +
355 +def test_factory_function_creates_parser():
356 + """get_responses_parser_for_simple_context returns a working parser."""
357 + rp = get_responses_parser_for_simple_context(
358 + tokenizer=MagicMock(),
359 + parser_cls=_NoOpParser,
360 + response_messages=[],
361 + request=_make_request(),
362 + chat_template=None,
363 + chat_template_content_format="auto",
364 + )
365 + assert isinstance(rp, ResponsesParser)
366 +
367 + rp.process(_make_output(text="Works!"))
368 + assert len(rp.response_messages) == 1
369 +
370 +
371 +def test_factory_function_none_parser():
372 + """Factory function works with parser_cls=None."""
373 + rp = get_responses_parser_for_simple_context(
374 + tokenizer=MagicMock(),
375 + parser_cls=None,
376 + response_messages=[],
377 + request=_make_request(),
378 + chat_template=None,
379 + chat_template_content_format="auto",
380 + )
381 + assert isinstance(rp, ResponsesParser)
382 + assert rp.parser_instance is None
vllm/entrypoints/openai/parser/responses_parser.py
+48 -77
@@ -10,10 +10,6 @@ from openai.types.responses.response_function_tool_call_output_item import (
10 from openai.types.responses.response_output_item import McpCall
11 from openai.types.responses.response_output_message import ResponseOutputMessage
12 from openai.types.responses.response_output_text import ResponseOutputText
13 -from openai.types.responses.response_reasoning_item import (
14 - Content,
15 - ResponseReasoningItem,
16 -)
13
14 from vllm.entrypoints.chat_utils import ChatTemplateContentFormatOption
15 from vllm.entrypoints.constants import MCP_PREFIX
@@ -22,9 +18,8 @@ from vllm.entrypoints.openai.responses.protocol import (
18 ResponsesRequest,
19 )
20 from vllm.outputs import CompletionOutput
25 -from vllm.reasoning.abs_reasoning_parsers import ReasoningParser
21 +from vllm.parser.abstract_parser import Parser
22 from vllm.tokenizers import TokenizerLike
27 -from vllm.tool_parsers.abstract_tool_parser import ToolParser
23 from vllm.utils import random_uuid
24
25 logger = logging.getLogger(__name__)
@@ -37,12 +32,13 @@ class ResponsesParser:
32 self,
33 *,
34 tokenizer: TokenizerLike,
40 - reasoning_parser_cls: type[ReasoningParser],
35 + parser_cls: type[Parser] | None,
36 response_messages: list[ResponseInputOutputItem],
37 request: ResponsesRequest,
43 - tool_parser_cls: type[ToolParser] | None,
38 chat_template: str | None,
39 chat_template_content_format: ChatTemplateContentFormatOption,
40 + enable_auto_tools: bool = False,
41 + tool_call_id_type: str = "random",
42 ):
43 self.response_messages: list[ResponseInputOutputItem] = (
44 # TODO: initial messages may not be properly typed
@@ -52,17 +48,22 @@ class ResponsesParser:
48 self.tokenizer = tokenizer
49 self.request = request
50
55 - self.reasoning_parser_instance = reasoning_parser_cls(
56 - tokenizer,
57 - chat_template_kwargs=_effective_chat_template_kwargs(
51 + self.parser_instance: Parser | None = None
52 + if parser_cls is not None:
53 + chat_template_kwargs = _effective_chat_template_kwargs(
54 request,
55 chat_template=chat_template,
56 chat_template_content_format=chat_template_content_format,
61 - ),
62 - )
63 - self.tool_parser_instance = None
64 - if tool_parser_cls is not None:
65 - self.tool_parser_instance = tool_parser_cls(tokenizer, request.tools)
57 + )
58 +
59 + self.parser_instance = parser_cls(
60 + tokenizer,
61 + tools=request.tools,
62 + chat_template_kwargs=chat_template_kwargs,
63 + )
64 +
65 + self.enable_auto_tools = enable_auto_tools
66 + self.tool_call_id_type = tool_call_id_type
67
68 # Store the last finish_reason to determine response status
69 self.finish_reason: str | None = None
@@ -71,66 +72,34 @@ class ResponsesParser:
72 # Store the finish_reason from the output
73 self.finish_reason = output.finish_reason
74
74 - reasoning, content = self.reasoning_parser_instance.extract_reasoning(
75 - output.text, request=self.request
76 - )
77 - if reasoning:
78 - self.response_messages.append(
79 - ResponseReasoningItem(
80 - type="reasoning",
81 - id=f"rs_{random_uuid()}",
82 - summary=[],
83 - content=[
84 - Content(
85 - type="reasoning_text",
86 - text=reasoning,
87 - )
88 - ],
89 - )
90 - )
91 -
92 - function_calls: list[ResponseFunctionToolCall] = []
93 - if self.tool_parser_instance is not None:
94 - tool_call_info = self.tool_parser_instance.extract_tool_calls(
95 - content if content is not None else "",
96 - request=self.request, # type: ignore
75 + if self.parser_instance is not None:
76 + output_items = self.parser_instance.extract_response_outputs(
77 + model_output=output.text,
78 + model_output_token_ids=output.token_ids,
79 + request=self.request,
80 + enable_auto_tools=self.enable_auto_tools,
81 + tool_call_id_type=self.tool_call_id_type,
82 )
98 - if tool_call_info is not None and tool_call_info.tools_called:
99 - # extract_tool_calls() returns a list of tool calls.
100 - function_calls.extend(
101 - ResponseFunctionToolCall(
102 - id=f"fc_{random_uuid()}",
103 - call_id=f"call_{random_uuid()}",
104 - type="function_call",
83 + self.response_messages.extend(output_items)
84 + else:
85 + # No parser configured, treat entire output as text content
86 + if output.text:
87 + self.response_messages.append(
88 + ResponseOutputMessage(
89 + type="message",
90 + id=f"msg_{random_uuid()}",
91 status="completed",
106 - name=tool_call.function.name,
107 - arguments=tool_call.function.arguments,
92 + role="assistant",
93 + content=[
94 + ResponseOutputText(
95 + annotations=[], # TODO
96 + type="output_text",
97 + text=output.text,
98 + logprobs=None, # TODO
99 + )
100 + ],
101 )
109 - for tool_call in tool_call_info.tool_calls
110 - )
111 - content = tool_call_info.content
112 - if content and content.strip() == "":
113 - content = None
114 -
115 - if content:
116 - self.response_messages.append(
117 - ResponseOutputMessage(
118 - type="message",
119 - id=f"msg_{random_uuid()}",
120 - status="completed",
121 - role="assistant",
122 - content=[
123 - ResponseOutputText(
124 - annotations=[], # TODO
125 - type="output_text",
126 - text=content,
127 - logprobs=None, # TODO
128 - )
129 - ],
102 )
131 - )
132 - if len(function_calls) > 0:
133 - self.response_messages.extend(function_calls)
103
104 return self
105
@@ -169,27 +138,29 @@ class ResponsesParser:
138 def get_responses_parser_for_simple_context(
139 *,
140 tokenizer: TokenizerLike,
172 - reasoning_parser_cls: type[ReasoningParser],
141 + parser_cls: type[Parser] | None,
142 response_messages: list[ResponseInputOutputItem],
143 request: ResponsesRequest,
175 - tool_parser_cls,
144 chat_template: str | None,
145 chat_template_content_format: ChatTemplateContentFormatOption,
146 + enable_auto_tools: bool = False,
147 + tool_call_id_type: str = "random",
148 ) -> ResponsesParser:
149 """Factory function to create a ResponsesParser with
180 - optional reasoning parser.
150 + optional unified parser.
151
152 Returns:
153 ResponsesParser instance configured with the provided parser
154 """
155 return ResponsesParser(
156 tokenizer=tokenizer,
187 - reasoning_parser_cls=reasoning_parser_cls,
157 + parser_cls=parser_cls,
158 response_messages=response_messages,
159 request=request,
190 - tool_parser_cls=tool_parser_cls,
160 chat_template=chat_template,
161 chat_template_content_format=chat_template_content_format,
162 + enable_auto_tools=enable_auto_tools,
163 + tool_call_id_type=tool_call_id_type,
164 )
165
166
vllm/entrypoints/openai/responses/context.py
+8 -10
@@ -41,9 +41,8 @@ from vllm.entrypoints.openai.responses.protocol import (
41 )
42 from vllm.entrypoints.openai.responses.utils import construct_tool_dicts
43 from vllm.outputs import RequestOutput
44 -from vllm.reasoning.abs_reasoning_parsers import ReasoningParser
44 +from vllm.parser.abstract_parser import Parser
45 from vllm.tokenizers import TokenizerLike
46 -from vllm.tool_parsers.abstract_tool_parser import ToolParser
46 from vllm.utils import random_uuid
47
48 if TYPE_CHECKING:
@@ -272,12 +271,13 @@ class ParsableContext(ConversationContext):
271 *,
272 response_messages: list[ResponseInputOutputItem],
273 tokenizer: TokenizerLike,
275 - reasoning_parser_cls: type[ReasoningParser] | None,
274 + parser_cls: type[Parser] | None,
275 request: ResponsesRequest,
276 available_tools: list[str] | None,
278 - tool_parser_cls: type[ToolParser] | None,
277 chat_template: str | None,
278 chat_template_content_format: ChatTemplateContentFormatOption,
279 + enable_auto_tools: bool = False,
280 + tool_call_id_type: str = "random",
281 ):
282 self.num_prompt_tokens = 0
283 self.num_output_tokens = 0
@@ -286,19 +286,17 @@ class ParsableContext(ConversationContext):
286 # not implemented yet for ParsableContext
287 self.all_turn_metrics: list[TurnMetrics] = []
288
289 - if reasoning_parser_cls is None:
290 - raise ValueError("reasoning_parser_cls must be provided.")
291 -
289 self.parser = get_responses_parser_for_simple_context(
290 tokenizer=tokenizer,
294 - reasoning_parser_cls=reasoning_parser_cls,
291 + parser_cls=parser_cls,
292 response_messages=response_messages,
293 request=request,
297 - tool_parser_cls=tool_parser_cls,
294 chat_template=chat_template,
295 chat_template_content_format=chat_template_content_format,
296 + enable_auto_tools=enable_auto_tools,
297 + tool_call_id_type=tool_call_id_type,
298 )
301 - self.tool_parser_cls = tool_parser_cls
299 + self.parser_cls = parser_cls
300 self.request = request
301
302 self.available_tools = available_tools or []
vllm/entrypoints/openai/responses/serving.py
+4 -7
@@ -460,16 +460,13 @@ class OpenAIServingResponses(OpenAIServing):
460 context = ParsableContext(
461 response_messages=messages,
462 tokenizer=tokenizer,
463 - reasoning_parser_cls=self.parser.reasoning_parser_cls
464 - if self.parser
465 - else None,
463 + parser_cls=self.parser,
464 request=request,
467 - tool_parser_cls=self.parser.tool_parser_cls
468 - if self.parser
469 - else None,
465 available_tools=available_tools,
466 chat_template=self.chat_template,
467 chat_template_content_format=self.chat_template_content_format,
468 + enable_auto_tools=self.enable_auto_tools,
469 + tool_call_id_type=self.tool_call_id_type,
470 )
471 else:
472 context = SimpleContext()
@@ -708,7 +705,7 @@ class OpenAIServingResponses(OpenAIServing):
705 context.request,
706 context.parser.response_messages,
707 context.tool_dicts,
711 - context.tool_parser_cls,
708 + context.parser_cls.tool_parser_cls if context.parser_cls else None,
709 context.chat_template,
710 context.chat_template_content_format,
711 )