main
py 482 lines 16 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import json
4 from copy import deepcopy
5
6 import pytest
7 import regex as re
8 from openai.types.responses import FunctionTool, ToolChoiceFunction, WebSearchTool
9 from pydantic import TypeAdapter
10
11 from vllm.entrypoints.openai.chat_completion.protocol import (
12 ChatCompletionNamedFunction,
13 ChatCompletionNamedToolChoiceParam,
14 ChatCompletionToolsParam,
15 )
16 from vllm.tool_parsers.streaming import extract_required_tool_call_streaming
17 from vllm.tool_parsers.utils import (
18 find_tool_properties,
19 get_json_schema_from_tools,
20 )
21
22 pytestmark = pytest.mark.cpu_test
23
24 EXAMPLE_TOOLS = [
25 {
26 "type": "function",
27 "function": {
28 "name": "get_current_weather",
29 "description": "Get the current weather in a given location",
30 "parameters": {
31 "type": "object",
32 "properties": {
33 "city": {
34 "type": "string",
35 "description": "The city to find the weather for"
36 ", e.g. 'San Francisco'",
37 },
38 },
39 "required": ["city"],
40 "additionalProperties": False,
41 },
42 },
43 "strict": True,
44 },
45 {
46 "type": "function",
47 "function": {
48 "name": "get_forecast",
49 "description": "Get the weather forecast for a given location",
50 "parameters": {
51 "type": "object",
52 "properties": {
53 "city": {
54 "type": "string",
55 "description": "The city to get the forecast for, e.g. "
56 "'New York'",
57 },
58 "days": {
59 "type": "integer",
60 "description": "Number of days to get the forecast for (1-7)",
61 },
62 },
63 "required": ["city", "days"],
64 "additionalProperties": False,
65 },
66 },
67 "strict": True,
68 },
69 ]
70
71
72 def _compile_and_check(
73 tools: list[ChatCompletionToolsParam],
74 sample_output,
75 should_match: bool,
76 parallel_tool_calls: bool | None = None,
77 ):
78 # self = MagicMock(tool_choice="required", tools=tools)
79 # schema = ChatCompletionRequest._get_json_schema_from_tool(self)
80 schema = get_json_schema_from_tools(
81 tools=tools,
82 tool_choice="required",
83 parallel_tool_calls=parallel_tool_calls,
84 )
85 assert isinstance(schema, dict)
86
87 # use build_regex_from_schema used in JSONLogitsProcessor to create Guide
88 from outlines_core.json_schema import build_regex_from_schema
89
90 regex = build_regex_from_schema(json.dumps(schema))
91 compiled = re.compile(regex)
92 matches = compiled.fullmatch(json.dumps(sample_output)) is not None
93
94 assert matches == should_match
95
96
97 VALID_TOOL_OUTPUTS = [
98 ([{"name": "get_current_weather", "parameters": {"city": "Vienna"}}], True),
99 (
100 [
101 {"name": "get_current_weather", "parameters": {"city": "Vienna"}},
102 {"name": "get_current_weather", "parameters": {"city": "Berlin"}},
103 ],
104 True,
105 ),
106 ([{"name": "get_forecast", "parameters": {"city": "Vienna", "days": 7}}], True),
107 (
108 [
109 {"name": "get_forecast", "parameters": {"city": "Vienna", "days": 7}},
110 {"name": "get_current_weather", "parameters": {"city": "Vienna"}},
111 ],
112 True,
113 ),
114 (
115 [
116 {"name": "get_forecast", "parameters": {"city": "Vienna", "days": 7}},
117 {"name": "get_current_weather", "parameters": {"city": "Vienna"}},
118 {"name": "get_forecast", "parameters": {"city": "Berlin", "days": 7}},
119 {"name": "get_current_weather", "parameters": {"city": "Berlin"}},
120 ],
121 True,
122 ),
123 ]
124
125 VALID_TOOLS = [t[0] for t in VALID_TOOL_OUTPUTS]
126
127
128 @pytest.mark.parametrize(
129 "sample_output, should_match",
130 VALID_TOOL_OUTPUTS
131 + [
132 (None, False),
133 ([], False), # empty list cannot be generated
134 ({}, False), # empty object cannot be generated
135 ([{}], False), # list with empty object cannot be generated
136 (
137 [
138 { # function without required parameters cannot be generated
139 "name": "get_current_weather"
140 }
141 ],
142 False,
143 ),
144 (
145 [
146 { # function without required parameters cannot be generated
147 "name": "get_current_weather",
148 "parameters": {},
149 }
150 ],
151 False,
152 ),
153 (
154 [
155 { # function without required parameters cannot be generated
156 "name": "get_current_weather",
157 "parameters": None,
158 }
159 ],
160 False,
161 ),
162 (
163 { # tool call without lists cannot be generated
164 "name": "get_current_weather",
165 "parameters": {"city": "Vienna"},
166 },
167 False,
168 ),
169 (
170 [
171 { # tool call with extra parameters cannot be generated
172 "name": "get_current_weather",
173 "parameters": {"city": "Vienna", "extra": "value"},
174 }
175 ],
176 False,
177 ),
178 (
179 [
180 { # tool call where parameters are first cannot be generated
181 "parameters": {"city": "Vienna"},
182 "name": "get_current_weather",
183 }
184 ],
185 False,
186 ),
187 (
188 [
189 { # tool call without all required parameters cannot be generated
190 "name": "get_forecast",
191 "parameters": {"city": "Vienna"},
192 }
193 ],
194 False,
195 ),
196 ( # tool call with incorrect name/parameters cannot be generated
197 [{"name": "get_weather", "parameters": {"city": "Vienna", "days": 7}}],
198 False,
199 ),
200 ( # tool call with both valid and empty function cannot be generated
201 [{"name": "get_current_weather", "parameters": {"city": "Vienna"}}, {}],
202 False,
203 ),
204 ],
205 )
206 def test_structured_outputs_json(sample_output, should_match):
207 _compile_and_check(
208 tools=TypeAdapter(list[ChatCompletionToolsParam]).validate_python(
209 EXAMPLE_TOOLS
210 ),
211 sample_output=sample_output,
212 should_match=should_match,
213 )
214
215
216 def update_parameters_none(tool: ChatCompletionToolsParam) -> ChatCompletionToolsParam:
217 tool.function.parameters = None
218 return tool
219
220
221 def update_parameters_empty_dict(
222 tool: ChatCompletionToolsParam,
223 ) -> ChatCompletionToolsParam:
224 tool.function.parameters = {}
225 return tool
226
227
228 @pytest.mark.parametrize(
229 "sample_output, should_match",
230 [
231 (None, False),
232 ([], False), # empty list cannot be generated
233 ({}, False), # empty object cannot be generated
234 ([{}], False), # list with empty object cannot be generated
235 (
236 [
237 { # function without required parameters cannot be generated
238 "name": "get_current_weather"
239 }
240 ],
241 False,
242 ),
243 (
244 [
245 { # function without required parameters cannot be generated
246 "name": "get_current_weather",
247 "parameters": None,
248 }
249 ],
250 False,
251 ),
252 (
253 [
254 { # function with extra parameters cannot be generated
255 "name": "get_current_weather",
256 "parameters": {"extra": "value"},
257 }
258 ],
259 False,
260 ),
261 (
262 [
263 { # only function with empty parameters object is valid
264 "name": "get_current_weather",
265 "parameters": {},
266 }
267 ],
268 True,
269 ),
270 ],
271 )
272 @pytest.mark.parametrize(
273 "update_parameters", [update_parameters_none, update_parameters_empty_dict]
274 )
275 def test_structured_outputs_json_without_parameters(
276 sample_output, should_match, update_parameters
277 ):
278 updated_tools = [deepcopy(EXAMPLE_TOOLS[0])]
279 tools = TypeAdapter(list[ChatCompletionToolsParam]).validate_python(updated_tools)
280 tools = list(map(update_parameters, tools))
281 assert all(
282 [
283 tool.function.parameters is None or tool.function.parameters == {}
284 for tool in tools
285 ]
286 )
287 _compile_and_check(
288 tools=tools, sample_output=sample_output, should_match=should_match
289 )
290
291
292 def _collect_required_tool_streaming_json(output_json: str, delta_len: int) -> str:
293 previous_text = ""
294 function_name_returned = False
295 messages = []
296 for i in range(0, len(output_json), delta_len):
297 delta_text = output_json[i : i + delta_len]
298 current_text = previous_text + delta_text
299
300 delta_message, function_name_returned = extract_required_tool_call_streaming(
301 previous_text=previous_text,
302 current_text=current_text,
303 delta_text=delta_text,
304 function_name_returned=function_name_returned,
305 tool_call_idx=None,
306 tool_call_id_type="random",
307 )
308
309 if delta_message:
310 messages.append(delta_message)
311
312 previous_text = current_text
313
314 assert len(messages) > 0
315
316 combined_messages = "["
317 for message in messages:
318 fn = message.tool_calls[0].function
319 assert fn is not None
320 if fn.name:
321 if len(combined_messages) > 1:
322 combined_messages += "},"
323
324 combined_messages += (
325 '{"name": "' + fn.name + '", "parameters": ' + (fn.arguments or "")
326 )
327 else:
328 combined_messages += fn.arguments or ""
329 combined_messages += "}]"
330 return combined_messages
331
332
333 @pytest.mark.parametrize("output", VALID_TOOLS)
334 @pytest.mark.parametrize("empty_params", [False, True])
335 @pytest.mark.parametrize("delta_len", [1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
336 def test_streaming_output_valid(output, empty_params, delta_len):
337 output = deepcopy(output)
338 if empty_params:
339 output = [{"name": o["name"], "parameters": {}} for o in output]
340 output_json = json.dumps(output)
341
342 combined_messages = _collect_required_tool_streaming_json(output_json, delta_len)
343 assert json.loads(combined_messages) == output
344 assert json.dumps(json.loads(combined_messages)) == output_json
345
346
347 @pytest.mark.parametrize(
348 "city",
349 [
350 "a { b",
351 "a } b",
352 "a }} b",
353 'a " } b',
354 r"a \ } b",
355 ],
356 )
357 @pytest.mark.parametrize("delta_len", [1, 2, 3, 8, 9999])
358 def test_streaming_output_valid_with_braces_in_string(city, delta_len):
359 output = [{"name": "get_current_weather", "parameters": {"city": city}}]
360 output_json = json.dumps(output)
361 combined_messages = _collect_required_tool_streaming_json(output_json, delta_len)
362 assert json.loads(combined_messages) == output
363 assert json.dumps(json.loads(combined_messages)) == output_json
364
365
366 def test_streaming_output_valid_with_trailing_extra_data():
367 output = [{"name": "get_current_weather", "parameters": {"city": "Vienna"}}]
368 output_json = json.dumps(output) + "\nDONE"
369 combined_messages = _collect_required_tool_streaming_json(output_json, delta_len=3)
370 assert json.loads(combined_messages) == output
371
372
373 FUNCTION_TOOL = FunctionTool(
374 type="function",
375 name="get_weather",
376 parameters={
377 "type": "object",
378 "properties": {"city": {"type": "string"}},
379 "required": ["city"],
380 },
381 )
382 WEB_SEARCH_TOOL = WebSearchTool(type="web_search")
383
384
385 class TestNonFunctionToolsSkipped:
386 """Non-function tools (web_search, etc.) must be silently skipped
387 by the tool-schema utilities instead of raising TypeError."""
388
389 def test_find_tool_properties_skips_web_search(self):
390 tools = [WEB_SEARCH_TOOL, FUNCTION_TOOL]
391 props = find_tool_properties(tools, "get_weather")
392 assert props == {"city": {"type": "string"}}
393
394 def test_find_tool_properties_only_non_function_tools(self):
395 props = find_tool_properties([WEB_SEARCH_TOOL], "get_weather")
396 assert props == {}
397
398 def test_get_json_schema_with_mixed_tools(self):
399 tools = [WEB_SEARCH_TOOL, FUNCTION_TOOL]
400 schema = get_json_schema_from_tools(tools=tools, tool_choice="required")
401 assert isinstance(schema, dict)
402 any_of = schema["items"]["anyOf"]
403 assert len(any_of) == 1
404 assert any_of[0]["properties"]["name"]["enum"] == ["get_weather"]
405
406
407 class TestParallelToolCallsConstraint:
408 """`parallel_tool_calls=false` must be enforced by the decoding grammar.
409
410 Without a `maxItems` bound the model is free to emit an unbounded run of
411 tool calls that are only discarded afterwards, wasting the token budget and
412 risking truncation of the one call the client actually receives."""
413
414 TOOLS = TypeAdapter(list[ChatCompletionToolsParam]).validate_python(EXAMPLE_TOOLS)
415 ONE_CALL = [{"name": "get_current_weather", "parameters": {"city": "Vienna"}}]
416 TWO_CALLS = [
417 {"name": "get_current_weather", "parameters": {"city": "Vienna"}},
418 {"name": "get_current_weather", "parameters": {"city": "Berlin"}},
419 ]
420
421 def test_disabled_rejects_a_second_tool_call(self):
422 _compile_and_check(self.TOOLS, self.ONE_CALL, True, parallel_tool_calls=False)
423 _compile_and_check(self.TOOLS, self.TWO_CALLS, False, parallel_tool_calls=False)
424
425 @pytest.mark.parametrize("parallel_tool_calls", [True, None])
426 def test_enabled_or_unset_still_allows_multiple(self, parallel_tool_calls):
427 _compile_and_check(
428 self.TOOLS,
429 self.TWO_CALLS,
430 True,
431 parallel_tool_calls=parallel_tool_calls,
432 )
433
434 @pytest.mark.parametrize(
435 "parallel_tool_calls,expected",
436 [(False, 1), (True, None), (None, None)],
437 )
438 def test_max_items_bound(self, parallel_tool_calls, expected):
439 schema = get_json_schema_from_tools(
440 tools=self.TOOLS,
441 tool_choice="required",
442 parallel_tool_calls=parallel_tool_calls,
443 )
444 assert isinstance(schema, dict)
445 assert schema["minItems"] == 1
446 assert schema.get("maxItems") == expected
447
448 def test_forced_named_tool_is_unaffected(self):
449 # Named tool choice yields a bare parameters object, never an array.
450 schema = get_json_schema_from_tools(
451 tools=self.TOOLS,
452 tool_choice=ChatCompletionNamedToolChoiceParam(
453 function=ChatCompletionNamedFunction(name="get_current_weather")
454 ),
455 parallel_tool_calls=False,
456 )
457 assert isinstance(schema, dict)
458 assert "maxItems" not in schema
459
460
461 class TestForcedNamedToolChoiceEmptyParams:
462 """A forced named tool_choice with missing/empty parameters must still
463 constrain the generated arguments to a JSON object, like the
464 `tool_choice="required"` path, instead of leaving them unconstrained."""
465
466 @pytest.mark.parametrize("params", [None, {}])
467 def test_chat_empty_params_constrains_object(self, params):
468 tool = ChatCompletionToolsParam.model_validate(
469 {"type": "function", "function": {"name": "ping", "parameters": params}}
470 )
471 choice = ChatCompletionNamedToolChoiceParam.model_validate(
472 {"type": "function", "function": {"name": "ping"}}
473 )
474 schema = get_json_schema_from_tools(choice, [tool])
475 assert schema == {"type": "object", "properties": {}}
476
477 @pytest.mark.parametrize("params", [None, {}])
478 def test_responses_empty_params_constrains_object(self, params):
479 tool = FunctionTool(type="function", name="ping", parameters=params)
480 choice = ToolChoiceFunction(type="function", name="ping")
481 schema = get_json_schema_from_tools(choice, [tool])
482 assert schema == {"type": "object", "properties": {}}