[Bugfix][Responses] Add tests for Chat Completions Responses API Render Parity (#50334)
Signed-off-by: Yifan Zong <yzong@redhat.com> Co-authored-by: Ben Browning <56071+bbrowning@users.noreply.github.com>
yzong-rh committed
Jul 31, 2026 at 15:45 UTC
e8b358b4c0df5cf136072747c97a95526bd59df1
8 files changed
+521
-43
tests/entrypoints/openai/responses/test_parsable_context.py
+6
@@ -186,6 +186,12 @@ async def test_function_call_first_turn(client: OpenAI, model_name: str):
186
187
@pytest.mark.asyncio
188
@pytest.mark.parametrize("model_name", [MODEL_NAME])
189
+@pytest.mark.xfail(
190
+ reason=(
191
+ "MCP tools are not properly supported: tool name parameters "
192
+ "are not extracted for prompt rendering."
193
+ ),
194
+)
195
async def test_mcp_tool_call(client: OpenAI, model_name: str):
196
"""MCP tool calling with code_interpreter.
197
tests/entrypoints/openai/responses/test_responses_utils.py
+1
-22
@@ -20,7 +20,6 @@ from vllm.entrypoints.openai.responses.utils import (
20
_construct_message_from_response_item,
21
construct_chat_messages_with_tool_call,
22
construct_input_messages,
23
- convert_tool_responses_to_completions_format,
23
should_continue_final_message,
24
)
25
@@ -116,27 +115,7 @@ def make_function_call_output(
115
116
117
class TestResponsesUtils:
119
- """Tests for convert_tool_responses_to_completions_format function."""
120
-
121
- def test_convert_tool_responses_to_completions_format(self):
122
- """Test basic conversion of a flat tool schema to nested format."""
123
- input_tool = {
124
- "type": "function",
125
- "name": "get_weather",
126
- "description": "Get the current weather in a given location",
127
- "parameters": {
128
- "type": "object",
129
- "properties": {
130
- "location": {"type": "string"},
131
- "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
132
- },
133
- "required": ["location", "unit"],
134
- },
135
- }
136
-
137
- result = convert_tool_responses_to_completions_format(input_tool)
138
-
139
- assert result == {"type": "function", "function": input_tool}
118
+ """Tests for Responses API utils."""
119
120
def test_construct_chat_messages_with_tool_call(self):
121
"""Test construction of chat messages with tool calls."""
tests/entrypoints/openai/test_render_parity.py
new
+479
@@ -0,0 +1,479 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Cross-API HF render-input parity tests.
4
+
5
+Drives the real Chat Completions and Responses prep paths and captures the
6
+arguments each passes to ``BaseRenderer.render_chat_async`` (via shared
7
+``OnlineRenderer.preprocess_chat``):
8
+
9
+ - conversation messages
10
+ - ChatParams fields that affect the HF template / media path
11
+ (template, content format, template kwargs including tools,
12
+ media_io_kwargs, mm_processor_kwargs)
13
+
14
+``TokenizeParams``, ``prompt_extras``, Harmony / GPT-OSS, prefill / continue,
15
+prompt cache salt, and truncation are out of scope for this file.
16
+"""
17
+
18
+from __future__ import annotations
19
+
20
+from dataclasses import dataclass, field
21
+from typing import Any
22
+from unittest.mock import MagicMock
23
+
24
+import pytest
25
+from openai.types.shared import Reasoning
26
+
27
+from vllm.config.multimodal import MultiModalConfig
28
+from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
29
+from vllm.entrypoints.openai.engine.protocol import ErrorResponse
30
+from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
31
+from vllm.entrypoints.openai.responses.serving import OpenAIServingResponses
32
+from vllm.inputs import tokens_input
33
+from vllm.renderers.online_renderer import OnlineRenderer
34
+from vllm.renderers.params import ChatParams
35
+
36
+_MODEL = "test-model"
37
+_USER = [{"role": "user", "content": "Hello"}]
38
+
39
+_WEATHER_PARAMETERS = {
40
+ "type": "object",
41
+ "properties": {"location": {"type": "string"}},
42
+ "required": ["location"],
43
+}
44
+
45
+_CHAT_WEATHER_TOOL = {
46
+ "type": "function",
47
+ "function": {
48
+ "name": "get_weather",
49
+ "description": "Get the weather",
50
+ "parameters": _WEATHER_PARAMETERS,
51
+ },
52
+}
53
+
54
+_RESPONSES_WEATHER_TOOL = {
55
+ "type": "function",
56
+ "name": "get_weather",
57
+ "description": "Get the weather",
58
+ "parameters": _WEATHER_PARAMETERS,
59
+}
60
+
61
+
62
+@dataclass
63
+class MockHFConfig:
64
+ model_type: str = "llama"
65
+
66
+
67
+@dataclass
68
+class MockModelConfig:
69
+ task = "generate"
70
+ runner_type = "generate"
71
+ model = _MODEL
72
+ tokenizer = _MODEL
73
+ trust_remote_code = False
74
+ tokenizer_mode = "auto"
75
+ max_model_len = 100
76
+ tokenizer_revision = None
77
+ multimodal_config = MultiModalConfig()
78
+ hf_config = MockHFConfig()
79
+ hf_text_config = MockHFConfig()
80
+ logits_processors: list[str] | None = None
81
+ diff_sampling_param: dict | None = None
82
+ allowed_local_media_path: str = ""
83
+ allowed_media_domains: list[str] | None = None
84
+ encoder_config = None
85
+ generation_config: str = "auto"
86
+ override_generation_config: dict[str, Any] = field(default_factory=dict)
87
+ media_io_kwargs: dict[str, dict[str, Any]] = field(default_factory=dict)
88
+ skip_tokenizer_init = False
89
+ is_encoder_decoder: bool = False
90
+ is_multimodal_model: bool = False
91
+ renderer_num_workers: int = 1
92
+ enable_prompt_embeds: bool = False
93
+
94
+ def get_diff_sampling_param(self):
95
+ return self.diff_sampling_param or {}
96
+
97
+
98
+@dataclass(frozen=True)
99
+class CapturedRenderInputs:
100
+ """Args observed at ``render_chat_async`` (HF render boundary)."""
101
+
102
+ messages: list[Any]
103
+ chat_params: ChatParams
104
+
105
+
106
+class RenderCapture:
107
+ """Install a ``render_chat_async`` stub and record its HF-bound inputs."""
108
+
109
+ def __init__(self, online_renderer: OnlineRenderer) -> None:
110
+ self.online_renderer = online_renderer
111
+ self.captured: CapturedRenderInputs | None = None
112
+
113
+ async def fake_render_chat_async(
114
+ conversations,
115
+ chat_params,
116
+ tok_params=None,
117
+ *,
118
+ prompt_extras=None,
119
+ skip_mm_cache=False,
120
+ ):
121
+ assert len(conversations) == 1
122
+ self.captured = CapturedRenderInputs(
123
+ messages=list(conversations[0]),
124
+ chat_params=chat_params,
125
+ )
126
+ return [list(conversations[0])], [
127
+ tokens_input(prompt_token_ids=[0]),
128
+ ]
129
+
130
+ online_renderer.renderer.render_chat_async = fake_render_chat_async
131
+
132
+ def take(self) -> CapturedRenderInputs:
133
+ assert self.captured is not None
134
+ captured = self.captured
135
+ self.captured = None
136
+ return captured
137
+
138
+
139
+async def _capture_chat(
140
+ online_renderer: OnlineRenderer,
141
+ request: ChatCompletionRequest,
142
+) -> CapturedRenderInputs:
143
+ capture = RenderCapture(online_renderer)
144
+ result = await online_renderer.render_chat(request)
145
+ assert not isinstance(result, ErrorResponse), result
146
+ return capture.take()
147
+
148
+
149
+async def _capture_responses(
150
+ serving: OpenAIServingResponses,
151
+ request: ResponsesRequest,
152
+) -> CapturedRenderInputs:
153
+ capture = RenderCapture(serving.online_renderer)
154
+ await serving._make_request(request, prev_response=None)
155
+ return capture.take()
156
+
157
+
158
+async def _assert_parity(
159
+ online_renderer: OnlineRenderer,
160
+ serving: OpenAIServingResponses,
161
+ *,
162
+ chat_kwargs: dict[str, Any],
163
+ responses_kwargs: dict[str, Any],
164
+) -> None:
165
+ """Build paired requests, capture HF render inputs, and assert equality."""
166
+ chat_req = ChatCompletionRequest(model=_MODEL, **chat_kwargs)
167
+ responses_req = ResponsesRequest(model=_MODEL, **responses_kwargs)
168
+ chat = await _capture_chat(online_renderer, chat_req)
169
+ responses = await _capture_responses(serving, responses_req)
170
+
171
+ assert chat.messages == responses.messages
172
+
173
+ chat_params = chat.chat_params
174
+ responses_params = responses.chat_params
175
+ assert chat_params.chat_template == responses_params.chat_template
176
+ assert (
177
+ chat_params.chat_template_content_format
178
+ == responses_params.chat_template_content_format
179
+ )
180
+ assert chat_params.media_io_kwargs == responses_params.media_io_kwargs
181
+ assert chat_params.mm_processor_kwargs == responses_params.mm_processor_kwargs
182
+ assert dict(chat_params.chat_template_kwargs) == dict(
183
+ responses_params.chat_template_kwargs
184
+ )
185
+
186
+
187
+def _weather_tools(
188
+ *, overrides: dict[str, Any] | None = None
189
+) -> tuple[list[dict], list[dict]]:
190
+ """Return (chat_tools, responses_tools) for the shared weather function."""
191
+ overrides = overrides or {}
192
+ chat_tool = {
193
+ "type": "function",
194
+ "function": {
195
+ "name": "get_weather",
196
+ "description": "Get the weather",
197
+ "parameters": _WEATHER_PARAMETERS,
198
+ **overrides,
199
+ },
200
+ }
201
+ responses_tool = {
202
+ "type": "function",
203
+ "name": "get_weather",
204
+ "description": "Get the weather",
205
+ "parameters": _WEATHER_PARAMETERS,
206
+ **overrides,
207
+ }
208
+ return [chat_tool], [responses_tool]
209
+
210
+
211
+@pytest.fixture
212
+def model_config() -> MockModelConfig:
213
+ return MockModelConfig()
214
+
215
+
216
+@pytest.fixture
217
+def online_renderer(model_config: MockModelConfig, request) -> OnlineRenderer:
218
+ exclude_tools_when_tool_choice_none = getattr(request, "param", False)
219
+ renderer = MagicMock()
220
+ # Non-Mistral stub; only needed so render_chat / preprocess_chat can
221
+ # inspect tokenizer type before render_chat_async is mocked.
222
+ renderer.tokenizer = MagicMock()
223
+
224
+ return OnlineRenderer(
225
+ model_config=model_config, # type: ignore[arg-type]
226
+ renderer=renderer,
227
+ request_logger=None,
228
+ chat_template=None,
229
+ chat_template_content_format="auto",
230
+ enable_auto_tools=True,
231
+ tool_parser="openai",
232
+ exclude_tools_when_tool_choice_none=exclude_tools_when_tool_choice_none,
233
+ )
234
+
235
+
236
+@pytest.fixture
237
+def serving_responses(
238
+ model_config: MockModelConfig,
239
+ online_renderer: OnlineRenderer,
240
+) -> OpenAIServingResponses:
241
+ engine_client = MagicMock()
242
+ engine_client.model_config = model_config
243
+ engine_client.renderer = online_renderer.renderer
244
+ engine_client.input_processor = MagicMock()
245
+ engine_client.vllm_config = MagicMock()
246
+
247
+ return OpenAIServingResponses(
248
+ engine_client=engine_client,
249
+ models=MagicMock(),
250
+ online_renderer=online_renderer,
251
+ request_logger=None,
252
+ chat_template=online_renderer.chat_template,
253
+ chat_template_content_format=online_renderer.chat_template_content_format,
254
+ enable_auto_tools=True,
255
+ tool_parser="openai",
256
+ )
257
+
258
+
259
+@pytest.mark.asyncio
260
+class TestConversationRenderParity:
261
+ async def test_multiturn_tool_calling(self, online_renderer, serving_responses):
262
+ """System/instructions, tool-call turn, and a follow-up user message."""
263
+ await _assert_parity(
264
+ online_renderer,
265
+ serving_responses,
266
+ chat_kwargs={
267
+ "messages": [
268
+ {"role": "system", "content": "Be helpful."},
269
+ {"role": "user", "content": "Weather in NYC?"},
270
+ {
271
+ "role": "assistant",
272
+ "tool_calls": [
273
+ {
274
+ "id": "call_1",
275
+ "type": "function",
276
+ "function": {
277
+ "name": "get_weather",
278
+ "arguments": '{"location":"NYC"}',
279
+ },
280
+ }
281
+ ],
282
+ },
283
+ {
284
+ "role": "tool",
285
+ "tool_call_id": "call_1",
286
+ "content": "72F",
287
+ },
288
+ {"role": "user", "content": "Thanks"},
289
+ ],
290
+ "tools": [_CHAT_WEATHER_TOOL],
291
+ "tool_choice": "auto",
292
+ },
293
+ responses_kwargs={
294
+ "instructions": "Be helpful.",
295
+ "input": [
296
+ {"role": "user", "content": "Weather in NYC?"},
297
+ {
298
+ "type": "function_call",
299
+ "call_id": "call_1",
300
+ "name": "get_weather",
301
+ "arguments": '{"location":"NYC"}',
302
+ },
303
+ {
304
+ "type": "function_call_output",
305
+ "call_id": "call_1",
306
+ "output": "72F",
307
+ },
308
+ {"role": "user", "content": "Thanks"},
309
+ ],
310
+ "tools": [_RESPONSES_WEATHER_TOOL],
311
+ "tool_choice": "auto",
312
+ },
313
+ )
314
+
315
+
316
+@pytest.mark.asyncio
317
+class TestToolsRenderParity:
318
+ @pytest.mark.parametrize(
319
+ "chat_tool_choice,responses_tool_choice",
320
+ [
321
+ ("auto", "auto"),
322
+ ("required", "required"),
323
+ (
324
+ {"type": "function", "function": {"name": "get_weather"}},
325
+ {"type": "function", "name": "get_weather"},
326
+ ),
327
+ ],
328
+ ids=["auto", "required", "named"],
329
+ )
330
+ async def test_tools_with_tool_choice(
331
+ self,
332
+ online_renderer,
333
+ serving_responses,
334
+ chat_tool_choice,
335
+ responses_tool_choice,
336
+ ):
337
+ chat_tools, responses_tools = _weather_tools()
338
+ await _assert_parity(
339
+ online_renderer,
340
+ serving_responses,
341
+ chat_kwargs={
342
+ "messages": _USER,
343
+ "tools": chat_tools,
344
+ "tool_choice": chat_tool_choice,
345
+ },
346
+ responses_kwargs={
347
+ "input": _USER,
348
+ "tools": responses_tools,
349
+ "tool_choice": responses_tool_choice,
350
+ },
351
+ )
352
+
353
+ @pytest.mark.parametrize(
354
+ "online_renderer",
355
+ [False, True],
356
+ indirect=True,
357
+ ids=["include_tools", "exclude_tools"],
358
+ )
359
+ async def test_tools_with_tool_choice_none(
360
+ self, online_renderer, serving_responses
361
+ ):
362
+ chat_tools, responses_tools = _weather_tools()
363
+ await _assert_parity(
364
+ online_renderer,
365
+ serving_responses,
366
+ chat_kwargs={
367
+ "messages": _USER,
368
+ "tools": chat_tools,
369
+ "tool_choice": "none",
370
+ },
371
+ responses_kwargs={
372
+ "input": _USER,
373
+ "tools": responses_tools,
374
+ "tool_choice": "none",
375
+ },
376
+ )
377
+
378
+ async def test_function_tool_optional_fields(
379
+ self, online_renderer, serving_responses
380
+ ):
381
+ """Optional FunctionDefinition fields dump the same on both APIs."""
382
+ chat_tools, responses_tools = _weather_tools(
383
+ overrides={
384
+ "strict": True,
385
+ "defer_loading": False,
386
+ "unrelated_extra": "should_be_ignored",
387
+ }
388
+ )
389
+ await _assert_parity(
390
+ online_renderer,
391
+ serving_responses,
392
+ chat_kwargs={
393
+ "messages": _USER,
394
+ "tools": chat_tools,
395
+ "tool_choice": "auto",
396
+ },
397
+ responses_kwargs={
398
+ "input": _USER,
399
+ "tools": responses_tools,
400
+ "tool_choice": "auto",
401
+ },
402
+ )
403
+
404
+
405
+@pytest.mark.asyncio
406
+class TestReasoningRenderParity:
407
+ @pytest.mark.parametrize(
408
+ "effort",
409
+ ["none", "minimal", "low", "medium", "high", "xhigh"],
410
+ )
411
+ async def test_reasoning_effort(
412
+ self, online_renderer, serving_responses, effort: str
413
+ ):
414
+ await _assert_parity(
415
+ online_renderer,
416
+ serving_responses,
417
+ chat_kwargs={
418
+ "messages": _USER,
419
+ "reasoning_effort": effort,
420
+ "tool_choice": "none",
421
+ },
422
+ responses_kwargs={
423
+ "input": _USER,
424
+ "reasoning": Reasoning(effort=effort),
425
+ "tool_choice": "none",
426
+ },
427
+ )
428
+
429
+ async def test_explicit_enable_thinking_not_overridden(
430
+ self, online_renderer, serving_responses
431
+ ):
432
+ await _assert_parity(
433
+ online_renderer,
434
+ serving_responses,
435
+ chat_kwargs={
436
+ "messages": _USER,
437
+ "reasoning_effort": "high",
438
+ "chat_template_kwargs": {"enable_thinking": False},
439
+ "tool_choice": "none",
440
+ },
441
+ responses_kwargs={
442
+ "input": _USER,
443
+ "reasoning": Reasoning(effort="high"),
444
+ "chat_template_kwargs": {"enable_thinking": False},
445
+ "tool_choice": "none",
446
+ },
447
+ )
448
+
449
+
450
+@pytest.mark.asyncio
451
+class TestTemplateKwargsRenderParity:
452
+ async def test_passthrough_fields(self, online_renderer, serving_responses):
453
+ """chat_template_kwargs / media_io_kwargs / mm_processor_kwargs parity."""
454
+ await _assert_parity(
455
+ online_renderer,
456
+ serving_responses,
457
+ chat_kwargs={
458
+ "messages": [
459
+ {"role": "system", "content": "Be helpful."},
460
+ {"role": "user", "content": "Hello"},
461
+ ],
462
+ "tools": [_CHAT_WEATHER_TOOL],
463
+ "tool_choice": "auto",
464
+ "reasoning_effort": "medium",
465
+ "chat_template_kwargs": {"custom_flag": True},
466
+ "media_io_kwargs": {"image": {"max_pixels": 512}},
467
+ "mm_processor_kwargs": {"num_crops": 2},
468
+ },
469
+ responses_kwargs={
470
+ "instructions": "Be helpful.",
471
+ "input": _USER,
472
+ "tools": [_RESPONSES_WEATHER_TOOL],
473
+ "tool_choice": "auto",
474
+ "reasoning": Reasoning(effort="medium"),
475
+ "chat_template_kwargs": {"custom_flag": True},
476
+ "media_io_kwargs": {"image": {"max_pixels": 512}},
477
+ "mm_processor_kwargs": {"num_crops": 2},
478
+ },
479
+ )
vllm/entrypoints/openai/chat_completion/protocol.py
+1
@@ -179,6 +179,7 @@ class ChatCompletionToolsParam(OpenAIBaseModel):
179
@model_serializer(mode="wrap")
180
def _serialize(self, handler):
181
data = handler(self)
182
+ data = {k: v for k, v in data.items() if k in type(self).model_fields}
183
if self.defer_loading is None:
184
data.pop("defer_loading", None)
185
return data
vllm/entrypoints/openai/engine/protocol.py
+1
@@ -296,6 +296,7 @@ class FunctionDefinition(OpenAIBaseModel):
296
@model_serializer(mode="wrap")
297
def _serialize(self, handler):
298
data = handler(self)
299
+ data = {k: v for k, v in data.items() if k in type(self).model_fields}
300
if self.strict is None:
301
data.pop("strict", None)
302
if self.defer_loading is None:
vllm/entrypoints/openai/responses/serving.py
+7
-1
@@ -610,7 +610,13 @@ class OpenAIServingResponses(GenerateBaseServing):
610
request: ResponsesRequest,
611
prev_response: ResponsesResponse | None,
612
):
613
- tool_dicts = construct_tool_dicts(request.tools, request.tool_choice)
613
+ tool_dicts = construct_tool_dicts(
614
+ request.tools,
615
+ request.tool_choice,
616
+ exclude_tools_when_tool_choice_none=(
617
+ self.online_renderer.exclude_tools_when_tool_choice_none
618
+ ),
619
+ )
620
# Construct the input messages.
621
messages = construct_input_messages(
622
request_instructions=request.instructions,
vllm/entrypoints/openai/responses/utils.py
+25
-19
@@ -31,8 +31,11 @@ from openai.types.responses.tool import Tool
31
32
from vllm import envs
33
from vllm.entrypoints.chat_utils import make_tool_call_id
34
-from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionMessageParam
35
-from vllm.entrypoints.openai.engine.protocol import FunctionCall
34
+from vllm.entrypoints.openai.chat_completion.protocol import (
35
+ ChatCompletionMessageParam,
36
+ ChatCompletionToolsParam,
37
+)
38
+from vllm.entrypoints.openai.engine.protocol import FunctionCall, FunctionDefinition
39
from vllm.entrypoints.openai.responses.protocol import ResponseInputOutputItem
40
from vllm.logger import init_logger
41
from vllm.tool_parsers.utils import (
@@ -363,27 +366,30 @@ def extract_tool_types(tools: list[Tool]) -> set[str]:
366
return tool_types
367
368
366
-def convert_tool_responses_to_completions_format(tool: dict) -> dict:
369
+def convert_tool_responses_to_completions_format(
370
+ tool: dict,
371
+) -> ChatCompletionToolsParam:
372
"""
368
- Convert a flat tool schema:
373
+ Convert a flat Responses tool schema:
374
{"type": "function", "name": "...", "description": "...", "parameters": {...}}
370
- into:
371
- {"type": "function", "function": {...}}
375
+ into a Chat Completions tool param for chat-template rendering.
376
"""
373
- return {
374
- "type": "function",
375
- "function": tool,
376
- }
377
+ return ChatCompletionToolsParam(
378
+ type="function",
379
+ function=FunctionDefinition.model_validate(
380
+ {k: v for k, v in tool.items() if k != "type"}
381
+ ),
382
+ )
383
384
385
def construct_tool_dicts(
380
- tools: list[Tool], tool_choice: ToolChoice
386
+ tools: list[Tool],
387
+ tool_choice: ToolChoice,
388
+ exclude_tools_when_tool_choice_none: bool = False,
389
) -> list[dict[str, Any]] | None:
382
- if not tools or (tool_choice == "none"):
383
- tool_dicts = None
384
- else:
385
- tool_dicts = [
386
- convert_tool_responses_to_completions_format(tool)
387
- for tool in iter_response_function_tool_dicts(tools)
388
- ]
389
- return tool_dicts
390
+ if not tools or (tool_choice == "none" and exclude_tools_when_tool_choice_none):
391
+ return None
392
+ return [
393
+ convert_tool_responses_to_completions_format(tool).model_dump()
394
+ for tool in iter_response_function_tool_dicts(tools)
395
+ ]
vllm/tool_parsers/utils.py
+1
-1
@@ -215,7 +215,7 @@ def iter_response_function_tool_dicts(
215
namespace, namespaced_tool.name
216
)
217
function_tools.append(tool_dict)
218
- else:
218
+ elif isinstance(tool, FunctionTool):
219
function_tools.append(tool.model_dump())
220
return function_tools
221