main
py 1,539 lines 45.9 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import json
5 from collections.abc import Generator
6 from unittest.mock import MagicMock
7
8 import pytest
9 from openai.types.responses.function_tool import FunctionTool
10 from xgrammar import StructuralTag
11
12 from vllm.entrypoints.generate.base.protocol import (
13 DeltaMessage,
14 FunctionCall,
15 ToolCall,
16 )
17 from vllm.entrypoints.openai.chat_completion.protocol import (
18 ChatCompletionNamedFunction,
19 ChatCompletionNamedToolChoiceParam,
20 ChatCompletionRequest,
21 ChatCompletionToolsParam,
22 FunctionDefinition,
23 )
24 from vllm.parser.abstract_parser import DelegatingParser
25 from vllm.tokenizers import TokenizerLike, get_tokenizer
26 from vllm.tokenizers.detokenizer_utils import detokenize_incrementally
27 from vllm.tool_parsers.qwen3_engine_tool_parser import (
28 Qwen3EngineToolParser,
29 )
30
31 MODEL = "Qwen/Qwen3-Coder-30B-A3B-Instruct-FP8"
32
33
34 @pytest.fixture(scope="module")
35 def qwen3_tokenizer():
36 return get_tokenizer(tokenizer_name=MODEL)
37
38
39 @pytest.fixture
40 def qwen3_tool_parser(qwen3_tokenizer, sample_tools):
41 return Qwen3EngineToolParser(qwen3_tokenizer, tools=sample_tools)
42
43
44 WEATHER_PARAMS = {
45 "type": "object",
46 "properties": {
47 "city": {"type": "string", "description": "The city name"},
48 "state": {"type": "string", "description": "The state code"},
49 "unit": {"type": "string", "enum": ["fahrenheit", "celsius"]},
50 },
51 "required": ["city", "state"],
52 }
53
54 AREA_PARAMS = {
55 "type": "object",
56 "properties": {
57 "shape": {"type": "string"},
58 "dimensions": {"type": "object"},
59 "precision": {"type": "integer"},
60 },
61 }
62
63 QUESTION_PARAMS = {
64 "type": "object",
65 "properties": {
66 "questions": {
67 "type": "array",
68 "items": {
69 "type": "object",
70 "properties": {
71 "question": {"type": "string"},
72 "multiSelect": {"type": "boolean"},
73 "answer": {"type": "string"},
74 },
75 },
76 },
77 },
78 }
79
80
81 @pytest.fixture(params=["chat_completion", "responses_api"])
82 def sample_tools(request):
83 if request.param == "chat_completion":
84 return [
85 ChatCompletionToolsParam(
86 type="function",
87 function={
88 "name": "get_current_weather",
89 "description": "Get the current weather",
90 "parameters": WEATHER_PARAMS,
91 },
92 ),
93 ChatCompletionToolsParam(
94 type="function",
95 function={
96 "name": "calculate_area",
97 "description": "Calculate area of a shape",
98 "parameters": AREA_PARAMS,
99 },
100 ),
101 ]
102 else:
103 return [
104 FunctionTool(
105 type="function",
106 name="get_current_weather",
107 description="Get the current weather",
108 parameters=WEATHER_PARAMS,
109 ),
110 FunctionTool(
111 type="function",
112 name="calculate_area",
113 description="Calculate area of a shape",
114 parameters=AREA_PARAMS,
115 ),
116 ]
117
118
119 def _with_strict(
120 tools: list[ChatCompletionToolsParam],
121 ) -> list[ChatCompletionToolsParam]:
122 return [
123 ChatCompletionToolsParam(
124 type=t.type,
125 function=FunctionDefinition(
126 name=t.function.name,
127 description=t.function.description,
128 parameters=t.function.parameters,
129 strict=True,
130 ),
131 )
132 for t in tools
133 ]
134
135
136 def _as_chat_completion_tools(
137 tools: list[ChatCompletionToolsParam | FunctionTool],
138 ) -> list[ChatCompletionToolsParam]:
139 normalized: list[ChatCompletionToolsParam] = []
140 for tool in tools:
141 if isinstance(tool, ChatCompletionToolsParam):
142 normalized.append(tool)
143 else:
144 normalized.append(
145 ChatCompletionToolsParam(
146 type="function",
147 function={
148 "name": tool.name,
149 "description": tool.description,
150 "parameters": tool.parameters,
151 },
152 )
153 )
154 return normalized
155
156
157 def assert_tool_calls(
158 actual_tool_calls: list[ToolCall], expected_tool_calls: list[ToolCall]
159 ):
160 assert len(actual_tool_calls) == len(expected_tool_calls)
161
162 for actual_tool_call, expected_tool_call in zip(
163 actual_tool_calls, expected_tool_calls
164 ):
165 # Qwen3 parser doesn't generate IDs during extraction
166 assert actual_tool_call.type == "function"
167 assert actual_tool_call.function.name == expected_tool_call.function.name
168 assert json.loads(actual_tool_call.function.arguments) == json.loads(
169 expected_tool_call.function.arguments
170 )
171
172
173 def stream_delta_message_generator(
174 qwen3_tool_parser,
175 qwen3_tokenizer: TokenizerLike,
176 model_output: str,
177 request: ChatCompletionRequest | None = None,
178 ) -> Generator[DeltaMessage, None, None]:
179 all_token_ids = qwen3_tokenizer.encode(model_output, add_special_tokens=False)
180
181 previous_text = ""
182 previous_tokens = None
183 prefix_offset = 0
184 read_offset = 0
185 for i, delta_token in enumerate(all_token_ids):
186 delta_token_ids = [delta_token]
187 previous_token_ids = all_token_ids[:i]
188 current_token_ids = all_token_ids[: i + 1]
189
190 (new_tokens, delta_text, new_prefix_offset, new_read_offset) = (
191 detokenize_incrementally(
192 tokenizer=qwen3_tokenizer,
193 all_input_ids=current_token_ids,
194 prev_tokens=previous_tokens,
195 prefix_offset=prefix_offset,
196 read_offset=read_offset,
197 skip_special_tokens=False,
198 spaces_between_special_tokens=True,
199 )
200 )
201
202 current_text = previous_text + delta_text
203
204 delta_message = qwen3_tool_parser.extract_tool_calls_streaming(
205 previous_text,
206 current_text,
207 delta_text,
208 previous_token_ids,
209 current_token_ids,
210 delta_token_ids,
211 request=request,
212 )
213 if delta_message:
214 yield delta_message
215
216 previous_text = current_text
217 previous_tokens = (
218 previous_tokens + new_tokens if previous_tokens else new_tokens
219 )
220 prefix_offset = new_prefix_offset
221 read_offset = new_read_offset
222
223
224 def test_extract_tool_calls_no_tools(qwen3_tool_parser):
225 model_output = "This is a test response without any tool calls"
226 extracted_tool_calls = qwen3_tool_parser.extract_tool_calls(
227 model_output, request=None
228 )
229 assert not extracted_tool_calls.tools_called
230 assert extracted_tool_calls.tool_calls == []
231 assert extracted_tool_calls.content == model_output
232
233
234 @pytest.mark.parametrize(
235 ids=[
236 "single_tool",
237 "single_tool_with_content",
238 "single_tool_multiline_param",
239 "parallel_tools",
240 "tool_with_typed_params",
241 ],
242 argnames=["model_output", "expected_tool_calls", "expected_content"],
243 argvalues=[
244 (
245 """<tool_call>
246 <function=get_current_weather>
247 <parameter=city>
248 Dallas
249 </parameter>
250 <parameter=state>
251 TX
252 </parameter>
253 <parameter=unit>
254 fahrenheit
255 </parameter>
256 </function>
257 </tool_call>""",
258 [
259 ToolCall(
260 function=FunctionCall(
261 name="get_current_weather",
262 arguments=json.dumps(
263 {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}
264 ),
265 )
266 )
267 ],
268 None,
269 ),
270 (
271 """Sure! Let me check the weather for you.<tool_call>
272 <function=get_current_weather>
273 <parameter=city>
274 Dallas
275 </parameter>
276 <parameter=state>
277 TX
278 </parameter>
279 <parameter=unit>
280 fahrenheit
281 </parameter>
282 </function>
283 </tool_call>""",
284 [
285 ToolCall(
286 function=FunctionCall(
287 name="get_current_weather",
288 arguments=json.dumps(
289 {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}
290 ),
291 )
292 )
293 ],
294 "Sure! Let me check the weather for you.",
295 ),
296 (
297 """<tool_call>
298 <function=calculate_area>
299 <parameter=shape>
300 rectangle
301 </parameter>
302 <parameter=dimensions>
303 {"width": 10,
304 "height": 20}
305 </parameter>
306 <parameter=precision>
307 2
308 </parameter>
309 </function>
310 </tool_call>""",
311 [
312 ToolCall(
313 function=FunctionCall(
314 name="calculate_area",
315 arguments=json.dumps(
316 {
317 "shape": "rectangle",
318 "dimensions": {"width": 10, "height": 20},
319 "precision": 2,
320 }
321 ),
322 )
323 )
324 ],
325 None,
326 ),
327 (
328 """<tool_call>
329 <function=get_current_weather>
330 <parameter=city>
331 Dallas
332 </parameter>
333 <parameter=state>
334 TX
335 </parameter>
336 <parameter=unit>
337 fahrenheit
338 </parameter>
339 </function>
340 </tool_call>
341 <tool_call>
342 <function=get_current_weather>
343 <parameter=city>
344 Orlando
345 </parameter>
346 <parameter=state>
347 FL
348 </parameter>
349 <parameter=unit>
350 fahrenheit
351 </parameter>
352 </function>
353 </tool_call>""",
354 [
355 ToolCall(
356 function=FunctionCall(
357 name="get_current_weather",
358 arguments=json.dumps(
359 {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}
360 ),
361 )
362 ),
363 ToolCall(
364 function=FunctionCall(
365 name="get_current_weather",
366 arguments=json.dumps(
367 {"city": "Orlando", "state": "FL", "unit": "fahrenheit"}
368 ),
369 )
370 ),
371 ],
372 None,
373 ),
374 (
375 """Let me calculate that area for you.<tool_call>
376 <function=calculate_area>
377 <parameter=shape>
378 circle
379 </parameter>
380 <parameter=dimensions>
381 {"radius": 15.5}
382 </parameter>
383 <parameter=precision>
384 3
385 </parameter>
386 </function>
387 </tool_call>""",
388 [
389 ToolCall(
390 function=FunctionCall(
391 name="calculate_area",
392 arguments=json.dumps(
393 {
394 "shape": "circle",
395 "dimensions": {"radius": 15.5},
396 "precision": 3,
397 }
398 ),
399 )
400 )
401 ],
402 "Let me calculate that area for you.",
403 ),
404 ],
405 )
406 def test_extract_tool_calls(
407 qwen3_tool_parser,
408 model_output,
409 expected_tool_calls,
410 expected_content,
411 ):
412 request = ChatCompletionRequest(model=MODEL, messages=[])
413 extracted_tool_calls = qwen3_tool_parser.extract_tool_calls(
414 model_output, request=request
415 )
416 assert extracted_tool_calls.tools_called
417
418 assert_tool_calls(extracted_tool_calls.tool_calls, expected_tool_calls)
419
420 assert extracted_tool_calls.content == expected_content
421
422
423 def test_extract_tool_calls_fallback_no_tags(
424 qwen3_tool_parser,
425 ):
426 """Test fallback parsing when XML tags are missing."""
427 model_output = """<function=get_current_weather>
428 <parameter=city>
429 Dallas
430 </parameter>
431 <parameter=state>
432 TX
433 </parameter>
434 </function>"""
435
436 request = ChatCompletionRequest(model=MODEL, messages=[])
437 extracted_tool_calls = qwen3_tool_parser.extract_tool_calls(
438 model_output, request=request
439 )
440
441 assert extracted_tool_calls.tools_called
442 assert len(extracted_tool_calls.tool_calls) == 1
443 assert extracted_tool_calls.tool_calls[0].function.name == "get_current_weather"
444
445
446 def test_extract_tool_calls_type_conversion(qwen3_tokenizer):
447 """Test parameter type conversion based on tool schema."""
448 tools = [
449 ChatCompletionToolsParam(
450 type="function",
451 function={
452 "name": "test_types",
453 "parameters": {
454 "type": "object",
455 "properties": {
456 "int_param": {"type": "integer"},
457 "float_param": {"type": "float"},
458 "bool_param": {"type": "boolean"},
459 "str_param": {"type": "string"},
460 "obj_param": {"type": "object"},
461 },
462 },
463 },
464 )
465 ]
466
467 model_output = """<tool_call>
468 <function=test_types>
469 <parameter=int_param>
470 42
471 </parameter>
472 <parameter=float_param>
473 3.14
474 </parameter>
475 <parameter=bool_param>
476 true
477 </parameter>
478 <parameter=str_param>
479 hello world
480 </parameter>
481 <parameter=obj_param>
482 {"key": "value"}
483 </parameter>
484 </function>
485 </tool_call>"""
486
487 parser = Qwen3EngineToolParser(qwen3_tokenizer, tools=tools)
488 request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools)
489 extracted_tool_calls = parser.extract_tool_calls(model_output, request=request)
490
491 args = json.loads(extracted_tool_calls.tool_calls[0].function.arguments)
492 assert args["int_param"] == 42
493 assert args["float_param"] == 3.14
494 assert args["bool_param"] is True
495 assert args["str_param"] == "hello world"
496 assert args["obj_param"] == {"key": "value"}
497
498
499 def test_extract_tool_calls_anyof_type_conversion(qwen3_tokenizer):
500 """Test type conversion for anyOf/oneOf nullable schemas (Pydantic v2).
501
502 Pydantic v2 emits anyOf for Optional[T] fields, e.g.:
503 Optional[int] -> {"anyOf": [{"type": "integer"}, {"type": "null"}]}
504 The parser must extract the non-null type and apply the correct
505 conversion (int(), float(), etc.) instead of returning a raw string.
506 """
507 tools = [
508 ChatCompletionToolsParam(
509 type="function",
510 function={
511 "name": "test_anyof",
512 "parameters": {
513 "type": "object",
514 "properties": {
515 "anyof_int": {
516 "anyOf": [
517 {"type": "integer"},
518 {"type": "null"},
519 ],
520 "default": 5,
521 },
522 "anyof_str": {
523 "anyOf": [
524 {"type": "string"},
525 {"type": "null"},
526 ],
527 },
528 "anyof_array": {
529 "anyOf": [
530 {"type": "array", "items": {"type": "string"}},
531 {"type": "null"},
532 ],
533 },
534 "anyof_obj": {
535 "anyOf": [
536 {"type": "object"},
537 {"type": "null"},
538 ],
539 },
540 "type_as_array": {
541 "type": ["integer", "null"],
542 },
543 "multi_non_null": {
544 "anyOf": [
545 {"type": "string"},
546 {"type": "integer"},
547 {"type": "null"},
548 ],
549 },
550 },
551 },
552 },
553 )
554 ]
555
556 model_output = """<tool_call>
557 <function=test_anyof>
558 <parameter=anyof_int>
559 5
560 </parameter>
561 <parameter=anyof_str>
562 hello
563 </parameter>
564 <parameter=anyof_array>
565 ["a", "b", "c"]
566 </parameter>
567 <parameter=anyof_obj>
568 {"key": "value"}
569 </parameter>
570 <parameter=type_as_array>
571 42
572 </parameter>
573 <parameter=multi_non_null>
574 some text
575 </parameter>
576 </function>
577 </tool_call>"""
578
579 parser = Qwen3EngineToolParser(qwen3_tokenizer, tools=tools)
580 request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools)
581 extracted = parser.extract_tool_calls(model_output, request=request)
582
583 args = json.loads(extracted.tool_calls[0].function.arguments)
584 assert args["anyof_int"] == 5
585 assert isinstance(args["anyof_int"], int)
586 assert args["anyof_str"] == "hello"
587 assert isinstance(args["anyof_str"], str)
588 assert args["anyof_array"] == ["a", "b", "c"]
589 assert isinstance(args["anyof_array"], list)
590 assert args["anyof_obj"] == {"key": "value"}
591 assert isinstance(args["anyof_obj"], dict)
592 assert args["type_as_array"] == 42
593 assert isinstance(args["type_as_array"], int)
594 # Multi non-null: anyOf[string, integer, null] → first non-null is string
595 assert args["multi_non_null"] == "some text"
596 assert isinstance(args["multi_non_null"], str)
597
598
599 def test_extract_tool_calls_anyof_type_conversion_streaming(qwen3_tokenizer):
600 """Test streaming e2e for anyOf/oneOf nullable schemas (Pydantic v2).
601
602 Verifies that the full streaming pipeline — tokenize, incrementally
603 decode, extract_tool_calls_streaming — correctly resolves types from
604 anyOf schemas and produces valid JSON with properly typed values.
605 """
606 tools = [
607 ChatCompletionToolsParam(
608 type="function",
609 function={
610 "name": "search_web",
611 "parameters": {
612 "type": "object",
613 "properties": {
614 "query": {
615 "anyOf": [
616 {"type": "string"},
617 {"type": "null"},
618 ],
619 },
620 "count": {
621 "anyOf": [
622 {"type": "integer"},
623 {"type": "null"},
624 ],
625 "default": 5,
626 },
627 "verbose": {
628 "anyOf": [
629 {"type": "boolean"},
630 {"type": "null"},
631 ],
632 },
633 },
634 },
635 },
636 )
637 ]
638
639 model_output = """<tool_call>
640 <function=search_web>
641 <parameter=query>
642 vllm tool parser
643 </parameter>
644 <parameter=count>
645 10
646 </parameter>
647 <parameter=verbose>
648 true
649 </parameter>
650 </function>
651 </tool_call>"""
652
653 parser = Qwen3EngineToolParser(qwen3_tokenizer, tools=tools)
654 request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools)
655
656 tool_states = {}
657 for delta_message in stream_delta_message_generator(
658 parser, qwen3_tokenizer, model_output, request
659 ):
660 if delta_message.tool_calls:
661 for tool_call in delta_message.tool_calls:
662 idx = tool_call.index
663 if idx not in tool_states:
664 tool_states[idx] = {"name": None, "arguments": ""}
665 if tool_call.function:
666 if tool_call.function.name:
667 tool_states[idx]["name"] = tool_call.function.name
668 if tool_call.function.arguments is not None:
669 tool_states[idx]["arguments"] += tool_call.function.arguments
670
671 assert len(tool_states) == 1
672 assert tool_states[0]["name"] == "search_web"
673 assert tool_states[0]["arguments"] is not None
674 args = json.loads(tool_states[0]["arguments"])
675 assert args["query"] == "vllm tool parser"
676 assert isinstance(args["query"], str)
677 assert args["count"] == 10
678 assert isinstance(args["count"], int)
679 assert args["verbose"] is True
680 assert isinstance(args["verbose"], bool)
681
682
683 @pytest.mark.parametrize(
684 ids=[
685 "no_tools",
686 "single_tool",
687 "single_tool_with_content",
688 "single_tool_multiline_param",
689 "parallel_tools",
690 "tool_with_typed_params", # Added this test case
691 ],
692 argnames=["model_output", "expected_tool_calls", "expected_content"],
693 argvalues=[
694 ("This is a test without tools", [], "This is a test without tools"),
695 (
696 """<tool_call>
697 <function=get_current_weather>
698 <parameter=city>
699 Dallas
700 </parameter>
701 <parameter=state>
702 TX
703 </parameter>
704 <parameter=unit>
705 fahrenheit
706 </parameter>
707 </function>
708 </tool_call>""",
709 [
710 ToolCall(
711 function=FunctionCall(
712 name="get_current_weather",
713 arguments=json.dumps(
714 {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}
715 ),
716 )
717 )
718 ],
719 None,
720 ),
721 (
722 """Sure! Let me check the weather for you.<tool_call>
723 <function=get_current_weather>
724 <parameter=city>
725 Dallas
726 </parameter>
727 <parameter=state>
728 TX
729 </parameter>
730 <parameter=unit>
731 fahrenheit
732 </parameter>
733 </function>
734 </tool_call>""",
735 [
736 ToolCall(
737 function=FunctionCall(
738 name="get_current_weather",
739 arguments=json.dumps(
740 {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}
741 ),
742 )
743 )
744 ],
745 "Sure! Let me check the weather for you.",
746 ),
747 (
748 """<tool_call>
749 <function=calculate_area>
750 <parameter=shape>
751 rectangle
752 </parameter>
753 <parameter=dimensions>
754 {"width": 10,
755 "height": 20}
756 </parameter>
757 <parameter=precision>
758 2
759 </parameter>
760 </function>
761 </tool_call>""",
762 [
763 ToolCall(
764 function=FunctionCall(
765 name="calculate_area",
766 arguments=json.dumps(
767 {
768 "shape": "rectangle",
769 "dimensions": {"width": 10, "height": 20},
770 "precision": 2,
771 }
772 ),
773 )
774 )
775 ],
776 None,
777 ),
778 (
779 """<tool_call>
780 <function=get_current_weather>
781 <parameter=city>
782 Dallas
783 </parameter>
784 <parameter=state>
785 TX
786 </parameter>
787 <parameter=unit>
788 fahrenheit
789 </parameter>
790 </function>
791 </tool_call>
792 <tool_call>
793 <function=get_current_weather>
794 <parameter=city>
795 Orlando
796 </parameter>
797 <parameter=state>
798 FL
799 </parameter>
800 <parameter=unit>
801 celsius
802 </parameter>
803 </function>
804 </tool_call>""",
805 [
806 ToolCall(
807 function=FunctionCall(
808 name="get_current_weather",
809 arguments=json.dumps(
810 {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}
811 ),
812 )
813 ),
814 ToolCall(
815 function=FunctionCall(
816 name="get_current_weather",
817 arguments=json.dumps(
818 {"city": "Orlando", "state": "FL", "unit": "celsius"}
819 ),
820 )
821 ),
822 ],
823 None,
824 ),
825 # Added tool_with_typed_params test case
826 (
827 """Let me calculate that area for you.<tool_call>
828 <function=calculate_area>
829 <parameter=shape>
830 circle
831 </parameter>
832 <parameter=dimensions>
833 {"radius": 15.5}
834 </parameter>
835 <parameter=precision>
836 3
837 </parameter>
838 </function>
839 </tool_call>""",
840 [
841 ToolCall(
842 function=FunctionCall(
843 name="calculate_area",
844 arguments=json.dumps(
845 {
846 "shape": "circle",
847 "dimensions": {"radius": 15.5},
848 "precision": 3,
849 }
850 ),
851 )
852 )
853 ],
854 "Let me calculate that area for you.",
855 ),
856 ],
857 )
858 def test_extract_tool_calls_streaming(
859 qwen3_tool_parser,
860 qwen3_tokenizer,
861 model_output,
862 expected_tool_calls,
863 expected_content,
864 ):
865 """Test incremental streaming behavior including typed parameters."""
866 request = ChatCompletionRequest(model=MODEL, messages=[])
867
868 other_content = ""
869 tool_states = {} # Track state per tool index
870
871 for delta_message in stream_delta_message_generator(
872 qwen3_tool_parser, qwen3_tokenizer, model_output, request
873 ):
874 # role should never be streamed from tool parser
875 assert not delta_message.role
876
877 if delta_message.content:
878 other_content += delta_message.content
879
880 if delta_message.tool_calls:
881 for tool_call in delta_message.tool_calls:
882 idx = tool_call.index
883
884 # Initialize state for new tool
885 if idx not in tool_states:
886 tool_states[idx] = {
887 "id": None,
888 "name": None,
889 "arguments": "",
890 "type": None,
891 }
892
893 # First chunk should have id, name, and type
894 if tool_call.id:
895 tool_states[idx]["id"] = tool_call.id
896
897 if tool_call.type:
898 assert tool_call.type == "function"
899 tool_states[idx]["type"] = tool_call.type
900
901 if tool_call.function:
902 if tool_call.function.name:
903 # Should only be set once
904 assert tool_states[idx]["name"] is None
905 tool_states[idx]["name"] = tool_call.function.name
906
907 if tool_call.function.arguments is not None:
908 # Accumulate arguments incrementally
909 tool_states[idx]["arguments"] += tool_call.function.arguments
910
911 # Verify final content
912 assert other_content == (expected_content or "") # Handle None case
913
914 # Verify we got all expected tool calls
915 assert len(tool_states) == len(expected_tool_calls)
916
917 # Verify each tool call
918 for idx, expected_tool in enumerate(expected_tool_calls):
919 state = tool_states[idx]
920 assert state["id"] is not None
921 assert state["type"] == "function"
922 assert state["name"] == expected_tool.function.name
923
924 # Parse accumulated arguments
925 arguments_str = state["arguments"]
926 assert arguments_str is not None
927 actual_args = json.loads(arguments_str)
928 expected_args = json.loads(expected_tool.function.arguments)
929 assert actual_args == expected_args
930
931
932 def test_extract_tool_calls_missing_closing_parameter_tag(
933 qwen3_tool_parser,
934 ):
935 """Test handling of missing closing </parameter> tag."""
936 # Using get_current_weather from sample_tools but with malformed XML
937 model_output = """Let me check the weather for you:
938 <tool_call>
939 <function=get_current_weather>
940 <parameter=city>
941 Dallas
942 <parameter=state>
943 TX
944 </parameter>
945 <parameter=unit>
946 fahrenheit
947 </parameter>
948 </function>
949 </tool_call>"""
950
951 request = ChatCompletionRequest(model=MODEL, messages=[])
952 extracted_tool_calls = qwen3_tool_parser.extract_tool_calls(
953 model_output, request=request
954 )
955
956 # The parser should handle the malformed XML gracefully
957 assert extracted_tool_calls.tools_called
958 assert len(extracted_tool_calls.tool_calls) == 1
959
960 # Verify the function name is correct
961 assert extracted_tool_calls.tool_calls[0].function.name == "get_current_weather"
962
963 # Verify the arguments are parsed despite the missing closing tag
964 args = json.loads(extracted_tool_calls.tool_calls[0].function.arguments)
965 assert "city" in args
966 assert args["city"] == "Dallas"
967 assert args["state"] == "TX"
968 assert args["unit"] == "fahrenheit"
969
970 # Check that content before the tool call is preserved
971 assert "Let me check the weather for you:" in extracted_tool_calls.content
972
973
974 def test_extract_tool_calls_streaming_missing_closing_tag(
975 qwen3_tool_parser, qwen3_tokenizer
976 ):
977 """Test streaming with missing closing </parameter> tag."""
978 # Using get_current_weather from sample_tools but with malformed XML
979 model_output = """Let me check the weather for you:
980 <tool_call>
981 <function=get_current_weather>
982 <parameter=city>
983 Dallas
984 <parameter=state>
985 TX
986 </parameter>
987 <parameter=unit>
988 fahrenheit
989 </parameter>
990 </function>
991 </tool_call>"""
992
993 request = ChatCompletionRequest(model=MODEL, messages=[])
994
995 other_content = ""
996 tool_states = {}
997
998 for delta_message in stream_delta_message_generator(
999 qwen3_tool_parser, qwen3_tokenizer, model_output, request
1000 ):
1001 if delta_message.content:
1002 other_content += delta_message.content
1003
1004 if delta_message.tool_calls:
1005 for tool_call in delta_message.tool_calls:
1006 idx = tool_call.index
1007
1008 if idx not in tool_states:
1009 tool_states[idx] = {
1010 "id": None,
1011 "name": None,
1012 "arguments": "",
1013 "type": None,
1014 }
1015
1016 if tool_call.id:
1017 tool_states[idx]["id"] = tool_call.id
1018
1019 if tool_call.type:
1020 assert tool_call.type == "function"
1021 tool_states[idx]["type"] = tool_call.type
1022
1023 if tool_call.function:
1024 if tool_call.function.name:
1025 tool_states[idx]["name"] = tool_call.function.name
1026
1027 if tool_call.function.arguments is not None:
1028 tool_states[idx]["arguments"] += tool_call.function.arguments
1029
1030 # Verify content was streamed
1031 assert "Let me check the weather for you:" in other_content
1032 # Verify we got the tool call
1033 assert len(tool_states) == 1
1034
1035 state = tool_states[0]
1036 assert state["id"] is not None
1037 assert state["type"] == "function"
1038 assert state["name"] == "get_current_weather"
1039
1040 # Verify arguments were parsed correctly despite missing closing tag
1041 assert state["arguments"] is not None
1042 args = json.loads(state["arguments"])
1043 assert args["city"] == "Dallas"
1044 assert args["state"] == "TX"
1045 assert args["unit"] == "fahrenheit"
1046
1047
1048 def test_extract_tool_calls_streaming_incremental(qwen3_tool_parser, qwen3_tokenizer):
1049 """Test that streaming is truly incremental."""
1050 model_output = """I'll check the weather.<tool_call>
1051 <function=get_current_weather>
1052 <parameter=city>
1053 Dallas
1054 </parameter>
1055 <parameter=state>
1056 TX
1057 </parameter>
1058 </function>
1059 </tool_call>"""
1060
1061 request = ChatCompletionRequest(model=MODEL, messages=[])
1062
1063 chunks = []
1064 for delta_message in stream_delta_message_generator(
1065 qwen3_tool_parser, qwen3_tokenizer, model_output, request
1066 ):
1067 chunks.append(delta_message)
1068
1069 # Should have multiple chunks
1070 assert len(chunks) > 3
1071
1072 # First chunk(s) should be content
1073 assert chunks[0].content is not None
1074 assert chunks[0].tool_calls is None or chunks[0].tool_calls == []
1075
1076 # Should have a chunk with tool header (id, name, type)
1077 header_found = False
1078 for chunk in chunks:
1079 if chunk.tool_calls and chunk.tool_calls[0].id:
1080 header_found = True
1081 assert chunk.tool_calls[0].function.name == "get_current_weather"
1082 assert chunk.tool_calls[0].type == "function"
1083 break
1084 assert header_found
1085
1086 # Should have chunks with incremental arguments
1087 arg_chunks = []
1088 for chunk in chunks:
1089 if (
1090 chunk.tool_calls
1091 and chunk.tool_calls[0].function
1092 and chunk.tool_calls[0].function.arguments
1093 ):
1094 arg_chunks.append(chunk.tool_calls[0].function.arguments)
1095
1096 # Arguments should be streamed
1097 assert len(arg_chunks) >= 1
1098
1099 # Concatenated arguments should form valid JSON
1100 full_args = "".join(arg_chunks)
1101 parsed_args = json.loads(full_args)
1102 assert parsed_args["city"] == "Dallas"
1103 assert parsed_args["state"] == "TX"
1104
1105
1106 def test_extract_tool_calls_streaming_missing_opening_tag(
1107 qwen3_tool_parser, qwen3_tokenizer
1108 ):
1109 """Test streaming with missing opening <tool_call> tag.
1110
1111 This tests that the streaming parser correctly handles
1112 tool calls that start directly with <function=...>
1113 """
1114 model_output = """I'll check the weather for you.
1115
1116 <function=get_current_weather>
1117 <parameter=city>
1118 Dallas
1119 </parameter>
1120 <parameter=state>
1121 TX
1122 </parameter>
1123 <parameter=unit>
1124 fahrenheit
1125 </parameter>
1126 </function>
1127 </tool_call>"""
1128
1129 request = ChatCompletionRequest(model=MODEL, messages=[])
1130
1131 other_content = ""
1132 tool_states = {}
1133
1134 for delta_message in stream_delta_message_generator(
1135 qwen3_tool_parser, qwen3_tokenizer, model_output, request
1136 ):
1137 if delta_message.content:
1138 other_content += delta_message.content
1139
1140 if delta_message.tool_calls:
1141 for tool_call in delta_message.tool_calls:
1142 idx = tool_call.index
1143
1144 if idx not in tool_states:
1145 tool_states[idx] = {
1146 "id": None,
1147 "name": None,
1148 "arguments": "",
1149 "type": None,
1150 }
1151
1152 if tool_call.id:
1153 tool_states[idx]["id"] = tool_call.id
1154
1155 if tool_call.type:
1156 assert tool_call.type == "function"
1157 tool_states[idx]["type"] = tool_call.type
1158
1159 if tool_call.function:
1160 if tool_call.function.name:
1161 tool_states[idx]["name"] = tool_call.function.name
1162
1163 if tool_call.function.arguments is not None:
1164 tool_states[idx]["arguments"] += tool_call.function.arguments
1165
1166 # Verify content was streamed
1167 assert "I'll check the weather for you." in other_content
1168
1169 # Verify we got the tool call
1170 assert len(tool_states) == 1
1171
1172 state = tool_states[0]
1173 assert state["id"] is not None
1174 assert state["type"] == "function"
1175 assert state["name"] == "get_current_weather"
1176
1177 # Verify arguments were parsed correctly despite missing opening tag
1178 assert state["arguments"] is not None
1179 args = json.loads(state["arguments"])
1180 assert args["city"] == "Dallas"
1181 assert args["state"] == "TX"
1182 assert args["unit"] == "fahrenheit"
1183
1184
1185 def test_malformed_xml_no_gt_delimiter(qwen3_tool_parser):
1186 """Regression: malformed XML without '>' must not crash (PR #36774)."""
1187 model_output = (
1188 "<tool_call>\n"
1189 "<function=get_current_weather\n"
1190 "<parameter=city>Dallas</parameter>\n"
1191 "</function>\n"
1192 "</tool_call>"
1193 )
1194
1195 request = ChatCompletionRequest(model=MODEL, messages=[])
1196 result = qwen3_tool_parser.extract_tool_calls(model_output, request=request)
1197 assert result is not None
1198 assert isinstance(result.tool_calls, list)
1199 assert all(tc is not None for tc in result.tool_calls)
1200
1201
1202 def test_none_tool_calls_filtered(qwen3_tool_parser):
1203 """Regression: None tool calls filtered from output (PR #36774)."""
1204 model_output = (
1205 "<tool_call>\n"
1206 "<function=bad_func_no_gt\n"
1207 "</function>\n"
1208 "</tool_call>\n"
1209 "<tool_call>\n"
1210 "<function=get_current_weather>\n"
1211 "<parameter=city>Dallas</parameter>\n"
1212 "<parameter=state>TX</parameter>\n"
1213 "</function>\n"
1214 "</tool_call>"
1215 )
1216
1217 request = ChatCompletionRequest(model=MODEL, messages=[])
1218 result = qwen3_tool_parser.extract_tool_calls(model_output, request=request)
1219 assert all(tc is not None for tc in result.tool_calls)
1220 assert result.tools_called
1221 valid = [
1222 tc for tc in result.tool_calls if tc.function.name == "get_current_weather"
1223 ]
1224 assert len(valid) == 1
1225 args = json.loads(valid[0].function.arguments)
1226 assert args["city"] == "Dallas"
1227 assert args["state"] == "TX"
1228
1229
1230 def test_anyof_parameter_not_double_encoded(qwen3_tokenizer):
1231 """Regression: anyOf parameters must not be double-encoded (PR #36032)."""
1232 tools = [
1233 ChatCompletionToolsParam(
1234 type="function",
1235 function={
1236 "name": "update_record",
1237 "parameters": {
1238 "type": "object",
1239 "properties": {
1240 "data": {
1241 "anyOf": [{"type": "object"}, {"type": "null"}],
1242 },
1243 },
1244 },
1245 },
1246 )
1247 ]
1248
1249 parser = Qwen3EngineToolParser(qwen3_tokenizer, tools=tools)
1250
1251 model_output = (
1252 "<tool_call>\n"
1253 "<function=update_record>\n"
1254 '<parameter=data>{"key": "value", "count": 42}</parameter>\n'
1255 "</function>\n"
1256 "</tool_call>"
1257 )
1258
1259 request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools)
1260 result = parser.extract_tool_calls(model_output, request=request)
1261
1262 assert result.tools_called
1263 assert len(result.tool_calls) == 1
1264 args = json.loads(result.tool_calls[0].function.arguments)
1265 assert isinstance(args["data"], dict)
1266 assert args["data"] == {"key": "value", "count": 42}
1267
1268
1269 def test_streaming_multi_param_single_chunk(qwen3_tool_parser, qwen3_tokenizer):
1270 """Regression: speculative decode delivering multiple params at once (PR #35615)."""
1271 request = ChatCompletionRequest(model=MODEL, messages=[])
1272
1273 deltas = [
1274 "<tool_call>",
1275 "\n<function=get_current_weather>",
1276 "\n", # triggers json_started -> sends "{"
1277 # This single delta delivers all three parameters at once
1278 "<parameter=city>\nDallas\n</parameter>"
1279 "\n<parameter=state>\nTX\n</parameter>"
1280 "\n<parameter=unit>\nfahrenheit\n</parameter>",
1281 "\n</function>",
1282 "\n</tool_call>",
1283 ]
1284
1285 from tests.tool_parsers.utils import (
1286 run_tool_extraction_streaming,
1287 )
1288
1289 reconstructor = run_tool_extraction_streaming(
1290 qwen3_tool_parser,
1291 deltas,
1292 request,
1293 assert_one_tool_per_delta=False,
1294 )
1295
1296 assert len(reconstructor.tool_calls) == 1
1297 args = json.loads(reconstructor.tool_calls[0].function.arguments)
1298 assert args["city"] == "Dallas"
1299 assert args["state"] == "TX"
1300 assert args["unit"] == "fahrenheit"
1301
1302
1303 def test_streaming_complete_tool_call_single_delta(qwen3_tool_parser):
1304 """Regression: one delta may contain a complete tool call."""
1305 request = ChatCompletionRequest(model=MODEL, messages=[])
1306
1307 from tests.tool_parsers.utils import (
1308 run_tool_extraction_streaming,
1309 )
1310
1311 reconstructor = run_tool_extraction_streaming(
1312 qwen3_tool_parser,
1313 [
1314 (
1315 "<tool_call>\n"
1316 "<function=get_current_weather>\n"
1317 "<parameter=city>\nDallas\n</parameter>\n"
1318 "<parameter=state>\nTX\n</parameter>\n"
1319 "</function>\n"
1320 "</tool_call>"
1321 )
1322 ],
1323 request,
1324 assert_one_tool_per_delta=False,
1325 )
1326
1327 assert len(reconstructor.tool_calls) == 1
1328 assert reconstructor.tool_calls[0].function.name == "get_current_weather"
1329 args = json.loads(reconstructor.tool_calls[0].function.arguments)
1330 assert args == {"city": "Dallas", "state": "TX"}
1331
1332
1333 def test_streaming_next_tool_call_starts_in_close_delta(qwen3_tool_parser):
1334 """Regression: a close delta may also contain the next tool call."""
1335 request = ChatCompletionRequest(model=MODEL, messages=[])
1336
1337 from tests.tool_parsers.utils import (
1338 run_tool_extraction_streaming,
1339 )
1340
1341 reconstructor = run_tool_extraction_streaming(
1342 qwen3_tool_parser,
1343 [
1344 "<tool_call>\n",
1345 "<function=get_current_weather>\n",
1346 "<parameter=city>\nDallas\n</parameter>\n",
1347 "<parameter=state>\nTX\n</parameter>\n",
1348 "</function>",
1349 (
1350 "\n</tool_call>\n"
1351 "<tool_call>\n"
1352 "<function=get_current_weather>\n"
1353 "<parameter=city>\nOrlando\n</parameter>\n"
1354 "<parameter=state>\nFL\n</parameter>\n"
1355 "</function>\n"
1356 "</tool_call>"
1357 ),
1358 ],
1359 request,
1360 assert_one_tool_per_delta=False,
1361 )
1362
1363 assert len(reconstructor.tool_calls) == 2
1364 first_args = json.loads(reconstructor.tool_calls[0].function.arguments)
1365 second_args = json.loads(reconstructor.tool_calls[1].function.arguments)
1366 assert first_args == {"city": "Dallas", "state": "TX"}
1367 assert second_args == {"city": "Orlando", "state": "FL"}
1368
1369
1370 def test_no_double_serialization_string_args(qwen3_tool_parser):
1371 """Regression: string arguments must not be double-serialized (PR #35615)."""
1372 tools = [
1373 ChatCompletionToolsParam(
1374 type="function",
1375 function={
1376 "name": "greet",
1377 "parameters": {
1378 "type": "object",
1379 "properties": {
1380 "message": {"type": "string"},
1381 },
1382 },
1383 },
1384 )
1385 ]
1386
1387 model_output = (
1388 "<tool_call>\n"
1389 "<function=greet>\n"
1390 "<parameter=message>hello world</parameter>\n"
1391 "</function>\n"
1392 "</tool_call>"
1393 )
1394
1395 request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools)
1396 result = qwen3_tool_parser.extract_tool_calls(model_output, request=request)
1397
1398 assert result.tools_called
1399 assert len(result.tool_calls) == 1
1400 raw_arguments = result.tool_calls[0].function.arguments
1401 args = json.loads(raw_arguments)
1402 assert args["message"] == "hello world"
1403 assert '\\"hello world\\"' not in raw_arguments
1404
1405
1406 def test_get_vllm_registry_structural_tag_returns_structural_tag(
1407 qwen3_tool_parser: Qwen3EngineToolParser,
1408 sample_tools: list[ChatCompletionToolsParam],
1409 ) -> None:
1410 request_tools = _as_chat_completion_tools(sample_tools)
1411 strict_tools = _with_strict(request_tools)
1412 req = ChatCompletionRequest(
1413 messages=[],
1414 model="m",
1415 tools=strict_tools,
1416 tool_choice="auto",
1417 )
1418 tag = qwen3_tool_parser.get_structural_tag(req)
1419 assert isinstance(tag, StructuralTag)
1420
1421 req = ChatCompletionRequest(
1422 messages=[],
1423 model="m",
1424 tools=request_tools,
1425 tool_choice="required",
1426 )
1427 tag = qwen3_tool_parser.get_structural_tag(req)
1428 assert isinstance(tag, StructuralTag)
1429
1430 if request_tools:
1431 tool = request_tools[0]
1432 req = ChatCompletionRequest(
1433 messages=[],
1434 model="m",
1435 tools=request_tools,
1436 )
1437 req.tool_choice = ChatCompletionNamedToolChoiceParam(
1438 function=ChatCompletionNamedFunction(name=tool.function.name)
1439 )
1440 tag = qwen3_tool_parser.get_structural_tag(req)
1441 assert isinstance(tag, StructuralTag)
1442
1443
1444 @pytest.mark.parametrize("include_reasoning", [True, False])
1445 def test_adjust_request_auto_uses_vllm_registry_structural_tag(
1446 sample_tools: list[ChatCompletionToolsParam],
1447 include_reasoning: bool,
1448 ) -> None:
1449 class TestParser(DelegatingParser):
1450 tool_parser_cls = Qwen3EngineToolParser
1451
1452 request_tools = _as_chat_completion_tools(sample_tools)
1453 strict_tools = _with_strict(request_tools)
1454 req = ChatCompletionRequest(
1455 messages=[],
1456 model="m",
1457 tools=strict_tools,
1458 tool_choice="auto",
1459 include_reasoning=include_reasoning,
1460 )
1461 out = TestParser(MagicMock(), tools=sample_tools).adjust_request(req)
1462 assert out.structured_outputs is not None
1463 assert out.structured_outputs.structural_tag is not None
1464 assert isinstance(out.structured_outputs.structural_tag, str)
1465 loaded = json.loads(out.structured_outputs.structural_tag)
1466 assert isinstance(loaded, dict)
1467
1468
1469 def test_adjust_request_required_prefers_structural_tag(
1470 sample_tools: list[ChatCompletionToolsParam],
1471 ) -> None:
1472 class TestParser(DelegatingParser):
1473 tool_parser_cls = Qwen3EngineToolParser
1474
1475 request_tools = _as_chat_completion_tools(sample_tools)
1476 req = ChatCompletionRequest(
1477 messages=[],
1478 model="m",
1479 tools=request_tools,
1480 tool_choice="required",
1481 )
1482 out = TestParser(MagicMock(), tools=sample_tools).adjust_request(req)
1483 assert out.structured_outputs is not None
1484 assert out.structured_outputs.structural_tag is not None
1485
1486
1487 WRITE_FILE_TOOLS = [
1488 ChatCompletionToolsParam(
1489 type="function",
1490 function={
1491 "name": "write_file",
1492 "parameters": {
1493 "type": "object",
1494 "properties": {"content": {"type": "string"}},
1495 },
1496 },
1497 )
1498 ]
1499
1500 WRITE_FILE_OUTPUT = (
1501 "<tool_call>\n<function=write_file>\n"
1502 "<parameter=content>\n"
1503 " def foo():\n return 1\n\n"
1504 "</parameter>\n</function>\n</tool_call>"
1505 )
1506
1507 EXPECTED_CONTENT = " def foo():\n return 1\n"
1508
1509
1510 class TestParameterWhitespace:
1511 """Wrapping newlines are markup; the rest of the value is preserved."""
1512
1513 def test_whitespace_preserved(self, qwen3_tokenizer):
1514 parser = Qwen3EngineToolParser(qwen3_tokenizer, tools=WRITE_FILE_TOOLS)
1515 request = ChatCompletionRequest(
1516 model=MODEL, messages=[], tools=WRITE_FILE_TOOLS
1517 )
1518
1519 extracted = parser.extract_tool_calls(WRITE_FILE_OUTPUT, request=request)
1520
1521 args = json.loads(extracted.tool_calls[0].function.arguments)
1522 assert args["content"] == EXPECTED_CONTENT
1523
1524 def test_whitespace_preserved_streaming(self, qwen3_tokenizer):
1525 """The partial path must not strip the value either."""
1526 parser = Qwen3EngineToolParser(qwen3_tokenizer, tools=WRITE_FILE_TOOLS)
1527 request = ChatCompletionRequest(
1528 model=MODEL, messages=[], tools=WRITE_FILE_TOOLS
1529 )
1530
1531 streamed = ""
1532 for delta_message in stream_delta_message_generator(
1533 parser, qwen3_tokenizer, WRITE_FILE_OUTPUT, request
1534 ):
1535 for tool_call in delta_message.tool_calls or []:
1536 if tool_call.function and tool_call.function.arguments:
1537 streamed += tool_call.function.arguments
1538
1539 assert json.loads(streamed)["content"] == EXPECTED_CONTENT