main
py 231 lines 7.02 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 from unittest.mock import MagicMock, patch
5
6 import pytest
7
8 from tests.tool_parsers.utils import (
9 run_tool_extraction,
10 run_tool_extraction_streaming,
11 )
12 from vllm.entrypoints.generate.base.protocol import FunctionCall
13 from vllm.tokenizers import TokenizerLike
14 from vllm.tool_parsers import ToolParser, ToolParserManager
15
16 # https://github.com/meta-llama/llama-models/blob/main/models/llama3_2/text_prompt_format.md#model-response-format-1
17 SIMPLE_FUNCTION_OUTPUT = "get_weather(city='San Francisco', metric='celsius')"
18 SIMPLE_FUNCTION_CALL = FunctionCall(
19 name="get_weather",
20 arguments='{"city": "San Francisco", "metric": "celsius"}',
21 )
22 MORE_TYPES_FUNCTION_OUTPUT = (
23 "register_user(name='John Doe', "
24 "age=37, "
25 "address={'city': 'San Francisco', 'state': 'CA'}, "
26 "role=None, "
27 "passed_test=True, "
28 "aliases=['John', 'Johnny'])"
29 )
30 MORE_TYPES_FUNCTION_CALL = FunctionCall(
31 name="register_user",
32 arguments='{"name": "John Doe", '
33 '"age": 37, '
34 '"address": {"city": "San Francisco", "state": "CA"}, '
35 '"role": null, '
36 '"passed_test": true, '
37 '"aliases": ["John", "Johnny"]}',
38 )
39 PARAMETERLESS_FUNCTION_OUTPUT = "get_weather()"
40 PARAMETERLESS_FUNCTION_CALL = FunctionCall(
41 name="get_weather",
42 arguments="{}",
43 )
44 EMPTY_DICT_FUNCTION_OUTPUT = "do_something_cool(additional_data={})"
45 EMPTY_DICT_FUNCTION_CALL = FunctionCall(
46 name="do_something_cool",
47 arguments='{"additional_data": {}}',
48 )
49 EMPTY_LIST_FUNCTION_OUTPUT = "do_something_cool(steps=[])"
50 EMPTY_LIST_FUNCTION_CALL = FunctionCall(
51 name="do_something_cool",
52 arguments='{"steps": []}',
53 )
54 ESCAPED_STRING_FUNCTION_OUTPUT = (
55 r"get_weather(city='Martha\'s Vineyard', metric='\"cool units\"')"
56 )
57 ESCAPED_STRING_FUNCTION_CALL = FunctionCall(
58 name="get_weather",
59 arguments='{"city": "Martha\'s Vineyard", "metric": "\\"cool units\\""}',
60 )
61
62
63 @pytest.mark.parametrize("streaming", [True, False])
64 def test_no_tool_call(streaming: bool, default_tokenizer: TokenizerLike):
65 tool_parser: ToolParser = ToolParserManager.get_tool_parser("pythonic")(
66 default_tokenizer
67 )
68 model_output = "How can I help you today?"
69
70 content, tool_calls = run_tool_extraction(
71 tool_parser, model_output, streaming=streaming
72 )
73
74 assert content == model_output
75 assert len(tool_calls) == 0
76
77
78 TEST_CASES = [
79 pytest.param(
80 True,
81 f"[{SIMPLE_FUNCTION_OUTPUT}]",
82 [SIMPLE_FUNCTION_CALL],
83 id="simple_streaming",
84 ),
85 pytest.param(
86 False,
87 f"[{SIMPLE_FUNCTION_OUTPUT}]",
88 [SIMPLE_FUNCTION_CALL],
89 id="simple_nonstreaming",
90 ),
91 pytest.param(
92 True,
93 f"[{MORE_TYPES_FUNCTION_OUTPUT}]",
94 [MORE_TYPES_FUNCTION_CALL],
95 id="more_types_streaming",
96 ),
97 pytest.param(
98 False,
99 f"[{MORE_TYPES_FUNCTION_OUTPUT}]",
100 [MORE_TYPES_FUNCTION_CALL],
101 id="more_types_nonstreaming",
102 ),
103 pytest.param(
104 True,
105 f"[{PARAMETERLESS_FUNCTION_OUTPUT}]",
106 [PARAMETERLESS_FUNCTION_CALL],
107 id="parameterless_streaming",
108 ),
109 pytest.param(
110 False,
111 f"[{PARAMETERLESS_FUNCTION_OUTPUT}]",
112 [PARAMETERLESS_FUNCTION_CALL],
113 id="parameterless_nonstreaming",
114 ),
115 pytest.param(
116 True,
117 f"[{EMPTY_DICT_FUNCTION_OUTPUT}]",
118 [EMPTY_DICT_FUNCTION_CALL],
119 id="empty_dict_streaming",
120 ),
121 pytest.param(
122 False,
123 f"[{EMPTY_DICT_FUNCTION_OUTPUT}]",
124 [EMPTY_DICT_FUNCTION_CALL],
125 id="empty_dict_nonstreaming",
126 ),
127 pytest.param(
128 True,
129 f"[{EMPTY_LIST_FUNCTION_OUTPUT}]",
130 [EMPTY_LIST_FUNCTION_CALL],
131 id="empty_list_streaming",
132 ),
133 pytest.param(
134 False,
135 f"[{EMPTY_LIST_FUNCTION_OUTPUT}]",
136 [EMPTY_LIST_FUNCTION_CALL],
137 id="empty_list_nonstreaming",
138 ),
139 pytest.param(
140 True,
141 f"[{ESCAPED_STRING_FUNCTION_OUTPUT}]",
142 [ESCAPED_STRING_FUNCTION_CALL],
143 id="escaped_string_streaming",
144 ),
145 pytest.param(
146 False,
147 f"[{ESCAPED_STRING_FUNCTION_OUTPUT}]",
148 [ESCAPED_STRING_FUNCTION_CALL],
149 id="escaped_string_nonstreaming",
150 ),
151 pytest.param(
152 True,
153 f"[{SIMPLE_FUNCTION_OUTPUT}, {MORE_TYPES_FUNCTION_OUTPUT}]",
154 [SIMPLE_FUNCTION_CALL, MORE_TYPES_FUNCTION_CALL],
155 id="parallel_calls_streaming",
156 ),
157 pytest.param(
158 False,
159 f"[{SIMPLE_FUNCTION_OUTPUT}, {MORE_TYPES_FUNCTION_OUTPUT}]",
160 [SIMPLE_FUNCTION_CALL, MORE_TYPES_FUNCTION_CALL],
161 id="parallel_calls_nonstreaming",
162 ),
163 ]
164
165
166 @pytest.mark.parametrize("streaming, model_output, expected_tool_calls", TEST_CASES)
167 def test_tool_call(
168 streaming: bool,
169 model_output: str,
170 expected_tool_calls: list[FunctionCall],
171 default_tokenizer: TokenizerLike,
172 ):
173 tool_parser: ToolParser = ToolParserManager.get_tool_parser("pythonic")(
174 default_tokenizer
175 )
176
177 content, tool_calls = run_tool_extraction(
178 tool_parser, model_output, streaming=streaming
179 )
180
181 assert content is None
182 assert len(tool_calls) == len(expected_tool_calls)
183 for actual, expected in zip(tool_calls, expected_tool_calls):
184 assert actual.type == "function"
185 assert actual.function == expected
186
187
188 def test_streaming_tool_call_with_large_steps(default_tokenizer: TokenizerLike):
189 tool_parser: ToolParser = ToolParserManager.get_tool_parser("pythonic")(
190 default_tokenizer
191 )
192 model_output_deltas = [
193 "[get_weather(city='San",
194 " Francisco', metric='celsius'), "
195 f"{PARAMETERLESS_FUNCTION_OUTPUT}, "
196 f"{EMPTY_LIST_FUNCTION_OUTPUT}]",
197 ]
198
199 reconstructor = run_tool_extraction_streaming(
200 tool_parser, model_output_deltas, assert_one_tool_per_delta=False
201 )
202
203 assert reconstructor.other_content == ""
204 assert len(reconstructor.tool_calls) == 3
205 assert reconstructor.tool_calls[0].function == SIMPLE_FUNCTION_CALL
206 assert reconstructor.tool_calls[1].function == PARAMETERLESS_FUNCTION_CALL
207 assert reconstructor.tool_calls[2].function == EMPTY_LIST_FUNCTION_CALL
208
209
210 @pytest.mark.parametrize("streaming", [False])
211 def test_regex_timeout_handling(streaming: bool, default_tokenizer: TokenizerLike):
212 """Test regex timeout is handled gracefully."""
213 tool_parser: ToolParser = ToolParserManager.get_tool_parser("pythonic")(
214 default_tokenizer
215 )
216
217 fake_problematic_input = "hello world[A(A=" + "\t)A(A=,\t" * 2
218
219 # create a mock regex that raises TimeoutError
220 mock_regex = MagicMock()
221 mock_regex.match.side_effect = TimeoutError("Regex timeout")
222
223 with patch.object(tool_parser, "TOOL_CALL_REGEX", mock_regex):
224 content, tool_calls = run_tool_extraction(
225 tool_parser, fake_problematic_input, streaming=streaming
226 )
227
228 # should treat as regular text when regex times out
229 assert content == fake_problematic_input
230 assert len(tool_calls) == 0
231 mock_regex.match.assert_called_once()