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 4, 18:45 UTC
[Responses] Add tool_choice/tools validation to match OpenAI behavior (#40399)
Signed-off-by: sfeng33 <4florafeng@gmail.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Flora Feng committed
Apr 23, 2026 at 01:46 UTC
8317cedc7718301866963400646b1d624a6471ea
3 files changed
+227
-2
tests/tool_use/test_responses_request_validations.py
new
+184
@@ -0,0 +1,184 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import pytest
5
+from pydantic import ValidationError
6
+
7
+from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
8
+
9
+SAMPLE_TOOL = {
10
+ "type": "function",
11
+ "name": "get_weather",
12
+ "description": "Get current weather",
13
+ "parameters": {
14
+ "type": "object",
15
+ "properties": {"location": {"type": "string", "description": "City name"}},
16
+ "required": ["location"],
17
+ },
18
+}
19
+
20
+NAMED_TOOL_CHOICE = {
21
+ "type": "function",
22
+ "name": "get_weather",
23
+}
24
+
25
+
26
+def test_responses_request_with_no_tools():
27
+ # tools key is not present — defaults tool_choice to "none"
28
+ request = ResponsesRequest.model_validate({"input": "Hello", "model": "test-model"})
29
+ assert request.tool_choice == "none"
30
+
31
+ # tools key present but empty
32
+ request = ResponsesRequest.model_validate(
33
+ {"input": "Hello", "model": "test-model", "tools": []}
34
+ )
35
+ assert request.tool_choice == "none"
36
+
37
+
38
+def test_responses_request_no_tools_tool_choice_none():
39
+ request = ResponsesRequest.model_validate(
40
+ {"input": "Hello", "model": "test-model", "tool_choice": "none"}
41
+ )
42
+ assert request.tool_choice == "none"
43
+
44
+
45
+def test_responses_request_no_tools_tool_choice_auto():
46
+ request = ResponsesRequest.model_validate(
47
+ {"input": "Hello", "model": "test-model", "tool_choice": "auto"}
48
+ )
49
+ assert request.tool_choice == "none"
50
+
51
+
52
+@pytest.mark.parametrize("tools", [None, []])
53
+def test_responses_request_required_without_tools(tools):
54
+ kwargs = {"input": "Hello", "model": "test-model", "tool_choice": "required"}
55
+ if tools is not None:
56
+ kwargs["tools"] = tools
57
+ with pytest.raises(
58
+ ValidationError, match="Tool choice 'required' must be specified"
59
+ ):
60
+ ResponsesRequest.model_validate(kwargs)
61
+
62
+
63
+def test_responses_request_named_tool_choice_without_tools():
64
+ with pytest.raises(ValidationError, match="not found in 'tools' parameter"):
65
+ ResponsesRequest.model_validate(
66
+ {
67
+ "input": "Hello",
68
+ "model": "test-model",
69
+ "tool_choice": NAMED_TOOL_CHOICE,
70
+ }
71
+ )
72
+
73
+
74
+def test_responses_request_with_tools_default_tool_choice():
75
+ request = ResponsesRequest.model_validate(
76
+ {"input": "Hello", "model": "test-model", "tools": [SAMPLE_TOOL]}
77
+ )
78
+ assert request.tool_choice == "auto"
79
+
80
+
81
+def test_responses_request_with_tools_tool_choice_none():
82
+ request = ResponsesRequest.model_validate(
83
+ {
84
+ "input": "Hello",
85
+ "model": "test-model",
86
+ "tools": [SAMPLE_TOOL],
87
+ "tool_choice": "none",
88
+ }
89
+ )
90
+ assert request.tool_choice == "none"
91
+
92
+
93
+def test_responses_request_named_tool_choice_matching():
94
+ request = ResponsesRequest.model_validate(
95
+ {
96
+ "input": "Hello",
97
+ "model": "test-model",
98
+ "tools": [SAMPLE_TOOL],
99
+ "tool_choice": NAMED_TOOL_CHOICE,
100
+ }
101
+ )
102
+ assert request.tool_choice.type == "function"
103
+ assert request.tool_choice.name == "get_weather"
104
+
105
+
106
+def test_responses_request_named_tool_choice_not_matching():
107
+ with pytest.raises(ValidationError, match="not found in 'tools' parameter"):
108
+ ResponsesRequest.model_validate(
109
+ {
110
+ "input": "Hello",
111
+ "model": "test-model",
112
+ "tools": [SAMPLE_TOOL],
113
+ "tool_choice": {"type": "function", "name": "nonexistent"},
114
+ }
115
+ )
116
+
117
+
118
+def test_responses_request_with_tools_tool_choice_auto():
119
+ request = ResponsesRequest.model_validate(
120
+ {
121
+ "input": "Hello",
122
+ "model": "test-model",
123
+ "tools": [SAMPLE_TOOL],
124
+ "tool_choice": "auto",
125
+ }
126
+ )
127
+ assert request.tool_choice == "auto"
128
+
129
+
130
+def test_responses_request_with_tools_tool_choice_required():
131
+ request = ResponsesRequest.model_validate(
132
+ {
133
+ "input": "Hello",
134
+ "model": "test-model",
135
+ "tools": [SAMPLE_TOOL],
136
+ "tool_choice": "required",
137
+ }
138
+ )
139
+ assert request.tool_choice == "required"
140
+
141
+
142
+def test_responses_request_empty_tools_tool_choice_none():
143
+ request = ResponsesRequest.model_validate(
144
+ {"input": "Hello", "model": "test-model", "tools": [], "tool_choice": "none"}
145
+ )
146
+ assert request.tool_choice == "none"
147
+
148
+
149
+def test_responses_request_empty_tools_tool_choice_auto():
150
+ request = ResponsesRequest.model_validate(
151
+ {"input": "Hello", "model": "test-model", "tools": [], "tool_choice": "auto"}
152
+ )
153
+ assert request.tool_choice == "none"
154
+
155
+
156
+@pytest.mark.parametrize(
157
+ "tool_choice",
158
+ [
159
+ {"type": "function"},
160
+ {"type": "function", "name": ""},
161
+ ],
162
+)
163
+def test_responses_request_named_tool_choice_missing_name(tool_choice):
164
+ with pytest.raises(ValidationError, match="not found in 'tools' parameter"):
165
+ ResponsesRequest.model_validate(
166
+ {
167
+ "input": "Hello",
168
+ "model": "test-model",
169
+ "tools": [SAMPLE_TOOL],
170
+ "tool_choice": tool_choice,
171
+ }
172
+ )
173
+
174
+
175
+def test_responses_request_empty_tools_named_tool_choice():
176
+ with pytest.raises(ValidationError, match="not found in 'tools' parameter"):
177
+ ResponsesRequest.model_validate(
178
+ {
179
+ "input": "Hello",
180
+ "model": "test-model",
181
+ "tools": [],
182
+ "tool_choice": NAMED_TOOL_CHOICE,
183
+ }
184
+ )
vllm/entrypoints/openai/responses/protocol.py
+40
@@ -492,6 +492,46 @@ class ResponsesRequest(OpenAIBaseModel):
492
data["input"] = processed_input
493
return data
494
495
+ @model_validator(mode="before")
496
+ @classmethod
497
+ def check_tool_usage(cls, data):
498
+ if not isinstance(data, dict):
499
+ return data
500
+
501
+ tools = data.get("tools")
502
+ tool_choice = data.get("tool_choice", "auto")
503
+ has_tools = tools is not None and len(tools) > 0
504
+ is_named_tool_choice = (
505
+ isinstance(tool_choice, dict) and tool_choice.get("type") == "function"
506
+ )
507
+
508
+ if not has_tools:
509
+ if tool_choice in ("auto", "none"):
510
+ data["tool_choice"] = "none"
511
+ elif tool_choice == "required":
512
+ raise VLLMValidationError(
513
+ "Tool choice 'required' must be specified with 'tools' parameter.",
514
+ parameter="tool_choice",
515
+ )
516
+ elif is_named_tool_choice:
517
+ raise VLLMValidationError(
518
+ "Tool choice 'function' not found in 'tools' parameter.",
519
+ parameter="tool_choice",
520
+ )
521
+ elif is_named_tool_choice and tools is not None:
522
+ tool_name = tool_choice.get("name")
523
+ tool_names = {
524
+ t.get("name") if isinstance(t, dict) else getattr(t, "name", None)
525
+ for t in tools
526
+ }
527
+ if not tool_name or tool_name not in tool_names:
528
+ raise VLLMValidationError(
529
+ "Tool choice 'function' not found in 'tools' parameter.",
530
+ parameter="tool_choice",
531
+ )
532
+
533
+ return data
534
+
535
536
class ResponsesResponse(OpenAIBaseModel):
537
id: str = Field(default_factory=lambda: f"resp_{random_uuid()}")
vllm/entrypoints/openai/responses/serving.py
+3
-2
@@ -718,9 +718,10 @@ class OpenAIServingResponses(OpenAIServing):
718
request: ResponsesRequest,
719
prev_response: ResponsesResponse | None,
720
):
721
- if request.tool_choice != "auto":
721
+ if request.tool_choice not in ("auto", "none"):
722
raise NotImplementedError(
723
- "Only 'auto' tool_choice is supported in response API with Harmony"
723
+ "Only 'auto' or 'none' tool_choice is supported "
724
+ "in response API with Harmony"
725
)
726
727
arrival_time = time.time()