test_qwen3coder_tool_parser.py 25.7 KB
Newer Older
1
2
3
4
5
6
7
8
9
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project

import json
from collections.abc import Generator
from typing import Optional

import pytest

10
11
12
13
14
15
16
from vllm.entrypoints.openai.protocol import (
    ChatCompletionRequest,
    ChatCompletionToolsParam,
    DeltaMessage,
    FunctionCall,
    ToolCall,
)
17
from vllm.entrypoints.openai.tool_parsers.qwen3coder_tool_parser import (
18
19
20
    Qwen3CoderToolParser,
)
from vllm.entrypoints.openai.tool_parsers.qwen3xml_tool_parser import Qwen3XMLToolParser
21
from vllm.transformers_utils.detokenizer_utils import detokenize_incrementally
22
23
from vllm.transformers_utils.tokenizer import AnyTokenizer, get_tokenizer

24
25
pytestmark = pytest.mark.cpu_test

26
MODEL = "Qwen/Qwen3-Coder-30B-A3B-Instruct-FP8"
27
28
29
30
31
32
33
34
35
36
37
38


@pytest.fixture(scope="module")
def qwen3_tokenizer():
    return get_tokenizer(tokenizer_name=MODEL)


@pytest.fixture
def qwen3_tool_parser(qwen3_tokenizer):
    return Qwen3CoderToolParser(qwen3_tokenizer)


39
40
41
42
43
44
@pytest.fixture
def qwen3_xml_tool_parser(qwen3_tokenizer):
    return Qwen3XMLToolParser(qwen3_tokenizer)


@pytest.fixture(params=["original", "xml"])
45
def qwen3_tool_parser_parametrized(qwen3_tool_parser, qwen3_xml_tool_parser, request):
46
47
48
49
50
51
52
    """Parameterized fixture that provides both parser types for testing"""
    if request.param == "original":
        return qwen3_tool_parser
    else:
        return qwen3_xml_tool_parser


53
54
55
@pytest.fixture
def sample_tools():
    return [
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
        ChatCompletionToolsParam(
            type="function",
            function={
                "name": "get_current_weather",
                "description": "Get the current weather",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "city": {"type": "string", "description": "The city name"},
                        "state": {"type": "string", "description": "The state code"},
                        "unit": {"type": "string", "enum": ["fahrenheit", "celsius"]},
                    },
                    "required": ["city", "state"],
                },
            },
        ),
        ChatCompletionToolsParam(
            type="function",
            function={
                "name": "calculate_area",
                "description": "Calculate area of a shape",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "shape": {"type": "string"},
                        "dimensions": {"type": "object"},
                        "precision": {"type": "integer"},
                    },
                },
            },
        ),
87
88
89
    ]


90
91
92
def assert_tool_calls(
    actual_tool_calls: list[ToolCall], expected_tool_calls: list[ToolCall]
):
93
94
    assert len(actual_tool_calls) == len(expected_tool_calls)

95
96
97
    for actual_tool_call, expected_tool_call in zip(
        actual_tool_calls, expected_tool_calls
    ):
98
99
        # Qwen3 parser doesn't generate IDs during extraction
        assert actual_tool_call.type == "function"
100
101
102
103
        assert actual_tool_call.function.name == expected_tool_call.function.name
        assert json.loads(actual_tool_call.function.arguments) == json.loads(
            expected_tool_call.function.arguments
        )
104
105
106


def stream_delta_message_generator(
107
    qwen3_tool_parser,
108
109
    qwen3_tokenizer: AnyTokenizer,
    model_output: str,
110
    request: Optional[ChatCompletionRequest] = None,
111
) -> Generator[DeltaMessage, None, None]:
112
    all_token_ids = qwen3_tokenizer.encode(model_output, add_special_tokens=False)
113
114
115
116
117
118
119
120

    previous_text = ""
    previous_tokens = None
    prefix_offset = 0
    read_offset = 0
    for i, delta_token in enumerate(all_token_ids):
        delta_token_ids = [delta_token]
        previous_token_ids = all_token_ids[:i]
