main
py 787 lines 23.9 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 # ruff: noqa: E501
4
5 import json
6 import random
7 from unittest.mock import MagicMock
8
9 import pytest
10
11 from tests.tool_parsers.utils import run_tool_extraction_streaming
12 from vllm.entrypoints.generate.base.protocol import FunctionCall, ToolCall
13 from vllm.entrypoints.openai.chat_completion.protocol import (
14 ChatCompletionRequest,
15 ChatCompletionToolsParam,
16 )
17 from vllm.tool_parsers import ToolParser, ToolParserManager
18 from vllm.tool_parsers.minicpm5xml_tool_parser import MiniCPM5XMLToolParser
19
20
21 def _tool(name: str, parameters: dict) -> ChatCompletionToolsParam:
22 return ChatCompletionToolsParam(
23 type="function",
24 function={
25 "name": name,
26 "parameters": parameters,
27 },
28 )
29
30
31 def make_tools_weather() -> list[ChatCompletionToolsParam]:
32 return [
33 _tool(
34 "get_weather",
35 {
36 "type": "object",
37 "properties": {
38 "city": {"type": "string"},
39 "date": {"type": "string"},
40 },
41 "required": ["city"],
42 },
43 )
44 ]
45
46
47 def make_tools_sum() -> list[ChatCompletionToolsParam]:
48 return [
49 _tool(
50 "sum_values",
51 {
52 "type": "object",
53 "properties": {
54 "nums": {"type": "array"},
55 "exact": {"type": "boolean"},
56 },
57 "required": ["nums"],
58 },
59 )
60 ]
61
62
63 def make_tools_no_required() -> list[ChatCompletionToolsParam]:
64 return [
65 _tool(
66 "noop",
67 {
68 "type": "object",
69 "properties": {"note": {"type": "string"}},
70 "required": [],
71 },
72 )
73 ]
74
75
76 def make_request(
77 tools: list[ChatCompletionToolsParam],
78 tool_choice: str = "auto",
79 ) -> ChatCompletionRequest:
80 return ChatCompletionRequest(
81 model="test-model",
82 messages=[{"role": "user", "content": "hi"}],
83 tools=tools,
84 tool_choice=tool_choice,
85 )
86
87
88 def make_tool_call(name: str, arguments: dict) -> ToolCall:
89 return ToolCall(
90 type="function",
91 function=FunctionCall(
92 name=name,
93 arguments=json.dumps(arguments, ensure_ascii=False),
94 ),
95 )
96
97
98 def assert_tool_calls(
99 actual: list[ToolCall],
100 expected: list[ToolCall],
101 ) -> None:
102 assert len(actual) == len(expected)
103 for act, exp in zip(actual, expected):
104 assert act.type == "function"
105 assert act.function.name == exp.function.name
106 assert json.loads(act.function.arguments) == json.loads(exp.function.arguments)
107
108
109 @pytest.fixture
110 def parser() -> ToolParser:
111 mock_tokenizer = MagicMock()
112 return MiniCPM5XMLToolParser(mock_tokenizer)
113
114
115 def test_registered_in_tool_parser_manager() -> None:
116 cls = ToolParserManager.get_tool_parser("minicpm5")
117 assert cls is MiniCPM5XMLToolParser
118
119
120 def test_adjust_request_skip_special_tokens(parser: ToolParser) -> None:
121 request = make_request(make_tools_weather())
122 assert request.skip_special_tokens is True
123 adjusted = parser.adjust_request(request)
124 assert adjusted.skip_special_tokens is False
125
126
127 def test_adjust_request_tool_choice_none(parser: ToolParser) -> None:
128 request = make_request(make_tools_weather(), tool_choice="none")
129 adjusted = parser.adjust_request(request)
130 assert adjusted.skip_special_tokens is True
131
132
133 def test_no_tool_call(parser: ToolParser) -> None:
134 request = make_request(make_tools_weather())
135 out = parser.extract_tool_calls("How can I help you?", request)
136 assert not out.tools_called
137 assert out.tool_calls == []
138 assert out.content == "How can I help you?"
139
140
141 def test_single_call_with_surrounding_text(parser: ToolParser) -> None:
142 request = make_request(make_tools_weather())
143 text = (
144 "Intro before.\n"
145 '<function name="get_weather">'
146 '<param name="city">上海</param>'
147 '<param name="date">2024-06-27</param>'
148 "</function>\n"
149 "Outro after.\n"
150 )
151 out = parser.extract_tool_calls(text, request)
152 assert out.tools_called
153 assert_tool_calls(
154 out.tool_calls,
155 [
156 make_tool_call(
157 "get_weather",
158 {
159 "city": "上海",
160 "date": "2024-06-27",
161 },
162 )
163 ],
164 )
165 assert out.content is None
166
167
168 def test_cdata_multiline(parser: ToolParser) -> None:
169 request = make_request(make_tools_weather())
170 text = (
171 '<function name="get_weather">'
172 '<param name="city"><![CDATA[北\n京]]></param>'
173 '<param name="date">2024-06-27</param>'
174 "</function>\n"
175 )
176 out = parser.extract_tool_calls(text, request)
177 assert len(out.tool_calls) == 1
178 args = json.loads(out.tool_calls[0].function.arguments)
179 assert args["city"] == "北\n京"
180 assert args["date"] == "2024-06-27"
181
182
183 def test_tokenizer_space_marker(parser: ToolParser) -> None:
184 request = make_request(make_tools_weather())
185 text = (
186 '<function\u0120name="get_weather">'
187 '<param\u0120name="city">上海</param>'
188 '<param\u0120name="date">2024-06-27</param>'
189 "</function>\n"
190 )
191 out = parser.extract_tool_calls(text, request)
192 assert out.tools_called
193 assert_tool_calls(
194 out.tool_calls,
195 [
196 make_tool_call(
197 "get_weather",
198 {
199 "city": "上海",
200 "date": "2024-06-27",
201 },
202 )
203 ],
204 )
205
206
207 def test_collapsed_function_and_param_tags(parser: ToolParser) -> None:
208 request = make_request(make_tools_weather())
209 text = (
210 '<functionname="get_weather">'
211 '<paramname="city">上海</param>'
212 '<paramname="date">2024-06-27</param>'
213 "</function>\n"
214 )
215 out = parser.extract_tool_calls(text, request)
216 assert out.tools_called
217 assert_tool_calls(
218 out.tool_calls,
219 [
220 make_tool_call(
221 "get_weather",
222 {
223 "city": "上海",
224 "date": "2024-06-27",
225 },
226 )
227 ],
228 )
229
230
231 def test_collapsed_param_tags_with_tokenizer_space_function(
232 parser: ToolParser,
233 ) -> None:
234 request = make_request(make_tools_weather())
235 text = (
236 '<function\u0120name="get_weather">'
237 '<paramname="city">上海</param>'
238 '<paramname="date">2024-06-27</param>'
239 "</function>\n"
240 )
241 out = parser.extract_tool_calls(text, request)
242 assert out.tools_called
243 assert_tool_calls(
244 out.tool_calls,
245 [
246 make_tool_call(
247 "get_weather",
248 {
249 "city": "上海",
250 "date": "2024-06-27",
251 },
252 )
253 ],
254 )
255
256
257 def test_extract_tool_calls_streaming_partial_chunks(parser: ToolParser) -> None:
258 request = make_request(make_tools_weather())
259 chunks = [
260 '<function name="get_weather">',
261 '<param name="city">',
262 '上海</param><param name="date">2024-06-27</param></function>\n',
263 ]
264 reconstructor = run_tool_extraction_streaming(
265 parser,
266 chunks,
267 request,
268 assert_one_tool_per_delta=False,
269 )
270 assert len(reconstructor.tool_calls) == 1
271 assert reconstructor.tool_calls[0].function.name == "get_weather"
272 assert json.loads(reconstructor.tool_calls[0].function.arguments) == {
273 "city": "上海",
274 "date": "2024-06-27",
275 }
276
277
278 def test_extract_tool_calls_streaming_tokenizer_space_marker(
279 parser: ToolParser,
280 ) -> None:
281 request = make_request(make_tools_weather())
282 chunks = [
283 '<function\u0120name="get_weather">',
284 '<param\u0120name="city">',
285 '上海</param><param\u0120name="date">2024-06-27</param></function>\n',
286 ]
287 reconstructor = run_tool_extraction_streaming(
288 parser,
289 chunks,
290 request,
291 assert_one_tool_per_delta=False,
292 )
293 assert len(reconstructor.tool_calls) == 1
294 assert json.loads(reconstructor.tool_calls[0].function.arguments) == {
295 "city": "上海",
296 "date": "2024-06-27",
297 }
298
299
300 def test_extract_tool_calls_streaming_collapsed_tags_weather(
301 parser: ToolParser,
302 ) -> None:
303 request = make_request(make_tools_weather())
304 text = (
305 '<functionname="get_weather">'
306 '<paramname="city">上海</param>'
307 '<paramname="date">2024-06-27</param>'
308 "</function>\n"
309 )
310 random.seed(2)
311 reconstructor = run_tool_extraction_streaming(
312 parser,
313 _random_chunks(text, 1, 4),
314 request,
315 )
316 assert len(reconstructor.tool_calls) == 1
317 assert reconstructor.tool_calls[0].function.name == "get_weather"
318 assert json.loads(reconstructor.tool_calls[0].function.arguments) == {
319 "city": "上海",
320 "date": "2024-06-27",
321 }
322
323
324 def test_collapsed_tags_current_weather(parser: ToolParser) -> None:
325 request = make_request(
326 [
327 _tool(
328 "get_current_weather",
329 {
330 "type": "object",
331 "properties": {
332 "city": {"type": "string"},
333 "state": {"type": "string"},
334 "unit": {"type": "string"},
335 },
336 "required": ["city", "state", "unit"],
337 },
338 )
339 ]
340 )
341 text = (
342 '<functionname="get_current_weather">'
343 '<paramname="city">Dallas</param>'
344 '<paramname="state">TX</param>'
345 '<paramname="unit">fahrenheit</param>'
346 "</function>"
347 )
348 out = parser.extract_tool_calls(text, request)
349 assert out.tools_called
350 assert json.loads(out.tool_calls[0].function.arguments) == {
351 "city": "Dallas",
352 "state": "TX",
353 "unit": "fahrenheit",
354 }
355
356
357 def test_extract_tool_calls_streaming_incremental_arguments(
358 parser: ToolParser,
359 ) -> None:
360 request = make_request(
361 [
362 _tool(
363 "get_current_weather",
364 {
365 "type": "object",
366 "properties": {
367 "city": {"type": "string"},
368 "state": {"type": "string"},
369 "unit": {"type": "string"},
370 },
371 "required": ["city", "state", "unit"],
372 },
373 )
374 ]
375 )
376 text = (
377 '<function name="get_current_weather">'
378 '<param name="city">Dallas</param>'
379 '<param name="state">TX</param>'
380 '<param name="unit">fahrenheit</param>'
381 "</function>"
382 )
383 prev = ""
384 arguments = ""
385 for chunk in [text[:37], text[37:70], text[70:]]:
386 current = prev + chunk
387 delta = parser.extract_tool_calls_streaming(
388 prev,
389 current,
390 chunk,
391 [],
392 [],
393 [],
394 request,
395 )
396 if delta and delta.tool_calls:
397 arg_delta = delta.tool_calls[0].function.arguments
398 if arg_delta:
399 arguments += arg_delta
400 prev = current
401
402 assert json.loads(arguments) == {
403 "city": "Dallas",
404 "state": "TX",
405 "unit": "fahrenheit",
406 }
407
408
409 def test_unknown_tool_block_preserved(parser: ToolParser) -> None:
410 request = make_request(make_tools_weather())
411 text = '<function name="unknown"><param name="x">1</param></function>\n'
412 out = parser.extract_tool_calls(text, request)
413 assert not out.tools_called
414 assert "unknown" in (out.content or "")
415
416
417 def test_non_string_types(parser: ToolParser) -> None:
418 request = make_request(make_tools_sum())
419 text = (
420 '<function name="sum_values">'
421 '<param name="nums">[1, 2, 3]</param>'
422 '<param name="exact">true</param>'
423 "</function>\n"
424 )
425 out = parser.extract_tool_calls(text, request)
426 assert len(out.tool_calls) == 1
427 args = json.loads(out.tool_calls[0].function.arguments)
428 assert args["nums"] == [1, 2, 3]
429 assert args["exact"] is True
430
431
432 def test_multiple_calls_interleaved_text(parser: ToolParser) -> None:
433 tools = make_tools_weather() + make_tools_sum()
434 request = make_request(tools)
435 text = (
436 "Head\n"
437 '<function name="get_weather"><param name="city">北京</param></function>\n'
438 "TXT\n"
439 '<function name="sum_values"><param name="nums">[7,8,9]</param>'
440 '<param name="exact">false</param></function>\n'
441 "Tail\n"
442 )
443 out = parser.extract_tool_calls(text, request)
444 assert len(out.tool_calls) == 2
445 args0 = json.loads(out.tool_calls[0].function.arguments)
446 assert args0["city"] == "北京"
447 args1 = json.loads(out.tool_calls[1].function.arguments)
448 assert args1["nums"] == [7, 8, 9]
449 assert args1["exact"] is False
450 assert out.content is None
451
452
453 def test_incomplete_missing_function_end(parser: ToolParser) -> None:
454 request = make_request(make_tools_weather())
455 text = '<function name="get_weather"><param name="city">北京</param>'
456 out = parser.extract_tool_calls(text, request)
457 assert not out.tools_called
458 assert "get_weather" in (out.content or "")
459
460
461 def test_param_missing_name_invalid(parser: ToolParser) -> None:
462 request = make_request(make_tools_weather())
463 text = (
464 '<function name="get_weather">'
465 "<param>北京</param>"
466 '<param name="date">2024-06-27</param>'
467 "</function>\n"
468 )
469 out = parser.extract_tool_calls(text, request)
470 assert not out.tools_called
471 assert "<param>北京</param>" in (out.content or "")
472
473
474 def test_duplicate_param_names_invalid(parser: ToolParser) -> None:
475 request = make_request(make_tools_weather())
476 text = (
477 '<function name="get_weather">'
478 '<param name="city">北京</param>'
479 '<param name="city">上海</param>'
480 "</function>\n"
481 )
482 out = parser.extract_tool_calls(text, request)
483 assert not out.tools_called
484
485
486 def test_case_sensitive_param_name_invalid(parser: ToolParser) -> None:
487 request = make_request(make_tools_weather())
488 text = '<function name="get_weather"><param name="City">北京</param></function>\n'
489 out = parser.extract_tool_calls(text, request)
490 assert not out.tools_called
491
492
493 def test_no_required_and_zero_param_valid(parser: ToolParser) -> None:
494 request = make_request(make_tools_no_required())
495 text = '<function name="noop"></function>\n'
496 out = parser.extract_tool_calls(text, request)
497 assert len(out.tool_calls) == 1
498 args = json.loads(out.tool_calls[0].function.arguments)
499 assert args == {}
500
501
502 def test_thinking_only_sentencepiece_normalized_in_content(
503 parser: ToolParser,
504 ) -> None:
505 request = make_request(make_tools_weather())
506 text = "\u010aFirst,\u0120I\u0120need\u0120to\u0120check\u0120the\u0120weather."
507 out = parser.extract_tool_calls(text, request)
508 assert not out.tools_called
509 assert out.content is not None
510 assert "\u0120" not in out.content
511 assert "\u010a" not in out.content
512 assert "First, I need to check the weather." in out.content
513
514
515 def test_properties_wrapped_arguments(parser: ToolParser) -> None:
516 tools = [
517 _tool(
518 "get_customer_by_phone",
519 {
520 "type": "object",
521 "properties": {"phone_number": {"type": "string"}},
522 "required": ["phone_number"],
523 },
524 )
525 ]
526 request = make_request(tools)
527 text = (
528 '<function name="get_customer_by_phone">'
529 "<param name=\"properties\">{'phone_number': '555-123-2002'}</param>"
530 "</function>"
531 )
532 out = parser.extract_tool_calls(text, request)
533 assert len(out.tool_calls) == 1
534 assert out.tool_calls[0].function.name == "get_customer_by_phone"
535 args = json.loads(out.tool_calls[0].function.arguments)
536 assert args == {"phone_number": "555-123-2002"}
537
538
539 def test_arguments_wrapped_arguments(parser: ToolParser) -> None:
540 request = make_request(make_tools_weather())
541 text = (
542 '<function name="get_weather">'
543 '<param name="arguments">{"city": "上海", "date": "2024-06-27"}</param>'
544 "</function>"
545 )
546 out = parser.extract_tool_calls(text, request)
547 assert len(out.tool_calls) == 1
548 args = json.loads(out.tool_calls[0].function.arguments)
549 assert args == {"city": "上海", "date": "2024-06-27"}
550
551
552 def test_wrapped_arguments_still_validate_schema(parser: ToolParser) -> None:
553 request = make_request(make_tools_weather())
554 text = (
555 '<function name="get_weather">'
556 '<param name="properties">{"unknown": "x"}</param>'
557 "</function>"
558 )
559 out = parser.extract_tool_calls(text, request)
560 assert not out.tools_called
561
562
563 def test_extra_arguments_ignored_when_required_present(parser: ToolParser) -> None:
564 request = make_request(make_tools_weather())
565 text = (
566 '<function name="get_weather">'
567 '<param name="city">上海</param>'
568 '<param name="unknown">ignored</param>'
569 "</function>"
570 )
571 out = parser.extract_tool_calls(text, request)
572 assert len(out.tool_calls) == 1
573 args = json.loads(out.tool_calls[0].function.arguments)
574 assert args == {"city": "上海"}
575
576
577 def test_extra_arguments_do_not_satisfy_required(parser: ToolParser) -> None:
578 request = make_request(make_tools_weather())
579 text = (
580 '<function name="get_weather"><param name="unknown">ignored</param></function>'
581 )
582 out = parser.extract_tool_calls(text, request)
583 assert not out.tools_called
584
585
586 def test_zero_arg_tool_ignores_extra_arguments(parser: ToolParser) -> None:
587 request = make_request(make_tools_no_required())
588 text = (
589 '<function name="noop">'
590 '<param name="note">ignored</param>'
591 '<param name="extra">ignored</param>'
592 "</function>"
593 )
594 out = parser.extract_tool_calls(text, request)
595 assert len(out.tool_calls) == 1
596 args = json.loads(out.tool_calls[0].function.arguments)
597 assert args == {"note": "ignored"}
598
599
600 def test_alias_get_details_by_phone(parser: ToolParser) -> None:
601 tools = [
602 _tool(
603 "get_customer_by_phone",
604 {
605 "type": "object",
606 "properties": {"phone_number": {"type": "string"}},
607 "required": ["phone_number"],
608 },
609 )
610 ]
611 request = make_request(tools)
612 text = (
613 '<function name="get_details_by_phone">'
614 '<param name="phone_number">555-123-2002</param>'
615 "</function>"
616 )
617 out = parser.extract_tool_calls(text, request)
618 assert len(out.tool_calls) == 1
619 assert out.tool_calls[0].function.name == "get_customer_by_phone"
620 args = json.loads(out.tool_calls[0].function.arguments)
621 assert args == {"phone_number": "555-123-2002"}
622
623
624 def test_alias_get_line_details(parser: ToolParser) -> None:
625 tools = [
626 _tool(
627 "get_details_by_id",
628 {
629 "type": "object",
630 "properties": {"id": {"type": "string"}},
631 "required": ["id"],
632 },
633 )
634 ]
635 request = make_request(tools)
636 text = (
637 '<function name="get_line_details">'
638 '<param name="customer_id">C1001</param>'
639 '<param name="line_id">L1001</param>'
640 "</function>"
641 )
642 out = parser.extract_tool_calls(text, request)
643 assert len(out.tool_calls) == 1
644 assert out.tool_calls[0].function.name == "get_details_by_id"
645 args = json.loads(out.tool_calls[0].function.arguments)
646 assert args == {"id": "L1001"}
647
648
649 def test_alias_enable_roaming(parser: ToolParser) -> None:
650 tools = [
651 _tool(
652 "toggle_roaming",
653 {
654 "type": "object",
655 "properties": {
656 "line_id": {"type": "string"},
657 "enabled": {"type": "boolean"},
658 },
659 "required": ["line_id", "enabled"],
660 },
661 )
662 ]
663 request = make_request(tools)
664 text = (
665 '<function name="enable_roaming"><param name="line_id">L1001</param></function>'
666 )
667 out = parser.extract_tool_calls(text, request)
668 assert len(out.tool_calls) == 1
669 assert out.tool_calls[0].function.name == "toggle_roaming"
670 args = json.loads(out.tool_calls[0].function.arguments)
671 assert args == {"line_id": "L1001", "enabled": True}
672
673
674 def _random_chunks(text: str, min_len: int, max_len: int) -> list[str]:
675 chunks: list[str] = []
676 index = 0
677 while index < len(text):
678 size = random.randint(min_len, max_len)
679 chunks.append(text[index : index + size])
680 index += size
681 return chunks
682
683
684 def test_extract_tool_calls_streaming_single(parser: ToolParser) -> None:
685 request = make_request(make_tools_weather())
686 text = (
687 "Intro before.\n"
688 '<function name="get_weather">'
689 '<param name="city">上海</param>'
690 '<param name="date">2024-06-27</param>'
691 "</function>\n"
692 "Outro after.\n"
693 )
694 random.seed(0)
695 reconstructor = run_tool_extraction_streaming(
696 parser,
697 _random_chunks(text, 1, 4),
698 request,
699 )
700 assert "Intro before." in reconstructor.other_content
701 assert "Outro after." in reconstructor.other_content
702 assert len(reconstructor.tool_calls) == 1
703 assert reconstructor.tool_calls[0].function.name == "get_weather"
704 assert json.loads(reconstructor.tool_calls[0].function.arguments) == {
705 "city": "上海",
706 "date": "2024-06-27",
707 }
708
709
710 def test_extract_tool_calls_streaming_multiple(parser: ToolParser) -> None:
711 tools = make_tools_weather() + make_tools_sum()
712 request = make_request(tools)
713 text = (
714 "Head\n"
715 '<function name="get_weather"><param name="city">北京</param></function>\n'
716 "TXT\n"
717 '<function name="sum_values"><param name="nums">[7,8,9]</param>'
718 '<param name="exact">false</param></function>\n'
719 "Tail\n"
720 )
721 random.seed(1)
722 reconstructor = run_tool_extraction_streaming(
723 parser,
724 _random_chunks(text, 1, 5),
725 request,
726 )
727 assert "Head" in reconstructor.other_content
728 assert "TXT" in reconstructor.other_content
729 assert "Tail" in reconstructor.other_content
730 assert len(reconstructor.tool_calls) == 2
731 assert json.loads(reconstructor.tool_calls[0].function.arguments)["city"] == "北京"
732 assert json.loads(reconstructor.tool_calls[1].function.arguments) == {
733 "nums": [7, 8, 9],
734 "exact": False,
735 }
736
737
738 def make_tools_write() -> list[ChatCompletionToolsParam]:
739 return [
740 _tool(
741 "write_file",
742 {
743 "type": "object",
744 "properties": {"content": {"type": "string"}},
745 "required": ["content"],
746 },
747 )
748 ]
749
750
751 class TestParameterWhitespace:
752 """CDATA is verbatim, so its whitespace must survive."""
753
754 def test_cdata_whitespace_preserved(self, parser: ToolParser) -> None:
755 request = make_request(make_tools_write())
756 text = (
757 '<function name="write_file">'
758 '<param name="content"><![CDATA[ def foo():\n pass\n]]></param>'
759 "</function>\n"
760 )
761
762 out = parser.extract_tool_calls(text, request)
763
764 assert len(out.tool_calls) == 1
765 args = json.loads(out.tool_calls[0].function.arguments)
766 assert args["content"] == " def foo():\n pass\n"
767
768 def test_cdata_whitespace_preserved_streaming(self, parser: ToolParser) -> None:
769 """Value split across chunks, i.e. the partial path."""
770 request = make_request(make_tools_write())
771 chunks = [
772 '<function name="write_file">',
773 '<param name="content"><![CDATA[ def foo():\n',
774 " pass\n]]></param></function>\n",
775 ]
776
777 reconstructor = run_tool_extraction_streaming(
778 parser,
779 chunks,
780 request,
781 assert_one_tool_per_delta=False,
782 )
783
784 assert len(reconstructor.tool_calls) == 1
785 assert json.loads(reconstructor.tool_calls[0].function.arguments) == {
786 "content": " def foo():\n pass\n"
787 }