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
)