121
122
123
124
125
126
127
128
129
130
131
132
133
        current_token_ids = all_token_ids[: i + 1]

        (new_tokens, delta_text, new_prefix_offset, new_read_offset) = (
            detokenize_incrementally(
                tokenizer=qwen3_tokenizer,
                all_input_ids=current_token_ids,
                prev_tokens=previous_tokens,
                prefix_offset=prefix_offset,
                read_offset=read_offset,
                skip_special_tokens=False,
                spaces_between_special_tokens=True,
            )
        )
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149

        current_text = previous_text + delta_text

        delta_message = qwen3_tool_parser.extract_tool_calls_streaming(
            previous_text,
            current_text,
            delta_text,
            previous_token_ids,
            current_token_ids,
            delta_token_ids,
            request=request,
        )
        if delta_message:
            yield delta_message

        previous_text = current_text
150
151
152
        previous_tokens = (
            previous_tokens + new_tokens if previous_tokens else new_tokens
        )
153
154
155
156
        prefix_offset = new_prefix_offset
        read_offset = new_read_offset


157
def test_extract_tool_calls_no_tools(qwen3_tool_parser_parametrized):
158
    model_output = "This is a test response without any tool calls"
159
    extracted_tool_calls = qwen3_tool_parser_parametrized.extract_tool_calls(
160
161
        model_output, request=None
    )  # type: ignore[arg-type]
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
    assert not extracted_tool_calls.tools_called
    assert extracted_tool_calls.tool_calls == []
    assert extracted_tool_calls.content == model_output


@pytest.mark.parametrize(
    ids=[
        "single_tool",
        "single_tool_with_content",
        "single_tool_multiline_param",
        "parallel_tools",
        "tool_with_typed_params",
    ],
    argnames=["model_output", "expected_tool_calls", "expected_content"],
    argvalues=[
177
178
        (
            """<tool_call>
179
180
181
182
183
184
185
186
187
188
189
<function=get_current_weather>
<parameter=city>
Dallas
</parameter>
<parameter=state>
TX
</parameter>
<parameter=unit>
fahrenheit
</parameter>
</function>
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
</tool_call>""",
            [
                ToolCall(
                    function=FunctionCall(
                        name="get_current_weather",
                        arguments=json.dumps(
                            {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}
                        ),
                    )
                )
            ],
            None,
        ),
        (
            """Sure! Let me check the weather for you.<tool_call>
205
206
207
208
209
210
211
212
213
214
215
<function=get_current_weather>
<parameter=city>
Dallas
</parameter>
<parameter=state>
TX
</parameter>
<parameter=unit>
fahrenheit
</parameter>
</function>
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
</tool_call>""",
            [
                ToolCall(
                    function=FunctionCall(
                        name="get_current_weather",
                        arguments=json.dumps(
                            {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}
                        ),
                    )
                )
            ],
            "Sure! Let me check the weather for you.",
        ),
        (
            """<tool_call>
231
232
233
234
235
236
237
238
239
240
241
242
<function=calculate_area>
<parameter=shape>
rectangle
</parameter>
<parameter=dimensions>
{"width": 10, 
 "height": 20}
</parameter>
<parameter=precision>
2
</parameter>
</function>
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
</tool_call>""",
            [
                ToolCall(
                    function=FunctionCall(
                        name="calculate_area",
                        arguments=json.dumps(
                            {
                                "shape": "rectangle",
                                "dimensions": {"width": 10, "height": 20},
                                "precision": 2,
                            }
                        ),
                    )
                )
            ],
            None,
        ),
        (
            """<tool_call>
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
<function=get_current_weather>
<parameter=city>
Dallas
</parameter>
<parameter=state>
TX
</parameter>
<parameter=unit>
fahrenheit
</parameter>
</function>
</tool_call>
<tool_call>
<function=get_current_weather>
<parameter=city>
Orlando
</parameter>
<parameter=state>
FL
</parameter>
<parameter=unit>
fahrenheit
</parameter>
</function>
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
</tool_call>""",
            [
                ToolCall(
                    function=FunctionCall(
                        name="get_current_weather",
                        arguments=json.dumps(
                            {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}
                        ),
                    )
                ),
                ToolCall(
                    function=FunctionCall(
                        name="get_current_weather",
                        arguments=json.dumps(
                            {"city": "Orlando", "state": "FL", "unit": "fahrenheit"}
                        ),
                    )
                ),
            ],
            None,
        ),
        (
            """Let me calculate that area for you.<tool_call>
309
310
311
312
313
314
315
316
317
318
319
<function=calculate_area>
<parameter=shape>
circle
</parameter>
<parameter=dimensions>
{"radius": 15.5}
</parameter>
<parameter=precision>
3
</parameter>
</function>
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
</tool_call>""",
            [
                ToolCall(
                    function=FunctionCall(
                        name="calculate_area",
                        arguments=json.dumps(
                            {
                                "shape": "circle",
                                "dimensions": {"radius": 15.5},
                                "precision": 3,
                            }
                        ),
                    )
                )
            ],
            "Let me calculate that area for you.",
        ),
337
338
    ],
)
339
340
341
342
343
344
345
346
def test_extract_tool_calls(
    qwen3_tool_parser_parametrized,
    sample_tools,
    model_output,
    expected_tool_calls,
    expected_content,
):
    request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
