main
py 1,201 lines 42.3 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Tests for the engine-based Qwen3 tool call parser.
4
5 These validate that the engine-driven parser correctly handles
6 Qwen3 XML-style tool calls.
7 """
8
9 import json
10 from unittest.mock import MagicMock
11
12 import pytest
13
14 from tests.parser.engine.conftest import make_mock_tokenizer
15 from tests.parser.engine.streaming_helpers import (
16 collect_content,
17 collect_function_name,
18 collect_tool_arguments,
19 simulate_tool_streaming,
20 )
21 from vllm.parser.engine.parser_engine import ParserEngine
22 from vllm.parser.qwen3 import (
23 TOOL_CALL_END,
24 TOOL_CALL_START,
25 qwen3_config,
26 )
27
28
29 @pytest.fixture
30 def mock_tokenizer():
31 return make_mock_tokenizer(
32 {
33 TOOL_CALL_START: 100,
34 TOOL_CALL_END: 101,
35 }
36 )
37
38
39 @pytest.fixture
40 def parser(mock_tokenizer):
41 return ParserEngine(
42 mock_tokenizer,
43 parser_engine_config=qwen3_config(thinking=False),
44 )
45
46
47 class TestNonStreaming:
48 def test_no_tool_calls(self, parser, mock_request):
49 result = parser.extract_tool_calls(
50 "This is a regular response without any tool calls.",
51 mock_request,
52 )
53 assert result.tools_called is False
54 assert result.tool_calls == []
55 assert result.content == ("This is a regular response without any tool calls.")
56
57 def test_single_tool_call(self, parser, mock_request):
58 text = (
59 "<tool_call>\n"
60 "<function=get_weather>\n"
61 "<parameter=city>Tokyo</parameter>\n"
62 "</function>\n"
63 "</tool_call>"
64 )
65 result = parser.extract_tool_calls(text, mock_request)
66
67 assert result.tools_called is True
68 assert len(result.tool_calls) == 1
69 assert result.tool_calls[0].function.name == "get_weather"
70 args = json.loads(result.tool_calls[0].function.arguments)
71 assert args == {"city": "Tokyo"}
72
73 def test_parallel_tool_calls(self, parser, mock_request):
74 text = (
75 "<tool_call>\n"
76 "<function=get_weather>\n"
77 "<parameter=city>Tokyo</parameter>\n"
78 "</function>\n"
79 "</tool_call>"
80 "<tool_call>\n"
81 "<function=get_time>\n"
82 "<parameter=timezone>Asia/Tokyo</parameter>\n"
83 "</function>\n"
84 "</tool_call>"
85 )
86 result = parser.extract_tool_calls(text, mock_request)
87
88 assert result.tools_called is True
89 assert len(result.tool_calls) == 2
90 assert result.tool_calls[0].function.name == "get_weather"
91 assert result.tool_calls[1].function.name == "get_time"
92
93 args0 = json.loads(result.tool_calls[0].function.arguments)
94 assert args0 == {"city": "Tokyo"}
95 args1 = json.loads(result.tool_calls[1].function.arguments)
96 assert args1 == {"timezone": "Asia/Tokyo"}
97
98 def test_various_data_types(self, parser, mock_request):
99 text = (
100 "<tool_call>\n<function=test_function>\n"
101 "<parameter=string_field>hello</parameter>\n"
102 "<parameter=int_field>42</parameter>\n"
103 "<parameter=float_field>3.14</parameter>\n"
104 "<parameter=bool_field>true</parameter>\n"
105 "<parameter=null_field>null</parameter>\n"
106 '<parameter=array_field>["a", "b", "c"]</parameter>\n'
107 '<parameter=object_field>{"nested": "value"}</parameter>\n'
108 "</function>\n</tool_call>"
109 )
110 result = parser.extract_tool_calls(text, mock_request)
111
112 assert result.tools_called is True
113 args = json.loads(result.tool_calls[0].function.arguments)
114 assert args["string_field"] == "hello"
115 assert args["int_field"] == "42"
116 assert args["float_field"] == "3.14"
117 assert args["bool_field"] == "true"
118 assert args["null_field"] == "null"
119 assert args["array_field"] == '["a", "b", "c"]'
120 assert args["object_field"] == '{"nested": "value"}'
121
122 def test_empty_arguments(self, parser, mock_request):
123 text = "<tool_call>\n<function=refresh>\n</function>\n</tool_call>"
124 result = parser.extract_tool_calls(text, mock_request)
125
126 assert result.tools_called is True
127 assert result.tool_calls[0].function.name == "refresh"
128 args = json.loads(result.tool_calls[0].function.arguments)
129 assert args == {}
130
131 def test_surrounding_text(self, parser, mock_request):
132 text = (
133 "Let me check the weather for you.\n\n"
134 "<tool_call>\n<function=get_weather>\n"
135 "<parameter=city>Tokyo</parameter>\n"
136 "</function>\n</tool_call>\n\n"
137 "I will get that information."
138 )
139 result = parser.extract_tool_calls(text, mock_request)
140
141 assert result.tools_called is True
142 assert result.content is not None
143 assert "Let me check the weather" in result.content
144 assert result.tool_calls[0].function.name == "get_weather"
145
146 def test_escaped_strings(self, parser, mock_request):
147 text = (
148 "<tool_call>\n<function=test_function>\n"
149 '<parameter=quoted>He said "hello"</parameter>\n'
150 "<parameter=path>C:\\Users\\file.txt</parameter>\n"
151 "<parameter=newline>line1\nline2</parameter>\n"
152 "</function>\n</tool_call>"
153 )
154 result = parser.extract_tool_calls(text, mock_request)
155
156 assert result.tools_called is True
157 args = json.loads(result.tool_calls[0].function.arguments)
158 assert args["quoted"] == 'He said "hello"'
159 assert args["path"] == "C:\\Users\\file.txt"
160 assert args["newline"] == "line1\nline2"
161
162 def test_multiple_parameters(self, parser, mock_request):
163 text = (
164 "<tool_call>\n<function=search>\n"
165 "<parameter=query>vllm parsing</parameter>\n"
166 "<parameter=limit>10</parameter>\n"
167 "<parameter=exact_match>false</parameter>\n"
168 "</function>\n</tool_call>"
169 )
170 result = parser.extract_tool_calls(text, mock_request)
171
172 assert result.tools_called is True
173 args = json.loads(result.tool_calls[0].function.arguments)
174 assert args == {
175 "query": "vllm parsing",
176 "limit": "10",
177 "exact_match": "false",
178 }
179
180 def test_multiline_param_values(self, parser, mock_request):
181 """Parameter values spanning multiple lines."""
182 text = (
183 "<tool_call>\n"
184 "<function=Bash>\n"
185 "<parameter=command>\n"
186 "ls -la /tmp\n"
187 "</parameter>\n"
188 "<parameter=description>\n"
189 "List files in /tmp directory\n"
190 "</parameter>\n"
191 "</function>\n"
192 "</tool_call>"
193 )
194 result = parser.extract_tool_calls(text, mock_request)
195
196 assert result.tools_called is True
197 assert len(result.tool_calls) == 1
198 assert result.tool_calls[0].function.name == "Bash"
199 args = json.loads(result.tool_calls[0].function.arguments)
200 assert args["command"] == "ls -la /tmp"
201 assert args["description"] == "List files in /tmp directory"
202
203 def test_multiline_two_tool_calls(self, parser, mock_request):
204 """Two tool calls with multi-line parameter values (bug report)."""
205 text = (
206 "<tool_call>\n"
207 "<function=Bash>\n"
208 "<parameter=command>\n"
209 "find /workspace -name '*.py' | head -20\n"
210 "</parameter>\n"
211 "<parameter=description>\n"
212 "Find Python files\n"
213 "</parameter>\n"
214 "</function>\n"
215 "</tool_call>"
216 "<tool_call>\n"
217 "<function=Read>\n"
218 "<parameter=file_path>\n"
219 "/workspace/main.py\n"
220 "</parameter>\n"
221 "</function>\n"
222 "</tool_call>"
223 )
224 result = parser.extract_tool_calls(text, mock_request)
225
226 assert result.tools_called is True
227 assert len(result.tool_calls) == 2
228 assert result.tool_calls[0].function.name == "Bash"
229 assert result.tool_calls[1].function.name == "Read"
230 args0 = json.loads(result.tool_calls[0].function.arguments)
231 assert "find /workspace" in args0["command"]
232 assert "Find Python files" in args0["description"]
233 args1 = json.loads(result.tool_calls[1].function.arguments)
234 assert "/workspace/main.py" in args1["file_path"]
235
236 def test_consecutive_tool_calls_without_tool_end(self, parser, mock_request):
237 text = (
238 "<tool_call>\n"
239 "<function=get_weather>\n"
240 "<parameter=city>Tokyo</parameter>\n"
241 "</function>\n"
242 "<tool_call>\n"
243 "<function=get_weather>\n"
244 "<parameter=city>Paris</parameter>\n"
245 "</function>\n"
246 "</tool_call>"
247 )
248 result = parser.extract_tool_calls(text, mock_request)
249
250 assert result.tools_called is True
251 assert len(result.tool_calls) == 2
252 args0 = json.loads(result.tool_calls[0].function.arguments)
253 assert args0 == {"city": "Tokyo"}
254 args1 = json.loads(result.tool_calls[1].function.arguments)
255 assert args1 == {"city": "Paris"}
256
257 def test_nested_json_array_parameter(self, parser, mock_request):
258 text = (
259 "<tool_call>\n"
260 "<function=AskUserQuestion>\n"
261 "<parameter=questions>"
262 '[{"question": "Pick a color",'
263 ' "multiSelect": false, "answer": null}]'
264 "</parameter>\n"
265 "</function>\n"
266 "</tool_call>"
267 )
268 result = parser.extract_tool_calls(text, mock_request)
269
270 assert result.tools_called is True
271 args = json.loads(result.tool_calls[0].function.arguments)
272 assert args == {
273 "questions": '[{"question": "Pick a color",'
274 ' "multiSelect": false, "answer": null}]',
275 }
276
277
278 class TestStreaming:
279 def test_basic_streaming(self, parser, mock_request):
280 chunks = [
281 "<tool_call>\n",
282 "<function=get_weather>\n",
283 "<parameter=city>Tokyo",
284 "</parameter>\n",
285 "</function>\n",
286 "</tool_call>",
287 ]
288
289 results = simulate_tool_streaming(parser, mock_request, chunks)
290
291 name = collect_function_name(results)
292 assert name == "get_weather"
293
294 args_text = collect_tool_arguments(results)
295 assert args_text
296 parsed = json.loads(args_text)
297 assert parsed == {"city": "Tokyo"}
298
299 def test_streaming_multi_param(self, parser, mock_request):
300 chunks = [
301 "<tool_call>\n",
302 "<function=get_weather>\n",
303 "<parameter=city>Tokyo</parameter>\n",
304 "<parameter=unit>celsius</parameter>\n",
305 "</function>\n",
306 "</tool_call>",
307 ]
308
309 results = simulate_tool_streaming(parser, mock_request, chunks)
310
311 name = collect_function_name(results)
312 assert name == "get_weather"
313
314 args_text = collect_tool_arguments(results)
315 assert args_text
316 parsed = json.loads(args_text)
317 assert parsed == {"city": "Tokyo", "unit": "celsius"}
318
319 def test_streaming_args_arrive_incrementally(self, parser, mock_request):
320 """Arguments must stream as intermediate deltas, not batch at
321 tool-end."""
322 chunks = [
323 "<tool_call>\n",
324 "<function=get_weather>\n",
325 "<parameter=city>Tokyo</parameter>\n",
326 "<parameter=unit>celsius</parameter>\n",
327 "<parameter=days>5</parameter>\n",
328 "</function>\n",
329 "</tool_call>",
330 ]
331
332 results = simulate_tool_streaming(parser, mock_request, chunks)
333
334 arg_deltas: list[str] = []
335 for delta, _ in results:
336 if delta and delta.tool_calls:
337 for tc in delta.tool_calls:
338 if tc.function and tc.function.arguments:
339 arg_deltas.append(tc.function.arguments)
340
341 assert len(arg_deltas) > 1, (
342 f"Expected arguments across multiple deltas, got {len(arg_deltas)}: "
343 f"{arg_deltas}"
344 )
345 concatenated = "".join(arg_deltas)
346 parsed = json.loads(concatenated)
347 assert parsed == {"city": "Tokyo", "unit": "celsius", "days": "5"}
348
349 def test_streaming_long_string_arg_before_parameter_end(self, parser, mock_request):
350 """Long string arguments should stream before the closing parameter tag."""
351 chunks = [
352 "<tool_call>\n",
353 "<function=write_report>\n",
354 "<parameter=content>",
355 "Artificial intelligence has rapidly transformed the way ",
356 "developers build dynamic applications with external tools.",
357 "</param",
358 "eter>\n",
359 "</function>\n",
360 "</tool_call>",
361 ]
362
363 results = simulate_tool_streaming(parser, mock_request, chunks)
364
365 pre_close_arg_deltas: list[str] = []
366 all_arg_deltas: list[str] = []
367 for idx, (delta, _) in enumerate(results):
368 if delta and delta.tool_calls:
369 for tc in delta.tool_calls:
370 if tc.function and tc.function.arguments:
371 all_arg_deltas.append(tc.function.arguments)
372 if idx < 5:
373 pre_close_arg_deltas.append(tc.function.arguments)
374
375 assert len(pre_close_arg_deltas) > 1, (
376 "Expected long string arguments to stream incrementally before "
377 f"</parameter>, got {pre_close_arg_deltas}"
378 )
379 partial_args = "".join(pre_close_arg_deltas)
380 assert partial_args.startswith('{"content": "Artificial intelligence')
381 assert partial_args.endswith("external tools.")
382 assert not partial_args.endswith('"}')
383
384 all_args = "".join(all_arg_deltas)
385 assert json.loads(all_args) == {
386 "content": (
387 "Artificial intelligence has rapidly transformed the way "
388 "developers build dynamic applications with external tools."
389 )
390 }
391
392 def test_streaming_text_before_tool(self, parser, mock_request):
393 chunks = [
394 "Let me check ",
395 "the weather. ",
396 "<tool_call>\n",
397 "<function=get_weather>\n",
398 "<parameter=city>Tokyo</parameter>\n",
399 "</function>\n",
400 "</tool_call>",
401 ]
402
403 results = simulate_tool_streaming(parser, mock_request, chunks)
404 assert collect_content(results).strip().startswith("Let me check")
405
406 def test_streaming_empty_args(self, parser, mock_request):
407 chunks = [
408 "<tool_call>\n",
409 "<function=refresh>\n",
410 "</function>\n",
411 "</tool_call>",
412 ]
413
414 results = simulate_tool_streaming(parser, mock_request, chunks)
415
416 name = collect_function_name(results)
417 assert name == "refresh"
418
419 def test_streaming_split_parameter_tag(self, parser, mock_request):
420 """Parameter tag split across chunks."""
421 chunks = [
422 "<tool_call>\n",
423 "<function=test>\n",
424 "<parameter=",
425 "name>Alice",
426 "</parameter>\n",
427 "</function>\n",
428 "</tool_call>",
429 ]
430
431 results = simulate_tool_streaming(parser, mock_request, chunks)
432
433 name = collect_function_name(results)
434 assert name == "test"
435
436 args_text = collect_tool_arguments(results)
437 assert args_text
438 parsed = json.loads(args_text)
439 assert parsed["name"] == "Alice"
440
441 def test_streaming_split_next_parameter_tag_is_buffered(self, parser, mock_request):
442 """A split opening parameter tag must not leak into previous value."""
443 chunks = [
444 "<tool_call>\n",
445 "<function=search>\n",
446 "<parameter=query>hello ",
447 "<param",
448 "eter=limit>10</parameter>\n",
449 "</function>\n",
450 "</tool_call>",
451 ]
452
453 results = simulate_tool_streaming(parser, mock_request, chunks)
454
455 args_after_partial_tag = collect_tool_arguments(results[:4])
456 assert "<param" not in args_after_partial_tag
457 assert args_after_partial_tag == '{"query": "hello '
458
459 args_text = collect_tool_arguments(results)
460 assert json.loads(args_text) == {"query": "hello ", "limit": "10"}
461
462 def test_streaming_numeric_values(self, parser, mock_request):
463 chunks = [
464 "<tool_call>\n",
465 "<function=set_config>\n",
466 "<parameter=count>42</parameter>\n",
467 "<parameter=active>true</parameter>\n",
468 "</function>\n",
469 "</tool_call>",
470 ]
471
472 results = simulate_tool_streaming(parser, mock_request, chunks)
473 args_text = collect_tool_arguments(results)
474 if args_text:
475 parsed = json.loads(args_text)
476 assert parsed["count"] == "42"
477 assert parsed["active"] == "true"
478
479 def test_streaming_parallel_calls(self, parser, mock_request):
480 chunks = [
481 "<tool_call>\n",
482 "<function=get_weather>\n",
483 "<parameter=city>Tokyo</parameter>\n",
484 "</function>\n",
485 "</tool_call>",
486 "<tool_call>\n",
487 "<function=get_time>\n",
488 "<parameter=tz>JST</parameter>\n",
489 "</function>\n",
490 "</tool_call>",
491 ]
492
493 results = simulate_tool_streaming(parser, mock_request, chunks)
494
495 names = []
496 for delta, _ in results:
497 if delta and delta.tool_calls:
498 for tc in delta.tool_calls:
499 if tc.function and tc.function.name:
500 names.append(tc.function.name)
501
502 assert "get_weather" in names
503 assert "get_time" in names
504
505 def test_streaming_value_split_across_chunks(self, parser, mock_request):
506 """Parameter value split across multiple chunks."""
507 chunks = [
508 "<tool_call>\n",
509 "<function=search>\n",
510 "<parameter=query>hello ",
511 "world",
512 " test</parameter>\n",
513 "</function>\n",
514 "</tool_call>",
515 ]
516
517 results = simulate_tool_streaming(parser, mock_request, chunks)
518
519 args_text = collect_tool_arguments(results)
520 assert args_text
521 parsed = json.loads(args_text)
522 assert parsed["query"] == "hello world test"
523
524 def test_streaming_split_tool_call_tag(self, parser, mock_request):
525 """<tool_call> arrives as a single special token; the rest of
526 the content is split into fine-grained chunks."""
527 chunks = [
528 "<tool_call>\n",
529 "<function=test>\n",
530 "<parameter=x>1",
531 "</parameter>\n",
532 "</function>\n",
533 "</tool_call>",
534 ]
535 results = simulate_tool_streaming(parser, mock_request, chunks)
536
537 name = collect_function_name(results)
538 assert name == "test"
539
540 args_text = collect_tool_arguments(results)
541 assert args_text
542 parsed = json.loads(args_text)
543 assert parsed["x"] == "1"
544
545 def test_char_by_char_streaming(self, mock_request):
546 """Feed text character-by-character to test lexer robustness.
547
548 Uses a tokenizer without special token IDs because char-by-char
549 delivery only occurs when the tokenizer splits the tag across
550 multiple sub-word tokens (i.e., no dedicated special token).
551 """
552 tokenizer = MagicMock()
553 tokenizer.encode.return_value = [1, 2, 3]
554 tokenizer.get_vocab.return_value = {}
555 tokenizer.decode.side_effect = lambda ids: "".join(
556 chr(i) if i < 128 else f"<{i}>" for i in ids
557 )
558 no_tid_parser = ParserEngine(
559 tokenizer, parser_engine_config=qwen3_config(thinking=False)
560 )
561
562 full_text = (
563 "<tool_call>\n"
564 "<function=echo>\n"
565 "<parameter=msg>hi</parameter>\n"
566 "</function>\n"
567 "</tool_call>"
568 )
569 chunks = list(full_text)
570 results = simulate_tool_streaming(no_tid_parser, mock_request, chunks)
571
572 name = collect_function_name(results)
573 assert name == "echo"
574
575 args_text = collect_tool_arguments(results)
576 assert args_text
577 parsed = json.loads(args_text)
578 assert parsed == {"msg": "hi"}
579
580 def test_streaming_multiline_param_values(self, parser, mock_request):
581 """Multi-line parameter values in streaming mode."""
582 chunks = [
583 "<tool_call>\n",
584 "<function=Bash>\n",
585 "<parameter=command>\n",
586 "ls -la /tmp\n",
587 "</parameter>\n",
588 "<parameter=description>\n",
589 "List files\n",
590 "</parameter>\n",
591 "</function>\n",
592 "</tool_call>",
593 ]
594 results = simulate_tool_streaming(parser, mock_request, chunks)
595
596 name = collect_function_name(results)
597 assert name == "Bash"
598
599 args_text = collect_tool_arguments(results)
600 assert args_text
601 parsed = json.loads(args_text)
602 assert "ls -la /tmp" in parsed["command"]
603 assert "List files" in parsed["description"]
604
605 def test_streaming_multiline_two_tool_calls(self, parser, mock_request):
606 """Two tool calls with multi-line values — matches bug report."""
607 chunks = [
608 "<tool_call>\n",
609 "<function=Bash>\n",
610 "<parameter=command>\n",
611 "find /workspace -name '*.py' | head -20\n",
612 "</parameter>\n",
613 "<parameter=description>\n",
614 "Find Python files\n",
615 "</parameter>\n",
616 "</function>\n",
617 "</tool_call>",
618 "<tool_call>\n",
619 "<function=Read>\n",
620 "<parameter=file_path>\n",
621 "/workspace/main.py\n",
622 "</parameter>\n",
623 "</function>\n",
624 "</tool_call>",
625 ]
626 results = simulate_tool_streaming(parser, mock_request, chunks)
627
628 names = []
629 for delta, _ in results:
630 if delta and delta.tool_calls:
631 for tc in delta.tool_calls:
632 if tc.function and tc.function.name:
633 names.append(tc.function.name)
634
635 assert "Bash" in names
636 assert "Read" in names
637
638
639 class TestArgConverter:
640 """Direct tests for the Qwen3 arg_converter with multi-line values."""
641
642 def test_multiline_param_values(self):
643 from vllm.parser.qwen3 import (
644 _qwen3_arg_converter,
645 )
646
647 raw = (
648 "<parameter=command>\n"
649 "ls -la /tmp\n"
650 "</parameter>\n"
651 "<parameter=description>\n"
652 "List files\n"
653 "</parameter>\n"
654 )
655 result = json.loads(_qwen3_arg_converter(raw, partial=False))
656 assert result["command"] == "ls -la /tmp"
657 assert result["description"] == "List files"
658
659 def test_two_multiline_params(self):
660 from vllm.parser.qwen3 import (
661 _qwen3_arg_converter,
662 )
663
664 raw = (
665 "<parameter=a>\nfoo\nbar\n</parameter>\n"
666 "<parameter=b>\nbaz\nqux\n</parameter>\n"
667 )
668 result = json.loads(_qwen3_arg_converter(raw, partial=False))
669 assert result["a"] == "foo\nbar"
670 assert result["b"] == "baz\nqux"
671
672 def test_partial_multiline(self):
673 from vllm.parser.qwen3 import (
674 _qwen3_arg_converter,
675 )
676
677 raw = "<parameter=command>\nls -la</parameter>\n<parameter=desc>\npartial value"
678 result = json.loads(_qwen3_arg_converter(raw, partial=True))
679 assert result["command"] == "ls -la"
680 assert result["desc"] == "partial value"
681
682 def test_partial_value_with_angle_bracket(self):
683 from vllm.parser.qwen3 import (
684 _qwen3_arg_converter,
685 )
686
687 raw = "<parameter=expr>x<5"
688 result = json.loads(_qwen3_arg_converter(raw, partial=True))
689 assert result == {"expr": "x<5"}
690
691 def test_partial_value_with_angle_bracket_and_complete_param(self):
692 from vllm.parser.qwen3 import (
693 _qwen3_arg_converter,
694 )
695
696 raw = "<parameter=city>Tokyo</parameter>\n<parameter=expr>x<5"
697 result = json.loads(_qwen3_arg_converter(raw, partial=True))
698 assert result == {"city": "Tokyo", "expr": "x<5"}
699
700
701 class TestSchemaAwareTypeCoercion:
702 """Verify that _fix_arg_types corrects miscoerced values using the
703 tool schema."""
704
705 @pytest.fixture
706 def tools(self):
707 from vllm.entrypoints.openai.chat_completion.protocol import (
708 ChatCompletionToolsParam,
709 )
710
711 return [
712 ChatCompletionToolsParam(
713 type="function",
714 function={
715 "name": "TaskUpdate",
716 "parameters": {
717 "type": "object",
718 "properties": {
719 "taskId": {"type": "string"},
720 "count": {"type": "integer"},
721 "ratio": {"type": "number"},
722 "flag": {"type": "string"},
723 },
724 },
725 },
726 )
727 ]
728
729 @pytest.fixture
730 def parser_with_tools(self, mock_tokenizer, tools):
731 return ParserEngine(
732 mock_tokenizer,
733 tools=tools,
734 parser_engine_config=qwen3_config(thinking=False),
735 )
736
737 def test_string_param_not_coerced_to_int(self, parser_with_tools, mock_request):
738 text = (
739 "<tool_call>\n"
740 "<function=TaskUpdate>\n"
741 "<parameter=taskId>1</parameter>\n"
742 "</function>\n"
743 "</tool_call>"
744 )
745 result = parser_with_tools.extract_tool_calls(text, mock_request)
746 assert result.tools_called
747 args = json.loads(result.tool_calls[0].function.arguments)
748 assert args["taskId"] == "1"
749 assert isinstance(args["taskId"], str)
750
751 def test_string_param_not_coerced_to_bool(self, parser_with_tools, mock_request):
752 text = (
753 "<tool_call>\n"
754 "<function=TaskUpdate>\n"
755 "<parameter=flag>true</parameter>\n"
756 "</function>\n"
757 "</tool_call>"
758 )
759 result = parser_with_tools.extract_tool_calls(text, mock_request)
760 args = json.loads(result.tool_calls[0].function.arguments)
761 assert args["flag"] == "true"
762 assert isinstance(args["flag"], str)
763
764 def test_int_param_still_coerced(self, parser_with_tools, mock_request):
765 text = (
766 "<tool_call>\n"
767 "<function=TaskUpdate>\n"
768 "<parameter=count>42</parameter>\n"
769 "</function>\n"
770 "</tool_call>"
771 )
772 result = parser_with_tools.extract_tool_calls(text, mock_request)
773 args = json.loads(result.tool_calls[0].function.arguments)
774 assert args["count"] == 42
775 assert isinstance(args["count"], int)
776
777 def test_no_tools_keeps_strings(self, parser, mock_request):
778 text = (
779 "<tool_call>\n"
780 "<function=TaskUpdate>\n"
781 "<parameter=taskId>1</parameter>\n"
782 "</function>\n"
783 "</tool_call>"
784 )
785 result = parser.extract_tool_calls(text, mock_request)
786 args = json.loads(result.tool_calls[0].function.arguments)
787 assert args["taskId"] == "1"
788 assert isinstance(args["taskId"], str)
789
790 def test_streaming_string_param_not_coerced(self, parser_with_tools, mock_request):
791 chunks = [
792 "<tool_call>\n",
793 "<function=TaskUpdate>\n",
794 "<parameter=taskId>1</parameter>\n",
795 "</function>\n",
796 "</tool_call>",
797 ]
798 results = simulate_tool_streaming(parser_with_tools, mock_request, chunks)
799 args_str = collect_tool_arguments(results)
800 args = json.loads(args_str)
801 assert args["taskId"] == "1"
802 assert isinstance(args["taskId"], str)
803
804
805 class TestAnyOfTypeCoercion:
806 """Verify that _fix_arg_types handles union types (anyOf/oneOf)."""
807
808 @pytest.fixture
809 def tools_with_anyof(self):
810 from vllm.entrypoints.openai.chat_completion.protocol import (
811 ChatCompletionToolsParam,
812 )
813
814 return [
815 ChatCompletionToolsParam(
816 type="function",
817 function={
818 "name": "set_config",
819 "parameters": {
820 "type": "object",
821 "properties": {
822 "port": {
823 "anyOf": [
824 {"type": "string"},
825 {"type": "null"},
826 ],
827 },
828 "count": {"type": "integer"},
829 },
830 },
831 },
832 )
833 ]
834
835 @pytest.fixture
836 def parser_with_anyof(self, mock_tokenizer, tools_with_anyof):
837 return ParserEngine(
838 mock_tokenizer,
839 tools=tools_with_anyof,
840 parser_engine_config=qwen3_config(thinking=False),
841 )
842
843 def test_anyof_string_param_not_coerced(self, parser_with_anyof, mock_request):
844 """A param with anyOf including 'string' must not be coerced
845 to integer."""
846 text = (
847 "<tool_call>\n"
848 "<function=set_config>\n"
849 "<parameter=port>8080</parameter>\n"
850 "</function>\n"
851 "</tool_call>"
852 )
853 result = parser_with_anyof.extract_tool_calls(text, mock_request)
854 assert result.tools_called
855 args = json.loads(result.tool_calls[0].function.arguments)
856 assert args["port"] == "8080"
857
858
859 class TestSchemaCoercionBoolNumberNull:
860 """Verify that _fix_arg_types coerces string values to non-string
861 schema types using coerce_to_schema_type."""
862
863 @pytest.fixture
864 def tools(self):
865 from vllm.entrypoints.openai.chat_completion.protocol import (
866 ChatCompletionToolsParam,
867 )
868
869 return [
870 ChatCompletionToolsParam(
871 type="function",
872 function={
873 "name": "configure",
874 "parameters": {
875 "type": "object",
876 "properties": {
877 "enabled": {"type": "boolean"},
878 "ratio": {"type": "number"},
879 "count": {"type": "integer"},
880 "value": {"type": ["integer", "null"]},
881 "label": {"type": "string"},
882 },
883 },
884 },
885 )
886 ]
887
888 @pytest.fixture
889 def parser_with_tools(self, mock_tokenizer, tools):
890 return ParserEngine(
891 mock_tokenizer,
892 tools=tools,
893 parser_engine_config=qwen3_config(thinking=False),
894 )
895
896 def test_bool_param_coerced(self, parser_with_tools, mock_request):
897 text = (
898 "<tool_call>\n"
899 "<function=configure>\n"
900 "<parameter=enabled>true</parameter>\n"
901 "</function>\n"
902 "</tool_call>"
903 )
904 result = parser_with_tools.extract_tool_calls(text, mock_request)
905 args = json.loads(result.tool_calls[0].function.arguments)
906 assert args["enabled"] is True
907 assert isinstance(args["enabled"], bool)
908
909 def test_number_param_whole_normalized(self, parser_with_tools, mock_request):
910 text = (
911 "<tool_call>\n"
912 "<function=configure>\n"
913 "<parameter=ratio>5.0</parameter>\n"
914 "</function>\n"
915 "</tool_call>"
916 )
917 result = parser_with_tools.extract_tool_calls(text, mock_request)
918 args = json.loads(result.tool_calls[0].function.arguments)
919 assert args["ratio"] == 5
920 assert isinstance(args["ratio"], int)
921
922 def test_number_param_fractional(self, parser_with_tools, mock_request):
923 text = (
924 "<tool_call>\n"
925 "<function=configure>\n"
926 "<parameter=ratio>3.14</parameter>\n"
927 "</function>\n"
928 "</tool_call>"
929 )
930 result = parser_with_tools.extract_tool_calls(text, mock_request)
931 args = json.loads(result.tool_calls[0].function.arguments)
932 assert args["ratio"] == pytest.approx(3.14)
933 assert isinstance(args["ratio"], float)
934
935 def test_null_coerced_when_in_schema(self, parser_with_tools, mock_request):
936 text = (
937 "<tool_call>\n"
938 "<function=configure>\n"
939 "<parameter=value>null</parameter>\n"
940 "</function>\n"
941 "</tool_call>"
942 )
943 result = parser_with_tools.extract_tool_calls(text, mock_request)
944 args = json.loads(result.tool_calls[0].function.arguments)
945 assert args["value"] is None
946
947 def test_null_stays_string_without_null_schema(
948 self, parser_with_tools, mock_request
949 ):
950 text = (
951 "<tool_call>\n"
952 "<function=configure>\n"
953 "<parameter=label>null</parameter>\n"
954 "</function>\n"
955 "</tool_call>"
956 )
957 result = parser_with_tools.extract_tool_calls(text, mock_request)
958 args = json.loads(result.tool_calls[0].function.arguments)
959 assert args["label"] == "null"
960 assert isinstance(args["label"], str)
961
962 def test_streaming_bool_param_coerced(self, parser_with_tools, mock_request):
963 chunks = [
964 "<tool_call>\n",
965 "<function=configure>\n",
966 "<parameter=enabled>true</parameter>\n",
967 "</function>\n",
968 "</tool_call>",
969 ]
970 results = simulate_tool_streaming(parser_with_tools, mock_request, chunks)
971 args_str = collect_tool_arguments(results)
972 args = json.loads(args_str)
973 assert args["enabled"] is True
974 assert isinstance(args["enabled"], bool)
975
976 def test_streaming_number_param_coerced(self, parser_with_tools, mock_request):
977 chunks = [
978 "<tool_call>\n",
979 "<function=configure>\n",
980 "<parameter=ratio>3.14</parameter>\n",
981 "</function>\n",
982 "</tool_call>",
983 ]
984 results = simulate_tool_streaming(parser_with_tools, mock_request, chunks)
985 args_str = collect_tool_arguments(results)
986 args = json.loads(args_str)
987 assert args["ratio"] == pytest.approx(3.14)
988 assert isinstance(args["ratio"], float)
989
990 def test_streaming_matches_non_streaming_comprehensive(
991 self, parser_with_tools, mock_request
992 ):
993 text = (
994 "<tool_call>\n"
995 "<function=configure>\n"
996 "<parameter=enabled>true</parameter>\n"
997 "<parameter=ratio>5.0</parameter>\n"
998 "<parameter=count>42</parameter>\n"
999 "<parameter=value>null</parameter>\n"
1000 "<parameter=label>hello</parameter>\n"
1001 "</function>\n"
1002 "</tool_call>"
1003 )
1004 non_stream = parser_with_tools.extract_tool_calls(text, mock_request)
1005 ns_args = json.loads(non_stream.tool_calls[0].function.arguments)
1006
1007 chunks = [line + "\n" for line in text.split("\n") if line]
1008 results = simulate_tool_streaming(parser_with_tools, mock_request, chunks)
1009 s_args = json.loads(collect_tool_arguments(results))
1010
1011 assert s_args == ns_args
1012 assert ns_args == {
1013 "enabled": True,
1014 "ratio": 5,
1015 "count": 42,
1016 "value": None,
1017 "label": "hello",
1018 }
1019
1020
1021 class TestNestedSchemaCoercion:
1022 """Verify that _fix_arg_types recurses into nested objects and arrays."""
1023
1024 @pytest.fixture
1025 def tools(self):
1026 from vllm.entrypoints.openai.chat_completion.protocol import (
1027 ChatCompletionToolsParam,
1028 )
1029
1030 return [
1031 ChatCompletionToolsParam(
1032 type="function",
1033 function={
1034 "name": "search",
1035 "parameters": {
1036 "type": "object",
1037 "properties": {
1038 "query": {"type": "string"},
1039 "filters": {
1040 "type": "object",
1041 "properties": {
1042 "language": {"type": "string"},
1043 "min_stars": {"type": "integer"},
1044 },
1045 },
1046 "tags": {
1047 "type": "array",
1048 "items": {"type": "string"},
1049 },
1050 "limits": {
1051 "type": "array",
1052 "items": {"type": "integer"},
1053 },
1054 "verbose": {"type": "boolean"},
1055 },
1056 },
1057 },
1058 ),
1059 ChatCompletionToolsParam(
1060 type="function",
1061 function={
1062 "name": "AskUserQuestion",
1063 "parameters": {
1064 "type": "object",
1065 "properties": {
1066 "questions": {
1067 "type": "array",
1068 "items": {
1069 "type": "object",
1070 "properties": {
1071 "question": {"type": "string"},
1072 "multiSelect": {
1073 "type": "boolean",
1074 },
1075 "answer": {
1076 "type": ["string", "null"],
1077 },
1078 },
1079 },
1080 },
1081 },
1082 },
1083 },
1084 ),
1085 ]
1086
1087 @pytest.fixture
1088 def parser_with_tools(self, mock_tokenizer, tools):
1089 return ParserEngine(
1090 mock_tokenizer,
1091 tools=tools,
1092 parser_engine_config=qwen3_config(thinking=False),
1093 )
1094
1095 def test_nested_object_coerced(self, parser_with_tools, mock_request):
1096 text = (
1097 "<tool_call>\n"
1098 "<function=search>\n"
1099 '<parameter=filters>{"language": "python",'
1100 ' "min_stars": 100}</parameter>\n'
1101 "</function>\n"
1102 "</tool_call>"
1103 )
1104 result = parser_with_tools.extract_tool_calls(text, mock_request)
1105 args = json.loads(result.tool_calls[0].function.arguments)
1106 assert args["filters"] == {"language": "python", "min_stars": 100}
1107 assert isinstance(args["filters"]["min_stars"], int)
1108
1109 def test_nested_array_items_coerced(self, parser_with_tools, mock_request):
1110 text = (
1111 "<tool_call>\n"
1112 "<function=search>\n"
1113 "<parameter=limits>[10, 20, 30]</parameter>\n"
1114 "</function>\n"
1115 "</tool_call>"
1116 )
1117 result = parser_with_tools.extract_tool_calls(text, mock_request)
1118 args = json.loads(result.tool_calls[0].function.arguments)
1119 assert args["limits"] == [10, 20, 30]
1120 assert all(isinstance(v, int) for v in args["limits"])
1121
1122 def test_nested_string_array_not_coerced(self, parser_with_tools, mock_request):
1123 text = (
1124 "<tool_call>\n"
1125 "<function=search>\n"
1126 '<parameter=tags>["ml", "42"]</parameter>\n'
1127 "</function>\n"
1128 "</tool_call>"
1129 )
1130 result = parser_with_tools.extract_tool_calls(text, mock_request)
1131 args = json.loads(result.tool_calls[0].function.arguments)
1132 assert args["tags"] == ["ml", "42"]
1133 assert all(isinstance(v, str) for v in args["tags"])
1134
1135 def test_array_of_objects_with_bool_and_null_coerced(
1136 self, parser_with_tools, mock_request
1137 ):
1138 text = (
1139 "<tool_call>\n"
1140 "<function=AskUserQuestion>\n"
1141 "<parameter=questions>"
1142 '[{"question": "Pick a color",'
1143 ' "multiSelect": false, "answer": null}]'
1144 "</parameter>\n"
1145 "</function>\n"
1146 "</tool_call>"
1147 )
1148 result = parser_with_tools.extract_tool_calls(text, mock_request)
1149 args = json.loads(result.tool_calls[0].function.arguments)
1150 questions = args["questions"]
1151 assert isinstance(questions, list)
1152 assert len(questions) == 1
1153 assert questions[0]["question"] == "Pick a color"
1154 assert questions[0]["multiSelect"] is False
1155 assert questions[0]["answer"] is None
1156
1157 def test_streaming_array_of_objects_with_bool_and_null_coerced(
1158 self, parser_with_tools, mock_request
1159 ):
1160 chunks = [
1161 "<tool_call>\n",
1162 "<function=AskUserQuestion>\n",
1163 '<parameter=questions>[{"question": "Pick a color",',
1164 ' "multiSelect": false, "answer": null}]',
1165 "</parameter>\n",
1166 "</function>\n",
1167 "</tool_call>",
1168 ]
1169 results = simulate_tool_streaming(parser_with_tools, mock_request, chunks)
1170 args_str = collect_tool_arguments(results)
1171 args = json.loads(args_str)
1172 questions = args["questions"]
1173 assert isinstance(questions, list)
1174 assert len(questions) == 1
1175 assert questions[0]["question"] == "Pick a color"
1176 assert questions[0]["multiSelect"] is False
1177 assert questions[0]["answer"] is None
1178
1179
1180 @pytest.mark.parametrize("value", ["\n北京 &amp;\n", "null", "42", "", "a\nb"])
1181 @pytest.mark.parametrize("chunk_size", [1, 7, 1000])
1182 def test_mimo_preserves_verbatim_parameter_values(
1183 mock_tokenizer, mock_request, value, chunk_size
1184 ):
1185 from vllm.parser.mimo import MiMoParser
1186
1187 text = (
1188 f"<tool_call><function=run><parameter=text>{value}</parameter>"
1189 "</function></tool_call>"
1190 )
1191 parser = MiMoParser(mock_tokenizer, chat_template_kwargs={"enable_thinking": False})
1192 result = parser.extract_tool_calls(text, mock_request)
1193 assert json.loads(result.tool_calls[0].function.arguments) == {"text": value}
1194 parser = MiMoParser(mock_tokenizer, chat_template_kwargs={"enable_thinking": False})
1195 results = simulate_tool_streaming(
1196 parser,
1197 mock_request,
1198 [text[i : i + chunk_size] for i in range(0, len(text), chunk_size)],
1199 )
1200 assert collect_function_name(results) == "run"
1201 assert json.loads(collect_tool_arguments(results)) == {"text": value}