@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 074475541a
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 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