347
    extracted_tool_calls = qwen3_tool_parser_parametrized.extract_tool_calls(
348
349
        model_output, request=request
    )
350
351
352
353
354
355
356
    assert extracted_tool_calls.tools_called

    assert_tool_calls(extracted_tool_calls.tool_calls, expected_tool_calls)

    assert extracted_tool_calls.content == expected_content


357
358
359
def test_extract_tool_calls_fallback_no_tags(
    qwen3_tool_parser_parametrized, sample_tools
):
360
    """Test fallback parsing when XML tags are missing"""
361
    model_output = """<function=get_current_weather>
362
363
364
365
366
367
<parameter=city>
Dallas
</parameter>
<parameter=state>
TX
</parameter>
368
</function>"""
369

370
    request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
371
    extracted_tool_calls = qwen3_tool_parser_parametrized.extract_tool_calls(
372
373
        model_output, request=request
    )
374
375
376

    assert extracted_tool_calls.tools_called
    assert len(extracted_tool_calls.tool_calls) == 1
377
    assert extracted_tool_calls.tool_calls[0].function.name == "get_current_weather"
378
379


380
def test_extract_tool_calls_type_conversion(qwen3_tool_parser_parametrized):
381
382
    """Test parameter type conversion based on tool schema"""
    tools = [
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
        ChatCompletionToolsParam(
            type="function",
            function={
                "name": "test_types",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "int_param": {"type": "integer"},
                        "float_param": {"type": "float"},
                        "bool_param": {"type": "boolean"},
                        "str_param": {"type": "string"},
                        "obj_param": {"type": "object"},
                    },
                },
            },
        )
399
400
    ]

401
    model_output = """<tool_call>
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
<function=test_types>
<parameter=int_param>
42
</parameter>
<parameter=float_param>
3.14
</parameter>
<parameter=bool_param>
true
</parameter>
<parameter=str_param>
hello world
</parameter>
<parameter=obj_param>
{"key": "value"}
</parameter>
</function>
419
</tool_call>"""
420
421

    request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools)
422
    extracted_tool_calls = qwen3_tool_parser_parametrized.extract_tool_calls(
423
424
        model_output, request=request
    )
425
426
427
428
429
430
431
432
433
434
435
436
437
438

    args = json.loads(extracted_tool_calls.tool_calls[0].function.arguments)
    assert args["int_param"] == 42
    assert args["float_param"] == 3.14
    assert args["bool_param"] is True
    assert args["str_param"] == "hello world"
    assert args["obj_param"] == {"key": "value"}


