diff --git a/tests/tool_use/test_responses_request_validations.py b/tests/tool_use/test_responses_request_validations.py new file mode 100644 index 00000000000..63a1828c500 --- /dev/null +++ b/tests/tool_use/test_responses_request_validations.py @@ -0,0 +1,184 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import pytest +from pydantic import ValidationError + +from vllm.entrypoints.openai.responses.protocol import ResponsesRequest + +SAMPLE_TOOL = { + "type": "function", + "name": "get_weather", + "description": "Get current weather", + "parameters": { + "type": "object", + "properties": {"location": {"type": "string", "description": "City name"}}, + "required": ["location"], + }, +} + +NAMED_TOOL_CHOICE = { + "type": "function", + "name": "get_weather", +} + + +def test_responses_request_with_no_tools(): + # tools key is not present — defaults tool_choice to "none" + request = ResponsesRequest.model_validate({"input": "Hello", "model": "test-model"}) + assert request.tool_choice == "none" + + # tools key present but empty + request = ResponsesRequest.model_validate( + {"input": "Hello", "model": "test-model", "tools": []} + ) + assert request.tool_choice == "none" + + +def test_responses_request_no_tools_tool_choice_none(): + request = ResponsesRequest.model_validate( + {"input": "Hello", "model": "test-model", "tool_choice": "none"} + ) + assert request.tool_choice == "none" + + +def test_responses_request_no_tools_tool_choice_auto(): + request = ResponsesRequest.model_validate( + {"input": "Hello", "model": "test-model", "tool_choice": "auto"} + ) + assert request.tool_choice == "none" + + +@pytest.mark.parametrize("tools", [None, []]) +def test_responses_request_required_without_tools(tools): + kwargs = {"input": "Hello", "model": "test-model", "tool_choice": "required"} + if tools is not None: + kwargs["tools"] = tools + with pytest.raises( + ValidationError, match="Tool choice 'required' must be specified" + ): + ResponsesRequest.model_validate(kwargs) + + +def test_responses_request_named_tool_choice_without_tools(): + with pytest.raises(ValidationError, match="not found in 'tools' parameter"): + ResponsesRequest.model_validate( + { + "input": "Hello", + "model": "test-model", + "tool_choice": NAMED_TOOL_CHOICE, + } + ) + + +def test_responses_request_with_tools_default_tool_choice(): + request = ResponsesRequest.model_validate( + {"input": "Hello", "model": "test-model", "tools": [SAMPLE_TOOL]} + ) + assert request.tool_choice == "auto" + + +def test_responses_request_with_tools_tool_choice_none(): + request = ResponsesRequest.model_validate( + { + "input": "Hello", + "model": "test-model", + "tools": [SAMPLE_TOOL], + "tool_choice": "none", + } + ) + assert request.tool_choice == "none" + + +def test_responses_request_named_tool_choice_matching(): + request = ResponsesRequest.model_validate( + { + "input": "Hello", + "model": "test-model", + "tools": [SAMPLE_TOOL], + "tool_choice": NAMED_TOOL_CHOICE, + } + ) + assert request.tool_choice.type == "function" + assert request.tool_choice.name == "get_weather" + + +def test_responses_request_named_tool_choice_not_matching(): + with pytest.raises(ValidationError, match="not found in 'tools' parameter"): + ResponsesRequest.model_validate( + { + "input": "Hello", + "model": "test-model", + "tools": [SAMPLE_TOOL], + "tool_choice": {"type": "function", "name": "nonexistent"}, + } + ) + + +def test_responses_request_with_tools_tool_choice_auto(): + request = ResponsesRequest.model_validate( + { + "input": "Hello", + "model": "test-model", + "tools": [SAMPLE_TOOL], + "tool_choice": "auto", + } + ) + assert request.tool_choice == "auto" + + +def test_responses_request_with_tools_tool_choice_required(): + request = ResponsesRequest.model_validate( + { + "input": "Hello", + "model": "test-model", + "tools": [SAMPLE_TOOL], + "tool_choice": "required", + } + ) + assert request.tool_choice == "required" + + +def test_responses_request_empty_tools_tool_choice_none(): + request = ResponsesRequest.model_validate( + {"input": "Hello", "model": "test-model", "tools": [], "tool_choice": "none"} + ) + assert request.tool_choice == "none" + + +def test_responses_request_empty_tools_tool_choice_auto(): + request = ResponsesRequest.model_validate( + {"input": "Hello", "model": "test-model", "tools": [], "tool_choice": "auto"} + ) + assert request.tool_choice == "none" + + +@pytest.mark.parametrize( + "tool_choice", + [ + {"type": "function"}, + {"type": "function", "name": ""}, + ], +) +def test_responses_request_named_tool_choice_missing_name(tool_choice): + with pytest.raises(ValidationError, match="not found in 'tools' parameter"): + ResponsesRequest.model_validate( + { + "input": "Hello", + "model": "test-model", + "tools": [SAMPLE_TOOL], + "tool_choice": tool_choice, + } + ) + + +def test_responses_request_empty_tools_named_tool_choice(): + with pytest.raises(ValidationError, match="not found in 'tools' parameter"): + ResponsesRequest.model_validate( + { + "input": "Hello", + "model": "test-model", + "tools": [], + "tool_choice": NAMED_TOOL_CHOICE, + } + ) diff --git a/vllm/entrypoints/openai/responses/protocol.py b/vllm/entrypoints/openai/responses/protocol.py index 79f5894fb91..96876e3f00f 100644 --- a/vllm/entrypoints/openai/responses/protocol.py +++ b/vllm/entrypoints/openai/responses/protocol.py @@ -492,6 +492,46 @@ class ResponsesRequest(OpenAIBaseModel): data["input"] = processed_input return data + @model_validator(mode="before") + @classmethod + def check_tool_usage(cls, data): + if not isinstance(data, dict): + return data + + tools = data.get("tools") + tool_choice = data.get("tool_choice", "auto") + has_tools = tools is not None and len(tools) > 0 + is_named_tool_choice = ( + isinstance(tool_choice, dict) and tool_choice.get("type") == "function" + ) + + if not has_tools: + if tool_choice in ("auto", "none"): + data["tool_choice"] = "none" + elif tool_choice == "required": + raise VLLMValidationError( + "Tool choice 'required' must be specified with 'tools' parameter.", + parameter="tool_choice", + ) + elif is_named_tool_choice: + raise VLLMValidationError( + "Tool choice 'function' not found in 'tools' parameter.", + parameter="tool_choice", + ) + elif is_named_tool_choice and tools is not None: + tool_name = tool_choice.get("name") + tool_names = { + t.get("name") if isinstance(t, dict) else getattr(t, "name", None) + for t in tools + } + if not tool_name or tool_name not in tool_names: + raise VLLMValidationError( + "Tool choice 'function' not found in 'tools' parameter.", + parameter="tool_choice", + ) + + return data + class ResponsesResponse(OpenAIBaseModel): id: str = Field(default_factory=lambda: f"resp_{random_uuid()}") diff --git a/vllm/entrypoints/openai/responses/serving.py b/vllm/entrypoints/openai/responses/serving.py index 6af25c9bcce..6d018f9c5b5 100644 --- a/vllm/entrypoints/openai/responses/serving.py +++ b/vllm/entrypoints/openai/responses/serving.py @@ -718,9 +718,10 @@ class OpenAIServingResponses(OpenAIServing): request: ResponsesRequest, prev_response: ResponsesResponse | None, ): - if request.tool_choice != "auto": + if request.tool_choice not in ("auto", "none"): raise NotImplementedError( - "Only 'auto' tool_choice is supported in response API with Harmony" + "Only 'auto' or 'none' tool_choice is supported " + "in response API with Harmony" ) arrival_time = time.time()