| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | |
| 4 | import json |
| 5 | |
| 6 | import pytest |
| 7 | |
| 8 | from tests.parser.engine.replay_harness import ( |
| 9 | DUMMY_TOOLS, |
| 10 | MockTokenizer, |
| 11 | _test_request, |
| 12 | collect_output, |
| 13 | replay_streaming, |
| 14 | ) |
| 15 | from vllm.parser.deepseek_v41 import deepseek_v41_config |
| 16 | from vllm.parser.parser_manager import ParserManager |
| 17 | |
| 18 | CALLS = ( |
| 19 | '\n\n<|DSML| calls>\n<|DSML| invoke name="get_weather">\n' |
| 20 | '<|DSML| parameter name="city" string="true">杭州</|DSML| parameter>\n' |
| 21 | '<|DSML| parameter name="count" string="false">42</|DSML| parameter>\n' |
| 22 | '</|DSML| invoke>\n<|DSML| invoke name="add">\n' |
| 23 | '<|DSML| parameter name="x" string="false">1.5</|DSML| parameter>\n' |
| 24 | '<|DSML| parameter name="y" string="false">2.25</|DSML| parameter>\n' |
| 25 | "</|DSML| invoke>\n</|DSML| calls>" |
| 26 | ) |
| 27 | |
| 28 | |
| 29 | def tokenizer_for(text, special_calls): |
| 30 | vocab = {"<think>": 50, "</think>": 51} |
| 31 | if special_calls: |
| 32 | vocab |= {"<|DSML| calls>": 52, "</|DSML| calls>": 53} |
| 33 | tokens: list[tuple[int, str]] = [] |
| 34 | while text: |
| 35 | special = next((marker for marker in vocab if text.startswith(marker)), None) |
| 36 | piece = special or text[0] |
| 37 | tokens.append((vocab[piece] if special else 100 + len(tokens), piece)) |
| 38 | text = text[len(piece) :] |
| 39 | return MockTokenizer(vocab, tokens), tokens |
| 40 | |
| 41 | |
| 42 | def parser_for(tokenizer, controls): |
| 43 | cls = ParserManager.get_parser( |
| 44 | tool_parser_name="deepseek_v41", |
| 45 | reasoning_parser_name="deepseek_v41", |
| 46 | enable_auto_tools=True, |
| 47 | ) |
| 48 | return cls(tokenizer, chat_template_kwargs=controls) |
| 49 | |
| 50 | |
| 51 | @pytest.mark.parametrize("thinking", [False, True]) |
| 52 | @pytest.mark.parametrize("special_calls", [False, True]) |
| 53 | @pytest.mark.parametrize("chunk_size", [1, 7, 10000]) |
| 54 | def test_registered_adapters_parse_parallel_calls_across_chunks( |
| 55 | thinking, |
| 56 | special_calls, |
| 57 | chunk_size, |
| 58 | ): |
| 59 | text = ("Plan.</think>" if thinking else "") + "Checking." + CALLS |
| 60 | tokenizer, tokens = tokenizer_for(text, special_calls) |
| 61 | parser = parser_for(tokenizer, {"thinking": thinking}) |
| 62 | output = collect_output( |
| 63 | replay_streaming( |
| 64 | parser, |
| 65 | tokens, |
| 66 | chunk_size=chunk_size, |
| 67 | finished_on_last=True, |
| 68 | tools=DUMMY_TOOLS, |
| 69 | prompt_token_ids=[50 if thinking else 51], |
| 70 | ) |
| 71 | ) |
| 72 | assert output.reasoning == ("Plan." if thinking else "") |
| 73 | assert output.content.strip() == "Checking." |
| 74 | assert [call["name"] for call in output.tool_calls] == ["get_weather", "add"] |
| 75 | assert [json.loads(call["arguments"]) for call in output.tool_calls] == [ |
| 76 | {"city": "杭州", "count": 42}, |
| 77 | {"x": 1.5, "y": 2.25}, |
| 78 | ] |
| 79 | |
| 80 | |
| 81 | @pytest.mark.parametrize("thinking", [False, True]) |
| 82 | def test_registered_adapters_parse_complete_output(thinking): |
| 83 | text = ("Plan.</think>" if thinking else "") + CALLS |
| 84 | tokenizer, tokens = tokenizer_for(text, False) |
| 85 | parser = parser_for(tokenizer, {"thinking": thinking}) |
| 86 | reasoning, content, calls = parser.parse( |
| 87 | text, |
| 88 | _test_request(tools=DUMMY_TOOLS), |
| 89 | enable_auto_tools=True, |
| 90 | model_output_token_ids=[tid for tid, _ in tokens], |
| 91 | ) |
| 92 | assert (reasoning or "") == ("Plan." if thinking else "") |
| 93 | assert not (content or "").strip() |
| 94 | assert [call.name for call in calls] == ["get_weather", "add"] |
| 95 | assert json.loads(calls[0].arguments) == {"city": "杭州", "count": 42} |
| 96 | |
| 97 | |
| 98 | @pytest.mark.parametrize( |
| 99 | ("controls", "text", "expected_reasoning", "reasoning_tokens"), |
| 100 | [ |
| 101 | ({"thinking": False}, "12", "", 0), |
| 102 | ({"enable_thinking": False}, "12", "", 0), |
| 103 | ({"thinking": True, "reasoning_effort": "none"}, "12", "", 0), |
| 104 | ({}, "</think>12", "", 0), |
| 105 | ({}, "Plan.</think>12", "Plan.", 5), |
| 106 | ], |
| 107 | ) |
| 108 | def test_reasoning_adapter_controls_and_usage( |
| 109 | controls, |
| 110 | text, |
| 111 | expected_reasoning, |
| 112 | reasoning_tokens, |
| 113 | ): |
| 114 | tokenizer, tokens = tokenizer_for(text, False) |
| 115 | parser = parser_for(tokenizer, controls) |
| 116 | output = collect_output( |
| 117 | replay_streaming( |
| 118 | parser, |
| 119 | tokens, |
| 120 | chunk_size=1, |
| 121 | finished_on_last=True, |
| 122 | ) |
| 123 | ) |
| 124 | assert output.reasoning == expected_reasoning |
| 125 | assert output.content == "12" |
| 126 | assert ( |
| 127 | parser.reasoning_parser.count_reasoning_tokens([tid for tid, _ in tokens]) |
| 128 | == reasoning_tokens |
| 129 | ) |
| 130 | assert parser.is_reasoning_end([50, 100, 51]) |
| 131 | assert not parser.is_reasoning_end([51, 100, 50]) |
| 132 | |
| 133 | |
| 134 | def test_python_argument_conversion_and_partial_values(): |
| 135 | converter = deepseek_v41_config().arg_converter |
| 136 | raw = ( |
| 137 | '<|DSML| parameter name="object" string="false">' |
| 138 | '{"a": [true, null]}</|DSML| parameter>' |
| 139 | '<|DSML| parameter name="bad" string="false">[broken</|DSML| parameter>' |
| 140 | '<|DSML| parameter name="bare">7</|DSML| parameter>' |
| 141 | '<|DSML| parameter name="text" string="true"> a<b' |
| 142 | ) |
| 143 | assert json.loads(converter(raw, True)) == { |
| 144 | "object": {"a": [True, None]}, |
| 145 | "bad": "[broken", |
| 146 | "bare": 7, |
| 147 | "text": " a<b", |
| 148 | } |