@pytest.mark.parametrize(
    ids=[
        "no_tools",
        "single_tool",
        "single_tool_with_content",
439
        "single_tool_multiline_param",
440
        "parallel_tools",
441
        "tool_with_typed_params",  # Added this test case
442
443
444
445
    ],
    argnames=["model_output", "expected_tool_calls", "expected_content"],
    argvalues=[
        ("This is a test without tools", [], "This is a test without tools"),
446
447
        (
            """<tool_call>
448
449
450
451
452
453
454
455
456
457
458
<function=get_current_weather>
<parameter=city>
Dallas
</parameter>
<parameter=state>
TX
</parameter>
<parameter=unit>
fahrenheit
</parameter>
</function>
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
</tool_call>""",
            [
                ToolCall(
                    function=FunctionCall(
                        name="get_current_weather",
                        arguments=json.dumps(
                            {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}
                        ),
                    )
                )
            ],
            None,
        ),
        (
            """Sure! Let me check the weather for you.<tool_call>
474
475
476
477
478
479
480
481
482
483
484
<function=get_current_weather>
<parameter=city>
Dallas
</parameter>
<parameter=state>
TX
</parameter>
<parameter=unit>
fahrenheit
</parameter>
</function>
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
</tool_call>""",
            [
                ToolCall(
                    function=FunctionCall(
                        name="get_current_weather",
                        arguments=json.dumps(
                            {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}
                        ),
                    )
                )
            ],
            "Sure! Let me check the weather for you.",
        ),
        (
            """<tool_call>
500
501
502
503
504
505
506
507
508
509
510
511
<function=calculate_area>
<parameter=shape>
rectangle
</parameter>
<parameter=dimensions>
{"width": 10, 
 "height": 20}
</parameter>
<parameter=precision>
2
</parameter>
</function>
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
</tool_call>""",
            [
                ToolCall(
                    function=FunctionCall(
                        name="calculate_area",
                        arguments=json.dumps(
                            {
                                "shape": "rectangle",
                                "dimensions": {"width": 10, "height": 20},
                                "precision": 2,
                            }
                        ),
                    )
                )
            ],
            None,
        ),
        (
            """<tool_call>
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
<function=get_current_weather>
<parameter=city>
Dallas
</parameter>
<parameter=state>
TX
</parameter>
<parameter=unit>
fahrenheit
</parameter>
</function>
</tool_call>
<tool_call>
<function=get_current_weather>
<parameter=city>
Orlando
</parameter>
<parameter=state>
FL
</parameter>
<parameter=unit>
celsius
</parameter>
</function>
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
</tool_call>""",
            [
                ToolCall(
                    function=FunctionCall(
                        name="get_current_weather",
                        arguments=json.dumps(
                            {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}
                        ),
                    )
                ),
                ToolCall(
                    function=FunctionCall(
                        name="get_current_weather",
                        arguments=json.dumps(
                            {"city": "Orlando", "state": "FL", "unit": "celsius"}
                        ),
                    )
                ),
            ],
            None,
        ),
576
        # Added tool_with_typed_params test case
577
578
        (
            """Let me calculate that area for you.<tool_call>
579
580
581
582
583
584
585
586
587
588
589
<function=calculate_area>
<parameter=shape>
circle
</parameter>
<parameter=dimensions>
{"radius": 15.5}
</parameter>
<parameter=precision>
3
</parameter>
</function>
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
</tool_call>""",
            [
                ToolCall(
                    function=FunctionCall(
                        name="calculate_area",
                        arguments=json.dumps(
                            {
                                "shape": "circle",
                                "dimensions": {"radius": 15.5},
                                "precision": 3,
                            }
                        ),
                    )
                )
            ],
            "Let me calculate that area for you.",
        ),
607
608
    ],
)
609
610
611
612
613
614
615
616
def test_extract_tool_calls_streaming(
    qwen3_tool_parser_parametrized,
    qwen3_tokenizer,
    sample_tools,
    model_output,
    expected_tool_calls,
    expected_content,
):
617
    """Test incremental streaming behavior including typed parameters"""
618
    request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
619

620
    other_content = ""
