main
py 248 lines 7.39 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import pytest
5
6 from vllm.entrypoints.openai.responses.protocol import (
7 ResponsesRequest,
8 ResponsesResponse,
9 )
10 from vllm.exceptions import VLLMValidationError
11
12 SAMPLE_TOOL = {
13 "type": "function",
14 "name": "get_weather",
15 "description": "Get current weather",
16 "parameters": {
17 "type": "object",
18 "properties": {"location": {"type": "string", "description": "City name"}},
19 "required": ["location"],
20 },
21 }
22
23 NAMED_TOOL_CHOICE = {
24 "type": "function",
25 "name": "get_weather",
26 }
27
28
29 def test_responses_request_with_no_tools():
30 # tools key is not present — defaults tool_choice to "none"
31 request = ResponsesRequest.model_validate({"input": "Hello", "model": "test-model"})
32 assert request.tool_choice == "none"
33
34 # tools key present but empty
35 request = ResponsesRequest.model_validate(
36 {"input": "Hello", "model": "test-model", "tools": []}
37 )
38 assert request.tool_choice == "none"
39
40
41 def test_responses_request_no_tools_tool_choice_none():
42 request = ResponsesRequest.model_validate(
43 {"input": "Hello", "model": "test-model", "tool_choice": "none"}
44 )
45 assert request.tool_choice == "none"
46
47
48 def test_responses_request_no_tools_tool_choice_auto():
49 request = ResponsesRequest.model_validate(
50 {"input": "Hello", "model": "test-model", "tool_choice": "auto"}
51 )
52 assert request.tool_choice == "none"
53
54
55 @pytest.mark.parametrize("tools", [None, []])
56 def test_responses_request_required_without_tools(tools):
57 kwargs = {"input": "Hello", "model": "test-model", "tool_choice": "required"}
58 if tools is not None:
59 kwargs["tools"] = tools
60 with pytest.raises(
61 VLLMValidationError, match="Tool choice 'required' must be specified"
62 ):
63 ResponsesRequest.model_validate(kwargs)
64
65
66 def test_responses_request_named_tool_choice_without_tools():
67 with pytest.raises(VLLMValidationError, match="not found in 'tools' parameter"):
68 ResponsesRequest.model_validate(
69 {
70 "input": "Hello",
71 "model": "test-model",
72 "tool_choice": NAMED_TOOL_CHOICE,
73 }
74 )
75
76
77 def test_responses_request_with_tools_default_tool_choice():
78 request = ResponsesRequest.model_validate(
79 {"input": "Hello", "model": "test-model", "tools": [SAMPLE_TOOL]}
80 )
81 assert request.tool_choice == "auto"
82
83
84 def test_responses_request_with_tools_tool_choice_none():
85 request = ResponsesRequest.model_validate(
86 {
87 "input": "Hello",
88 "model": "test-model",
89 "tools": [SAMPLE_TOOL],
90 "tool_choice": "none",
91 }
92 )
93 assert request.tool_choice == "none"
94
95
96 def test_responses_request_named_tool_choice_matching():
97 request = ResponsesRequest.model_validate(
98 {
99 "input": "Hello",
100 "model": "test-model",
101 "tools": [SAMPLE_TOOL],
102 "tool_choice": NAMED_TOOL_CHOICE,
103 }
104 )
105 assert request.tool_choice.type == "function"
106 assert request.tool_choice.name == "get_weather"
107
108
109 def test_responses_request_named_tool_choice_not_matching():
110 with pytest.raises(VLLMValidationError, match="not found in 'tools' parameter"):
111 ResponsesRequest.model_validate(
112 {
113 "input": "Hello",
114 "model": "test-model",
115 "tools": [SAMPLE_TOOL],
116 "tool_choice": {"type": "function", "name": "nonexistent"},
117 }
118 )
119
120
121 def test_responses_request_with_tools_tool_choice_auto():
122 request = ResponsesRequest.model_validate(
123 {
124 "input": "Hello",
125 "model": "test-model",
126 "tools": [SAMPLE_TOOL],
127 "tool_choice": "auto",
128 }
129 )
130 assert request.tool_choice == "auto"
131
132
133 def test_responses_request_with_tools_tool_choice_required():
134 request = ResponsesRequest.model_validate(
135 {
136 "input": "Hello",
137 "model": "test-model",
138 "tools": [SAMPLE_TOOL],
139 "tool_choice": "required",
140 }
141 )
142 assert request.tool_choice == "required"
143
144
145 def test_responses_request_empty_tools_tool_choice_none():
146 request = ResponsesRequest.model_validate(
147 {"input": "Hello", "model": "test-model", "tools": [], "tool_choice": "none"}
148 )
149 assert request.tool_choice == "none"
150
151
152 def test_responses_request_empty_tools_tool_choice_auto():
153 request = ResponsesRequest.model_validate(
154 {"input": "Hello", "model": "test-model", "tools": [], "tool_choice": "auto"}
155 )
156 assert request.tool_choice == "none"
157
158
159 @pytest.mark.parametrize(
160 "tool_choice",
161 [
162 {"type": "function"},
163 {"type": "function", "name": ""},
164 ],
165 )
166 def test_responses_request_named_tool_choice_missing_name(tool_choice):
167 with pytest.raises(VLLMValidationError, match="not found in 'tools' parameter"):
168 ResponsesRequest.model_validate(
169 {
170 "input": "Hello",
171 "model": "test-model",
172 "tools": [SAMPLE_TOOL],
173 "tool_choice": tool_choice,
174 }
175 )
176
177
178 def test_responses_request_empty_tools_named_tool_choice():
179 with pytest.raises(VLLMValidationError, match="not found in 'tools' parameter"):
180 ResponsesRequest.model_validate(
181 {
182 "input": "Hello",
183 "model": "test-model",
184 "tools": [],
185 "tool_choice": NAMED_TOOL_CHOICE,
186 }
187 )
188
189
190 @pytest.mark.parametrize("detail", [None, "low"], ids=["default", "explicit"])
191 def test_input_image_detail_defaults_or_preserves(detail: str | None) -> None:
192 image = {
193 "type": "input_image",
194 "image_url": "https://example.com/image.png",
195 }
196 if detail is not None:
197 image["detail"] = detail
198
199 item = {
200 "role": "user",
201 "content": [image],
202 }
203
204 request = ResponsesRequest.model_validate({"input": [item]})
205
206 assert isinstance(request.input, list)
207 assert request.input[0]["content"][0]["detail"] == (
208 "auto" if detail is None else detail
209 )
210
211
212 # Regression tests for parallel_tool_calls=null crash in Responses API
213 # (from_request() passed None to ResponsesResponse.parallel_tool_calls,
214 # a non-optional bool field, causing a Pydantic ValidationError during
215 # response construction)
216 @pytest.mark.parametrize(
217 "value,expected",
218 [
219 (True, True),
220 (False, False),
221 (None, True), # null must resolve to the documented default (true)
222 ],
223 )
224 def test_responses_response_parallel_tool_calls_null_resolves_to_default(
225 value, expected
226 ):
227 request = ResponsesRequest.model_validate(
228 {"input": "Hello", "model": "test-model", "parallel_tool_calls": value}
229 )
230 sampling_params = request.to_sampling_params(default_max_tokens=16)
231 r = ResponsesResponse.from_request(
232 request=request,
233 sampling_params=sampling_params,
234 model_name="test-model",
235 created_time=0,
236 output=[],
237 status="completed",
238 usage=None,
239 )
240 assert r.parallel_tool_calls == expected
241
242
243 def test_responses_request_parallel_tool_calls_null_accepted():
244 """Client sending null must be accepted at request validation time."""
245 req = ResponsesRequest.model_validate(
246 {"input": "Hello", "model": "test-model", "parallel_tool_calls": None}
247 )
248 assert req.parallel_tool_calls is None