From 074475541a845a5a22851faf3a7002ac35181e5c Mon Sep 17 00:00:00 2001 From: strinczer Date: Fri, 24 Oct 2025 06:53:42 +0100 Subject: [PATCH] [Bugfix] Fix Pydantic union resolution for ResponseFunctionToolCall in Responses API (#26706) Signed-off-by: Shai Trinczer Co-authored-by: Chauncey Co-authored-by: Ye (Charlotte) Qi --- .../test_responses_function_call_parsing.py | 330 ++++++++++++++++++ vllm/entrypoints/openai/protocol.py | 43 +++ 2 files changed, 373 insertions(+) create mode 100644 tests/entrypoints/openai/test_responses_function_call_parsing.py diff --git a/tests/entrypoints/openai/test_responses_function_call_parsing.py b/tests/entrypoints/openai/test_responses_function_call_parsing.py new file mode 100644 index 000000000000..3c5a11c867eb --- /dev/null +++ b/tests/entrypoints/openai/test_responses_function_call_parsing.py @@ -0,0 +1,330 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Test function call parsing in ResponsesRequest.""" + +import json + +import pytest +from openai.types.responses import ResponseFunctionToolCall + +from vllm.entrypoints.openai.protocol import ResponsesRequest + + +def test_function_call_dict_converted_to_object(): + """Test that function_call dictionaries are correctly parsed into + ResponseFunctionToolCall objects.""" + # Create a request with function_call as dict + request_data = { + "model": "gpt-oss", + "input": [ + { + "type": "function_call", + "call_id": "fc_123", + "name": "get_weather", + "arguments": '{"location": "Boston", "unit": "celsius"}', + } + ], + } + + request = ResponsesRequest(**request_data) + + # Verify the input item is now a ResponseFunctionToolCall object + assert len(request.input) == 1 + assert isinstance(request.input[0], ResponseFunctionToolCall) + assert request.input[0].call_id == "fc_123" + assert request.input[0].name == "get_weather" + assert request.input[0].arguments == '{"location": "Boston", "unit": "celsius"}' + + +def test_direct_function_call_object_preservation(): + """Test that ResponseFunctionToolCall objects passed directly are preserved.""" + # Create a request with ResponseFunctionToolCall object + function_call = ResponseFunctionToolCall( + type="function_call", + call_id="fc_456", + name="get_stock_price", + arguments='{"symbol": "AAPL"}', + ) + + request_data = {"model": "gpt-oss", "input": [function_call]} + + request = ResponsesRequest(**request_data) + + # Verify the object is preserved + assert len(request.input) == 1 + assert request.input[0] is function_call + + +def test_mixed_input_types_with_function_calls(): + """Test parsing with mixed input types including function calls.""" + + request_data = { + "model": "gpt-oss", + "input": [ + # Valid Message type + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "What's the weather?"}], + }, + # Function call that should be parsed + { + "type": "function_call", + "call_id": "fc_789", + "name": "check_weather", + "arguments": '{"location": "NYC"}', + }, + # Another function call + { + "type": "function_call", + "call_id": "fc_790", + "name": "get_time", + "arguments": "{}", + }, + ], + } + + request = ResponsesRequest(**request_data) + + # Verify mixed types are handled correctly + assert len(request.input) == 3 + # First item should be validated as Message + assert request.input[0]["type"] == "message" + # Second item should be parsed to ResponseFunctionToolCall + assert isinstance(request.input[1], ResponseFunctionToolCall) + assert request.input[1].call_id == "fc_789" + assert request.input[1].name == "check_weather" + # Third item should also be parsed to ResponseFunctionToolCall + assert isinstance(request.input[2], ResponseFunctionToolCall) + assert request.input[2].call_id == "fc_790" + assert request.input[2].name == "get_time" + + +def test_function_call_with_complex_arguments(): + """Test parsing function calls with complex nested arguments.""" + complex_args = { + "query": "weather forecast", + "filters": { + "location": {"city": "San Francisco", "state": "CA"}, + "timeRange": {"start": "2024-01-01", "end": "2024-01-07"}, + "metrics": ["temperature", "humidity", "precipitation"], + }, + "options": {"format": "detailed", "includeAlerts": True}, + } + + request_data = { + "model": "gpt-oss", + "input": [ + { + "type": "function_call", + "call_id": "fc_complex", + "name": "advanced_weather_query", + "arguments": json.dumps(complex_args), + } + ], + } + + request = ResponsesRequest(**request_data) + + # Verify complex arguments are preserved correctly + assert len(request.input) == 1 + assert isinstance(request.input[0], ResponseFunctionToolCall) + assert request.input[0].call_id == "fc_complex" + assert request.input[0].name == "advanced_weather_query" + + # Parse the arguments back to verify they're intact + parsed_args = json.loads(request.input[0].arguments) + assert parsed_args == complex_args + + +def test_invalid_function_call_fallback(): + """Test that invalid function call dictionaries fall back gracefully.""" + # Missing required field 'call_id' + request_data = { + "model": "gpt-oss", + "input": [ + {"type": "function_call", "name": "incomplete_function", "arguments": "{}"} + ], + } + + # This should not raise an error during model creation + # The validator should keep the original dict and let Pydantic + # handle validation + with pytest.raises(ValueError): + # Pydantic should raise a validation error for the invalid structure + ResponsesRequest(**request_data) + + +def test_string_input_not_affected(): + """Test that string input is not affected by the validator.""" + request_data = {"model": "gpt-oss", "input": "This is a simple string input"} + + request = ResponsesRequest(**request_data) + + # Verify string input remains unchanged + assert request.input == "This is a simple string input" + + +def test_empty_list_input(): + """Test that empty list input is handled correctly.""" + request_data = {"model": "gpt-oss", "input": []} + + request = ResponsesRequest(**request_data) + + # Verify empty list is preserved + assert request.input == [] + + +def test_function_call_output_not_affected(): + """Test that FunctionCallOutput is not affected by the function_call parsing.""" + + # Test with FunctionCallOutput as dict (should not be parsed) + request_data = { + "model": "gpt-oss", + "input": [ + { + "type": "function_call_output", + "call_id": "fc_output_123", + "output": "The weather in Boston is 72°F and sunny.", + } + ], + } + + request = ResponsesRequest(**request_data) + + # FunctionCallOutput should remain as dict (not converted to an object) + assert len(request.input) == 1 + assert isinstance(request.input[0], dict) + assert request.input[0]["type"] == "function_call_output" + assert request.input[0]["call_id"] == "fc_output_123" + assert request.input[0]["output"] == "The weather in Boston is 72°F and sunny." + + +def test_mixed_function_call_and_output(): + """Test that function_call is parsed while function_call_output is preserved.""" + request_data = { + "model": "gpt-oss", + "input": [ + # This should be parsed to ResponseFunctionToolCall + { + "type": "function_call", + "call_id": "fc_call_456", + "name": "get_weather", + "arguments": '{"location": "NYC"}', + }, + # This should remain as dict + { + "type": "function_call_output", + "call_id": "fc_call_456", + "output": "NYC weather is 68°F with light rain", + }, + ], + } + + request = ResponsesRequest(**request_data) + + assert len(request.input) == 2 + + # First item should be parsed to ResponseFunctionToolCall + assert isinstance(request.input[0], ResponseFunctionToolCall) + assert request.input[0].call_id == "fc_call_456" + assert request.input[0].name == "get_weather" + + # Second item should remain as dict (FunctionCallOutput) + assert isinstance(request.input[1], dict) + assert request.input[1]["type"] == "function_call_output" + assert request.input[1]["call_id"] == "fc_call_456" + assert request.input[1]["output"] == "NYC weather is 68°F with light rain" + + +def test_function_call_validation_failure_logs_debug(caplog): + """Test that validation failures are logged at debug level.""" + from unittest.mock import patch + + request_data = { + "model": "gpt-oss", + "input": [ + { + "type": "function_call", + "name": "incomplete_function", + "arguments": "{}", # Missing call_id + } + ], + } + + # Mock the logger to verify debug was called + with patch("vllm.entrypoints.openai.protocol.logger") as mock_logger: + with pytest.raises(ValueError): + ResponsesRequest(**request_data) + + # Verify debug was called with expected message + mock_logger.debug.assert_called_once() + call_args = mock_logger.debug.call_args[0][0] + assert "Failed to parse function_call" in call_args + + +def test_validator_handles_iterator_input(): + """Test that validator can handle ValidatorIterator input (Pydantic internal).""" + + # This test simulates when Pydantic passes a ValidatorIterator instead of a list + # This happened with complex nested structures containing reasoning + function_call + + # Create test data that would normally be a list + test_input_items = [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Test"}], + }, + { + "type": "reasoning", + "id": "rs_1", + "summary": [{"type": "summary_text", "text": "Test reasoning"}], + "content": [{"type": "reasoning_text", "text": "Test content"}], + }, + { + "type": "function_call", + "call_id": "call_1", + "name": "test_function", + "arguments": '{"test": "value"}', + "id": "fc_1", + }, + ] + + # Mock data where input is an iterator (simulates Pydantic ValidatorIterator) + mock_data = { + "model": "test-model", + "input": iter(test_input_items), # Iterator instead of list + } + + # This should NOT raise an error with the fixed validator + try: + request = ResponsesRequest(**mock_data) + + # Verify the validator processed the data correctly + assert len(request.input) == 3 + + # Verify function_call was converted to ResponseFunctionToolCall object + function_call_item = None + for item in request.input: + if isinstance(item, ResponseFunctionToolCall): + function_call_item = item + break + + assert function_call_item is not None + assert function_call_item.call_id == "call_1" + assert function_call_item.name == "test_function" + + except Exception as e: + pytest.fail(f"Validator should handle iterator input, but failed with: {e}") + + +def test_validator_handles_empty_iterator(): + """Test validator handles empty iterator gracefully.""" + mock_data = { + "model": "test-model", + "input": iter([]), # Empty iterator + } + + request = ResponsesRequest(**mock_data) + assert request.input == [] diff --git a/vllm/entrypoints/openai/protocol.py b/vllm/entrypoints/openai/protocol.py index ca70faf62d62..7d32d5b23f1e 100644 --- a/vllm/entrypoints/openai/protocol.py +++ b/vllm/entrypoints/openai/protocol.py @@ -69,6 +69,7 @@ from pydantic import ( ConfigDict, Field, TypeAdapter, + ValidationError, ValidationInfo, field_serializer, field_validator, @@ -478,6 +479,48 @@ class ResponsesRequest(OpenAIBaseModel): ) return data + @model_validator(mode="before") + def function_call_parsing(cls, data): + """Parse function_call dictionaries into ResponseFunctionToolCall objects. + This ensures Pydantic can properly resolve union types in the input field. + Function calls provided as dicts are converted to ResponseFunctionToolCall + objects before validation, while invalid structures are left for Pydantic + to reject with appropriate error messages. + """ + + input_data = data.get("input") + + # Early return for None, strings, or bytes + # (strings are iterable but shouldn't be processed) + if input_data is None or isinstance(input_data, (str, bytes)): + return data + + # Convert iterators (like ValidatorIterator) to list + if not isinstance(input_data, list): + try: + input_data = list(input_data) + except TypeError: + # Not iterable, leave as-is for Pydantic to handle + return data + + processed_input = [] + for item in input_data: + if isinstance(item, dict) and item.get("type") == "function_call": + try: + processed_input.append(ResponseFunctionToolCall(**item)) + except ValidationError: + # Let Pydantic handle validation for malformed function calls + logger.debug( + "Failed to parse function_call to ResponseFunctionToolCall, " + "leaving for Pydantic validation" + ) + processed_input.append(item) + else: + processed_input.append(item) + + data["input"] = processed_input + return data + class ChatCompletionRequest(OpenAIBaseModel): # Ordered by official OpenAI API documentation