621
622
623
    tool_states = {}  # Track state per tool index

    for delta_message in stream_delta_message_generator(
624
625
        qwen3_tool_parser_parametrized, qwen3_tokenizer, model_output, request
    ):
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
        # role should never be streamed from tool parser
        assert not delta_message.role

        if delta_message.content:
            other_content += delta_message.content

        if delta_message.tool_calls:
            for tool_call in delta_message.tool_calls:
                idx = tool_call.index

                # Initialize state for new tool
                if idx not in tool_states:
                    tool_states[idx] = {
                        "id": None,
                        "name": None,
                        "arguments": "",
642
                        "type": None,
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
                    }

                # First chunk should have id, name, and type
                if tool_call.id:
                    tool_states[idx]["id"] = tool_call.id

                if tool_call.type:
                    assert tool_call.type == "function"
                    tool_states[idx]["type"] = tool_call.type

                if tool_call.function:
                    if tool_call.function.name:
                        # Should only be set once
                        assert tool_states[idx]["name"] is None
                        tool_states[idx]["name"] = tool_call.function.name

                    if tool_call.function.arguments is not None:
                        # Accumulate arguments incrementally
661
                        tool_states[idx]["arguments"] += tool_call.function.arguments
662
663

    # Verify final content
664
    assert other_content == (expected_content or "")  # Handle None case
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683

    # Verify we got all expected tool calls
    assert len(tool_states) == len(expected_tool_calls)

    # Verify each tool call
    for idx, expected_tool in enumerate(expected_tool_calls):
        state = tool_states[idx]
        assert state["id"] is not None
        assert state["type"] == "function"
        assert state["name"] == expected_tool.function.name

        # Parse accumulated arguments
        arguments_str = state["arguments"]
        assert arguments_str is not None
        actual_args = json.loads(arguments_str)
        expected_args = json.loads(expected_tool.function.arguments)
        assert actual_args == expected_args


684
def test_extract_tool_calls_missing_closing_parameter_tag(
685
686
    qwen3_tool_parser_parametrized, sample_tools
):
687
688
    """Test handling of missing closing </parameter> tag"""
    # Using get_current_weather from sample_tools but with malformed XML
689
    model_output = """Let me check the weather for you:
690
691
692
693
694
695
696
697
698
699
700
<tool_call>
<function=get_current_weather>
<parameter=city>
Dallas
<parameter=state>
TX
</parameter>
<parameter=unit>
fahrenheit
</parameter>
</function>
701
</tool_call>"""
702

703
    request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
704
    extracted_tool_calls = qwen3_tool_parser_parametrized.extract_tool_calls(
705
706
        model_output, request=request
    )
707
708
709
710
711
712

    # The parser should handle the malformed XML gracefully
    assert extracted_tool_calls.tools_called
    assert len(extracted_tool_calls.tool_calls) == 1

    # Verify the function name is correct
713
    assert extracted_tool_calls.tool_calls[0].function.name == "get_current_weather"
714
715
716
717
718
719
720
721
722
723
724
725
726

    # Verify the arguments are parsed despite the missing closing tag
    args = json.loads(extracted_tool_calls.tool_calls[0].function.arguments)
    assert "city" in args
    assert args["city"] == "Dallas"
    assert args["state"] == "TX"
    assert args["unit"] == "fahrenheit"

    # Check that content before the tool call is preserved
    assert "Let me check the weather for you:" in extracted_tool_calls.content


def test_extract_tool_calls_streaming_missing_closing_tag(
727
728
    qwen3_tool_parser_parametrized, qwen3_tokenizer, sample_tools
):
729
730
    """Test streaming with missing closing </parameter> tag"""
    # Using get_current_weather from sample_tools but with malformed XML
731
    model_output = """Let me check the weather for you:
732
733
734
735
736
737
738
739
740
741
742
<tool_call>
<function=get_current_weather>
<parameter=city>
Dallas
<parameter=state>
TX
</parameter>
<parameter=unit>
fahrenheit
</parameter>
</function>
743
</tool_call>"""
744

745
    request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
746

747
    other_content = ""
748
749
750
    tool_states = {}

    for delta_message in stream_delta_message_generator(
751
752
        qwen3_tool_parser_parametrized, qwen3_tokenizer, model_output, request
    ):
