main
py 376 lines 12.5 KB
Raw
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, ResponseOutputMessage
9
10 from vllm.entrypoints.openai.responses.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 request_data = {
61 "model": "gpt-oss",
62 "input": [
63 # Valid Message type
64 {
65 "type": "message",
66 "role": "user",
67 "content": [{"type": "input_text", "text": "What's the weather?"}],
68 },
69 # Function call that should be parsed
70 {
71 "type": "function_call",
72 "call_id": "fc_789",
73 "name": "check_weather",
74 "arguments": '{"location": "NYC"}',
75 },
76 # Another function call
77 {
78 "type": "function_call",
79 "call_id": "fc_790",
80 "name": "get_time",
81 "arguments": "{}",
82 },
83 ],
84 }
85
86 request = ResponsesRequest(**request_data)
87
88 # Verify mixed types are handled correctly
89 assert len(request.input) == 3
90 # First item should be validated as Message
91 assert request.input[0]["type"] == "message"
92 # Second item should be parsed to ResponseFunctionToolCall
93 assert isinstance(request.input[1], ResponseFunctionToolCall)
94 assert request.input[1].call_id == "fc_789"
95 assert request.input[1].name == "check_weather"
96 # Third item should also be parsed to ResponseFunctionToolCall
97 assert isinstance(request.input[2], ResponseFunctionToolCall)
98 assert request.input[2].call_id == "fc_790"
99 assert request.input[2].name == "get_time"
100
101
102 def test_function_call_with_complex_arguments():
103 """Test parsing function calls with complex nested arguments."""
104 complex_args = {
105 "query": "weather forecast",
106 "filters": {
107 "location": {"city": "San Francisco", "state": "CA"},
108 "timeRange": {"start": "2024-01-01", "end": "2024-01-07"},
109 "metrics": ["temperature", "humidity", "precipitation"],
110 },
111 "options": {"format": "detailed", "includeAlerts": True},
112 }
113
114 request_data = {
115 "model": "gpt-oss",
116 "input": [
117 {
118 "type": "function_call",
119 "call_id": "fc_complex",
120 "name": "advanced_weather_query",
121 "arguments": json.dumps(complex_args),
122 }
123 ],
124 }
125
126 request = ResponsesRequest(**request_data)
127
128 # Verify complex arguments are preserved correctly
129 assert len(request.input) == 1
130 assert isinstance(request.input[0], ResponseFunctionToolCall)
131 assert request.input[0].call_id == "fc_complex"
132 assert request.input[0].name == "advanced_weather_query"
133
134 # Parse the arguments back to verify they're intact
135 parsed_args = json.loads(request.input[0].arguments)
136 assert parsed_args == complex_args
137
138
139 def test_invalid_function_call_fallback():
140 """Test that invalid function call dictionaries fall back gracefully."""
141 # Missing required field 'call_id'
142 request_data = {
143 "model": "gpt-oss",
144 "input": [
145 {"type": "function_call", "name": "incomplete_function", "arguments": "{}"}
146 ],
147 }
148
149 # This should not raise an error during model creation
150 # The validator should keep the original dict and let Pydantic
151 # handle validation
152 with pytest.raises(ValueError):
153 # Pydantic should raise a validation error for the invalid structure
154 ResponsesRequest(**request_data)
155
156
157 def test_string_input_not_affected():
158 """Test that string input is not affected by the validator."""
159 request_data = {"model": "gpt-oss", "input": "This is a simple string input"}
160
161 request = ResponsesRequest(**request_data)
162
163 # Verify string input remains unchanged
164 assert request.input == "This is a simple string input"
165
166
167 def test_empty_list_input():
168 """Test that empty list input is handled correctly."""
169 request_data = {"model": "gpt-oss", "input": []}
170
171 request = ResponsesRequest(**request_data)
172
173 # Verify empty list is preserved
174 assert request.input == []
175
176
177 def test_function_call_output_not_affected():
178 """Test that FunctionCallOutput is not affected by the function_call parsing."""
179 # Test with FunctionCallOutput as dict (should not be parsed)
180 request_data = {
181 "model": "gpt-oss",
182 "input": [
183 {
184 "type": "function_call_output",
185 "call_id": "fc_output_123",
186 "output": "The weather in Boston is 72°F and sunny.",
187 }
188 ],
189 }
190
191 request = ResponsesRequest(**request_data)
192
193 # FunctionCallOutput should remain as dict (not converted to an object)
194 assert len(request.input) == 1
195 assert isinstance(request.input[0], dict)
196 assert request.input[0]["type"] == "function_call_output"
197 assert request.input[0]["call_id"] == "fc_output_123"
198 assert request.input[0]["output"] == "The weather in Boston is 72°F and sunny."
199
200
201 def test_mixed_function_call_and_output():
202 """Test that function_call is parsed while function_call_output is preserved."""
203 request_data = {
204 "model": "gpt-oss",
205 "input": [
206 # This should be parsed to ResponseFunctionToolCall
207 {
208 "type": "function_call",
209 "call_id": "fc_call_456",
210 "name": "get_weather",
211 "arguments": '{"location": "NYC"}',
212 },
213 # This should remain as dict
214 {
215 "type": "function_call_output",
216 "call_id": "fc_call_456",
217 "output": "NYC weather is 68°F with light rain",
218 },
219 ],
220 }
221
222 request = ResponsesRequest(**request_data)
223
224 assert len(request.input) == 2
225
226 # First item should be parsed to ResponseFunctionToolCall
227 assert isinstance(request.input[0], ResponseFunctionToolCall)
228 assert request.input[0].call_id == "fc_call_456"
229 assert request.input[0].name == "get_weather"
230
231 # Second item should remain as dict (FunctionCallOutput)
232 assert isinstance(request.input[1], dict)
233 assert request.input[1]["type"] == "function_call_output"
234 assert request.input[1]["call_id"] == "fc_call_456"
235 assert request.input[1]["output"] == "NYC weather is 68°F with light rain"
236
237
238 def test_function_call_validation_failure_logs_debug(caplog):
239 """Test that validation failures are logged at debug level."""
240 from unittest.mock import patch
241
242 request_data = {
243 "model": "gpt-oss",
244 "input": [
245 {
246 "type": "function_call",
247 "name": "incomplete_function",
248 "arguments": "{}", # Missing call_id
249 }
250 ],
251 }
252
253 # Mock the logger to verify debug was called
254 with patch("vllm.entrypoints.openai.responses.protocol.logger") as mock_logger:
255 with pytest.raises(ValueError):
256 ResponsesRequest(**request_data)
257
258 # Verify debug was called with expected message
259 mock_logger.debug.assert_called_once()
260 call_args = mock_logger.debug.call_args[0][0]
261 assert "Failed to parse function_call" in call_args
262
263
264 def test_validator_handles_iterator_input():
265 """Test that validator can handle ValidatorIterator input (Pydantic internal)."""
266 # This test simulates when Pydantic passes a ValidatorIterator instead of a list
267 # This happened with complex nested structures containing reasoning + function_call
268
269 # Create test data that would normally be a list
270 test_input_items = [
271 {
272 "type": "message",
273 "role": "user",
274 "content": [{"type": "input_text", "text": "Test"}],
275 },
276 {
277 "type": "reasoning",
278 "id": "rs_1",
279 "summary": [{"type": "summary_text", "text": "Test reasoning"}],
280 "content": [{"type": "reasoning_text", "text": "Test content"}],
281 },
282 {
283 "type": "function_call",
284 "call_id": "call_1",
285 "name": "test_function",
286 "arguments": '{"test": "value"}',
287 "id": "fc_1",
288 },
289 ]
290
291 # Mock data where input is an iterator (simulates Pydantic ValidatorIterator)
292 mock_data = {
293 "model": "test-model",
294 "input": iter(test_input_items), # Iterator instead of list
295 }
296
297 # This should NOT raise an error with the fixed validator
298 try:
299 request = ResponsesRequest(**mock_data)
300
301 # Verify the validator processed the data correctly
302 assert len(request.input) == 3
303
304 # Verify function_call was converted to ResponseFunctionToolCall object
305 function_call_item = None
306 for item in request.input:
307 if isinstance(item, ResponseFunctionToolCall):
308 function_call_item = item
309 break
310
311 assert function_call_item is not None
312 assert function_call_item.call_id == "call_1"
313 assert function_call_item.name == "test_function"
314
315 except Exception as e:
316 pytest.fail(f"Validator should handle iterator input, but failed with: {e}")
317
318
319 def test_validator_handles_empty_iterator():
320 """Test validator handles empty iterator gracefully."""
321 mock_data = {
322 "model": "test-model",
323 "input": iter([]), # Empty iterator
324 }
325
326 request = ResponsesRequest(**mock_data)
327 assert request.input == []
328
329
330 def test_assistant_string_content_stays_easyinput():
331 """EasyInput assistant message with plain string content is not
332 coerced into a ResponseOutputMessage."""
333 request_data = {
334 "model": "test-model",
335 "input": [
336 {"type": "message", "role": "assistant", "content": "hello"},
337 ],
338 }
339
340 request = ResponsesRequest(**request_data)
341
342 item = request.input[0]
343 assert isinstance(item, dict), (
344 "String-content assistant message should remain a dict (EasyInput), "
345 f"got {type(item)}"
346 )
347 assert item.get("content") == "hello"
348 assert "id" not in item
349 assert "status" not in item
350
351
352 def test_assistant_output_style_content_coerced():
353 """Assistant message whose content is output-message-shaped (list of
354 output_text items) should be coerced to ResponseOutputMessage."""
355 request_data = {
356 "model": "test-model",
357 "input": [
358 {
359 "type": "message",
360 "role": "assistant",
361 "content": [{"type": "output_text", "text": "world"}],
362 },
363 ],
364 }
365
366 request = ResponsesRequest(**request_data)
367
368 item = request.input[0]
369 assert isinstance(item, ResponseOutputMessage), (
370 "Output-style assistant message should be coerced to "
371 f"ResponseOutputMessage, got {type(item)}"
372 )
373 assert item.content[0].text == "world"
374 assert item.content[0].annotations == []
375 assert item.status == "completed"
376 assert item.id.startswith("msg_")