| 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_") |