753
754
755
756
757
758
759
760
761
762
763
764
        if delta_message.content:
            other_content += delta_message.content

        if delta_message.tool_calls:
            for tool_call in delta_message.tool_calls:
                idx = tool_call.index

                if idx not in tool_states:
                    tool_states[idx] = {
                        "id": None,
                        "name": None,
                        "arguments": "",
765
                        "type": None,
766
767
768
769
770
771
772
773
774
775
776
777
778
779
                    }

                if tool_call.id:
                    tool_states[idx]["id"] = tool_call.id

                if tool_call.type:
                    assert tool_call.type == "function"
                    tool_states[idx]["type"] = tool_call.type

                if tool_call.function:
                    if tool_call.function.name:
                        tool_states[idx]["name"] = tool_call.function.name

                    if tool_call.function.arguments is not None:
780
                        tool_states[idx]["arguments"] += tool_call.function.arguments
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799

    # Verify content was streamed
    assert "Let me check the weather for you:" in other_content

    # Verify we got the tool call
    assert len(tool_states) == 1
    state = tool_states[0]
    assert state["id"] is not None
    assert state["type"] == "function"
    assert state["name"] == "get_current_weather"

    # Verify arguments were parsed correctly despite missing closing tag
    assert state["arguments"] is not None
    args = json.loads(state["arguments"])
    assert args["city"] == "Dallas"
    assert args["state"] == "TX"
    assert args["unit"] == "fahrenheit"


800
def test_extract_tool_calls_streaming_incremental(
801
802
    qwen3_tool_parser_parametrized, qwen3_tokenizer, sample_tools
):
803
    """Test that streaming is truly incremental"""
804
    model_output = """I'll check the weather.<tool_call>
805
806
807
808
809
810
811
812
<function=get_current_weather>
<parameter=city>
Dallas
</parameter>
<parameter=state>
TX
</parameter>
</function>
813
</tool_call>"""
814

815
    request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
816
817
818

    chunks = []
    for delta_message in stream_delta_message_generator(
819
820
        qwen3_tool_parser_parametrized, qwen3_tokenizer, model_output, request
    ):
821
822
823
824
825
826
827
828
829
830
831
832
833
834
        chunks.append(delta_message)

    # Should have multiple chunks
    assert len(chunks) > 3

    # First chunk(s) should be content
    assert chunks[0].content is not None
    assert chunks[0].tool_calls is None or chunks[0].tool_calls == []

    # Should have a chunk with tool header (id, name, type)
    header_found = False
    for chunk in chunks:
        if chunk.tool_calls and chunk.tool_calls[0].id:
            header_found = True
835
            assert chunk.tool_calls[0].function.name == "get_current_weather"
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
            assert chunk.tool_calls[0].type == "function"
            # Empty initially
            assert chunk.tool_calls[0].function.arguments == ""
            break
    assert header_found

    # Should have chunks with incremental arguments
    arg_chunks = []
    for chunk in chunks:
        if chunk.tool_calls and chunk.tool_calls[0].function.arguments:
            arg_chunks.append(chunk.tool_calls[0].function.arguments)

    # Arguments should be streamed incrementally
    assert len(arg_chunks) > 1

    # Concatenated arguments should form valid JSON
    full_args = "".join(arg_chunks)
    parsed_args = json.loads(full_args)
    assert parsed_args["city"] == "Dallas"
    assert parsed_args["state"] == "TX"
856
857
858


def test_extract_tool_calls_complex_type_with_single_quote(
859
860
    qwen3_tool_parser_parametrized,
):
861
862
    """Test parameter type conversion based on tool schema"""
    tools = [
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
        ChatCompletionToolsParam(
            type="function",
            function={
                "name": "test_types",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "int_param": {"type": "integer"},
                        "float_param": {"type": "float"},
                        "bool_param": {"type": "boolean"},
                        "str_param": {"type": "string"},
                        "obj_param": {"type": "object"},
                    },
                },
            },
        )
879
880
    ]

881
    model_output = """<tool_call>
882
883
884
885
886
<function=test_types>
<parameter=obj_param>
{'key': 'value'}
</parameter>
</function>
887
</tool_call>"""
888
889
890

    request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools)
    extracted_tool_calls = qwen3_tool_parser_parametrized.extract_tool_calls(
891
892
        model_output, request=request
    )
893
894
895

    args = json.loads(extracted_tool_calls.tool_calls[0].function.arguments)
    assert args["obj_param"] == {"key": "value"}