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 14, 05:30 UTC
[Bugfix] Fix Pydantic union resolution for ResponseFunctionToolCall in Responses API (#26706)
Signed-off-by: Shai Trinczer <strinczer@icloud.com> Co-authored-by: Chauncey <chaunceyjiang@gmail.com> Co-authored-by: Ye (Charlotte) Qi <yeq@meta.com>
strinczer committed
Oct 24, 2025 at 06:53 UTC
074475541a845a5a22851faf3a7002ac35181e5c
2 files changed
+373
tests/entrypoints/openai/test_responses_function_call_parsing.py
new
+330
@@ -0,0 +1,330 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Test function call parsing in ResponsesRequest."""
4
+
5
+import json
6
+
7
+import pytest
8
+from openai.types.responses import ResponseFunctionToolCall
9
+
10
+from vllm.entrypoints.openai.protocol import ResponsesRequest
11
+
12
+
13
+def test_function_call_dict_converted_to_object():
14
+ """Test that function_call dictionaries are correctly parsed into
15
+ ResponseFunctionToolCall objects."""
16
+ # Create a request with function_call as dict
17
+ request_data = {
18
+ "model": "gpt-oss",
19
+ "input": [
20
+ {
21
+ "type": "function_call",
22
+ "call_id": "fc_123",
23
+ "name": "get_weather",
24
+ "arguments": '{"location": "Boston", "unit": "celsius"}',
25
+ }
26
+ ],
27
+ }
28
+
29
+ request = ResponsesRequest(**request_data)
30
+
31
+ # Verify the input item is now a ResponseFunctionToolCall object
32
+ assert len(request.input) == 1
33
+ assert isinstance(request.input[0], ResponseFunctionToolCall)
34
+ assert request.input[0].call_id == "fc_123"
35
+ assert request.input[0].name == "get_weather"
36
+ assert request.input[0].arguments == '{"location": "Boston", "unit": "celsius"}'
37
+
38
+
39
+def test_direct_function_call_object_preservation():
40
+ """Test that ResponseFunctionToolCall objects passed directly are preserved."""
41
+ # Create a request with ResponseFunctionToolCall object
42
+ function_call = ResponseFunctionToolCall(
43
+ type="function_call",
44
+ call_id="fc_456",
45
+ name="get_stock_price",
46
+ arguments='{"symbol": "AAPL"}',
47
+ )
48
+
49
+ request_data = {"model": "gpt-oss", "input": [function_call]}
50
+
51
+ request = ResponsesRequest(**request_data)
52
+
53
+ # Verify the object is preserved
54
+ assert len(request.input) == 1
55
+ assert request.input[0] is function_call
56
+
57
+
58
+def test_mixed_input_types_with_function_calls():
59
+ """Test parsing with mixed input types including function calls."""
60
+
61
+ request_data = {
62
+ "model": "gpt-oss",
63
+ "input": [
64
+ # Valid Message type
65
+ {
66
+ "type": "message",
67
+ "role": "user",
68
+ "content": [{"type": "input_text", "text": "What's the weather?"}],
69
+ },
70
+ # Function call that should be parsed
71
+ {
72
+ "type": "function_call",
73
+ "call_id": "fc_789",
74
+ "name": "check_weather",
75
+ "arguments": '{"location": "NYC"}',
76
+ },
77
+ # Another function call
78
+ {
79
+ "type": "function_call",
80
+ "call_id": "fc_790",
81
+ "name": "get_time",
82
+ "arguments": "{}",
83
+ },
84
+ ],
85
+ }
86
+
87
+ request = ResponsesRequest(**request_data)
88
+
89
+ # Verify mixed types are handled correctly
90
+ assert len(request.input) == 3
91
+ # First item should be validated as Message
92
+ assert request.input[0]["type"] == "message"
93
+ # Second item should be parsed to ResponseFunctionToolCall
94
+ assert isinstance(request.input[1], ResponseFunctionToolCall)
95
+ assert request.input[1].call_id == "fc_789"
96
+ assert request.input[1].name == "check_weather"
97
+ # Third item should also be parsed to ResponseFunctionToolCall
98
+ assert isinstance(request.input[2], ResponseFunctionToolCall)
99
+ assert request.input[2].call_id == "fc_790"
100
+ assert request.input[2].name == "get_time"
101
+
102
+
103
+def test_function_call_with_complex_arguments():
104
+ """Test parsing function calls with complex nested arguments."""
105
+ complex_args = {
106
+ "query": "weather forecast",
107
+ "filters": {
108
+ "location": {"city": "San Francisco", "state": "CA"},
109
+ "timeRange": {"start": "2024-01-01", "end": "2024-01-07"},
110
+ "metrics": ["temperature", "humidity", "precipitation"],
111
+ },
112
+ "options": {"format": "detailed", "includeAlerts": True},
113
+ }
114
+
115
+ request_data = {
116
+ "model": "gpt-oss",
117
+ "input": [
118
+ {
119
+ "type": "function_call",
120
+ "call_id": "fc_complex",
121
+ "name": "advanced_weather_query",
122
+ "arguments": json.dumps(complex_args),
123
+ }
124
+ ],
125
+ }
126
+
127
+ request = ResponsesRequest(**request_data)
128
+
129
+ # Verify complex arguments are preserved correctly
130
+ assert len(request.input) == 1
131
+ assert isinstance(request.input[0], ResponseFunctionToolCall)
132
+ assert request.input[0].call_id == "fc_complex"
133
+ assert request.input[0].name == "advanced_weather_query"
134
+
135
+ # Parse the arguments back to verify they're intact
136
+ parsed_args = json.loads(request.input[0].arguments)
137
+ assert parsed_args == complex_args
138
+
139
+
140
+def test_invalid_function_call_fallback():
141
+ """Test that invalid function call dictionaries fall back gracefully."""
142
+ # Missing required field 'call_id'
143
+ request_data = {
144
+ "model": "gpt-oss",
145
+ "input": [
146
+ {"type": "function_call", "name": "incomplete_function", "arguments": "{}"}
147
+ ],
148
+ }
149
+
150
+ # This should not raise an error during model creation
151
+ # The validator should keep the original dict and let Pydantic
152
+ # handle validation
153
+ with pytest.raises(ValueError):
154
+ # Pydantic should raise a validation error for the invalid structure
155
+ ResponsesRequest(**request_data)
156
+
157
+
158
+def test_string_input_not_affected():
159
+ """Test that string input is not affected by the validator."""
160
+ request_data = {"model": "gpt-oss", "input": "This is a simple string input"}
161
+
162
+ request = ResponsesRequest(**request_data)
163
+
164
+ # Verify string input remains unchanged
165
+ assert request.input == "This is a simple string input"
166
+
167
+
168
+def test_empty_list_input():
169
+ """Test that empty list input is handled correctly."""
170
+ request_data = {"model": "gpt-oss", "input": []}
171
+
172
+ request = ResponsesRequest(**request_data)
173
+
174
+ # Verify empty list is preserved
175
+ assert request.input == []
176
+
177
+
178
+def test_function_call_output_not_affected():
179
+ """Test that FunctionCallOutput is not affected by the function_call parsing."""
180
+
181
+ # Test with FunctionCallOutput as dict (should not be parsed)
182
+ request_data = {
183
+ "model": "gpt-oss",
184
+ "input": [
185
+ {
186
+ "type": "function_call_output",
187
+ "call_id": "fc_output_123",
188
+ "output": "The weather in Boston is 72°F and sunny.",
189
+ }
190
+ ],
191
+ }
192
+
193
+ request = ResponsesRequest(**request_data)
194
+
195
+ # FunctionCallOutput should remain as dict (not converted to an object)
196
+ assert len(request.input) == 1
197
+ assert isinstance(request.input[0], dict)
198
+ assert request.input[0]["type"] == "function_call_output"
199
+ assert request.input[0]["call_id"] == "fc_output_123"
200
+ assert request.input[0]["output"] == "The weather in Boston is 72°F and sunny."
201
+
202
+
203
+def test_mixed_function_call_and_output():
204
+ """Test that function_call is parsed while function_call_output is preserved."""
205
+ request_data = {
206
+ "model": "gpt-oss",
207
+ "input": [
208
+ # This should be parsed to ResponseFunctionToolCall
209
+ {
210
+ "type": "function_call",
211
+ "call_id": "fc_call_456",
212
+ "name": "get_weather",
213
+ "arguments": '{"location": "NYC"}',
214
+ },
215
+ # This should remain as dict
216
+ {
217
+ "type": "function_call_output",
218
+ "call_id": "fc_call_456",
219
+ "output": "NYC weather is 68°F with light rain",
220
+ },
221
+ ],
222
+ }
223
+
224
+ request = ResponsesRequest(**request_data)
225
+
226
+ assert len(request.input) == 2
227
+
228
+ # First item should be parsed to ResponseFunctionToolCall
229
+ assert isinstance(request.input[0], ResponseFunctionToolCall)
230
+ assert request.input[0].call_id == "fc_call_456"
231
+ assert request.input[0].name == "get_weather"
232
+
233
+ # Second item should remain as dict (FunctionCallOutput)
234
+ assert isinstance(request.input[1], dict)
235
+ assert request.input[1]["type"] == "function_call_output"
236
+ assert request.input[1]["call_id"] == "fc_call_456"
237
+ assert request.input[1]["output"] == "NYC weather is 68°F with light rain"
238
+
239
+
240
+def test_function_call_validation_failure_logs_debug(caplog):
241
+ """Test that validation failures are logged at debug level."""
242
+ from unittest.mock import patch
243
+
244
+ request_data = {
245
+ "model": "gpt-oss",
246
+ "input": [
247
+ {
248
+ "type": "function_call",
249
+ "name": "incomplete_function",
250
+ "arguments": "{}", # Missing call_id
251
+ }
252
+ ],
253
+ }
254
+
255
+ # Mock the logger to verify debug was called
256
+ with patch("vllm.entrypoints.openai.protocol.logger") as mock_logger:
257
+ with pytest.raises(ValueError):
258
+ ResponsesRequest(**request_data)
259
+
260
+ # Verify debug was called with expected message
261
+ mock_logger.debug.assert_called_once()
262
+ call_args = mock_logger.debug.call_args[0][0]
263
+ assert "Failed to parse function_call" in call_args
264
+
265
+
266
+def test_validator_handles_iterator_input():
267
+ """Test that validator can handle ValidatorIterator input (Pydantic internal)."""
268
+
269
+ # This test simulates when Pydantic passes a ValidatorIterator instead of a list
270
+ # This happened with complex nested structures containing reasoning + function_call
271
+
272
+ # Create test data that would normally be a list
273
+ test_input_items = [
274
+ {
275
+ "type": "message",
276
+ "role": "user",
277
+ "content": [{"type": "input_text", "text": "Test"}],
278
+ },
279
+ {
280
+ "type": "reasoning",
281
+ "id": "rs_1",
282
+ "summary": [{"type": "summary_text", "text": "Test reasoning"}],
283
+ "content": [{"type": "reasoning_text", "text": "Test content"}],
284
+ },
285
+ {
286
+ "type": "function_call",
287
+ "call_id": "call_1",
288
+ "name": "test_function",
289
+ "arguments": '{"test": "value"}',
290
+ "id": "fc_1",
291
+ },
292
+ ]
293
+
294
+ # Mock data where input is an iterator (simulates Pydantic ValidatorIterator)
295
+ mock_data = {
296
+ "model": "test-model",
297
+ "input": iter(test_input_items), # Iterator instead of list
298
+ }
299
+
300
+ # This should NOT raise an error with the fixed validator
301
+ try:
302
+ request = ResponsesRequest(**mock_data)
303
+
304
+ # Verify the validator processed the data correctly
305
+ assert len(request.input) == 3
306
+
307
+ # Verify function_call was converted to ResponseFunctionToolCall object
308
+ function_call_item = None
309
+ for item in request.input:
310
+ if isinstance(item, ResponseFunctionToolCall):
311
+ function_call_item = item
312
+ break
313
+
314
+ assert function_call_item is not None
315
+ assert function_call_item.call_id == "call_1"
316
+ assert function_call_item.name == "test_function"
317
+
318
+ except Exception as e:
319
+ pytest.fail(f"Validator should handle iterator input, but failed with: {e}")
320
+
321
+
322
+def test_validator_handles_empty_iterator():
323
+ """Test validator handles empty iterator gracefully."""
324
+ mock_data = {
325
+ "model": "test-model",
326
+ "input": iter([]), # Empty iterator
327
+ }
328
+
329
+ request = ResponsesRequest(**mock_data)
330
+ assert request.input == []
vllm/entrypoints/openai/protocol.py
+43
@@ -69,6 +69,7 @@ from pydantic import (
69
ConfigDict,
70
Field,
71
TypeAdapter,
72
+ ValidationError,
73
ValidationInfo,
74
field_serializer,
75
field_validator,
@@ -478,6 +479,48 @@ class ResponsesRequest(OpenAIBaseModel):
479
)
480
return data
481
482
+ @model_validator(mode="before")
483
+ def function_call_parsing(cls, data):
484
+ """Parse function_call dictionaries into ResponseFunctionToolCall objects.
485
+ This ensures Pydantic can properly resolve union types in the input field.
486
+ Function calls provided as dicts are converted to ResponseFunctionToolCall
487
+ objects before validation, while invalid structures are left for Pydantic
488
+ to reject with appropriate error messages.
489
+ """
490
+
491
+ input_data = data.get("input")
492
+
493
+ # Early return for None, strings, or bytes
494
+ # (strings are iterable but shouldn't be processed)
495
+ if input_data is None or isinstance(input_data, (str, bytes)):
496
+ return data
497
+
498
+ # Convert iterators (like ValidatorIterator) to list
499
+ if not isinstance(input_data, list):
500
+ try:
501
+ input_data = list(input_data)
502
+ except TypeError:
503
+ # Not iterable, leave as-is for Pydantic to handle
504
+ return data
505
+
506
+ processed_input = []
507
+ for item in input_data:
508
+ if isinstance(item, dict) and item.get("type") == "function_call":
509
+ try:
510
+ processed_input.append(ResponseFunctionToolCall(**item))
511
+ except ValidationError:
512
+ # Let Pydantic handle validation for malformed function calls
513
+ logger.debug(
514
+ "Failed to parse function_call to ResponseFunctionToolCall, "
515
+ "leaving for Pydantic validation"
516
+ )
517
+ processed_input.append(item)
518
+ else:
519
+ processed_input.append(item)
520
+
521
+ data["input"] = processed_input
522
+ return data
523
+
524
525
class ChatCompletionRequest(OpenAIBaseModel):
526
# Ordered by official OpenAI API documentation