main
py 251 lines 8.11 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_OUTPUT_JSON_LITERALS = (
31 "register_user(name='John Doe', "
32 "age=37, "
33 "address={'city': 'San Francisco', 'state': 'CA'}, "
34 "role=null, "
35 "passed_test=true, "
36 "aliases=['John', 'Johnny'])"
37 )
38 MORE_TYPES_FUNCTION_CALL = FunctionCall(
39 name="register_user",
40 arguments='{"name": "John Doe", '
41 '"age": 37, '
42 '"address": {"city": "San Francisco", "state": "CA"}, '
43 '"role": null, '
44 '"passed_test": true, '
45 '"aliases": ["John", "Johnny"]}',
46 )
47 PARAMETERLESS_FUNCTION_OUTPUT = "get_weather()"
48 PARAMETERLESS_FUNCTION_CALL = FunctionCall(
49 name="get_weather",
50 arguments="{}",
51 )
52 EMPTY_DICT_FUNCTION_OUTPUT = "do_something_cool(additional_data={})"
53 EMPTY_DICT_FUNCTION_CALL = FunctionCall(
54 name="do_something_cool",
55 arguments='{"additional_data": {}}',
56 )
57 EMPTY_LIST_FUNCTION_OUTPUT = "do_something_cool(steps=[])"
58 EMPTY_LIST_FUNCTION_CALL = FunctionCall(
59 name="do_something_cool",
60 arguments='{"steps": []}',
61 )
62 ESCAPED_STRING_FUNCTION_OUTPUT = (
63 r"get_weather(city='Martha\'s Vineyard', metric='\"cool units\"')"
64 )
65 ESCAPED_STRING_FUNCTION_CALL = FunctionCall(
66 name="get_weather",
67 arguments='{"city": "Martha\'s Vineyard", "metric": "\\"cool units\\""}',
68 )
69
70
71 @pytest.mark.parametrize("streaming", [True, False])
72 def test_no_tool_call(streaming: bool, default_tokenizer: TokenizerLike):
73 tool_parser: ToolParser = ToolParserManager.get_tool_parser("olmo3")(
74 default_tokenizer
75 )
76 model_output = "How can I help you today?"
77
78 content, tool_calls = run_tool_extraction(
79 tool_parser, model_output, streaming=streaming
80 )
81
82 assert content == model_output
83 assert len(tool_calls) == 0
84
85
86 TEST_CASES = [
87 pytest.param(
88 True,
89 f"<function_calls>{SIMPLE_FUNCTION_OUTPUT}</function_calls>",
90 [SIMPLE_FUNCTION_CALL],
91 id="simple_streaming",
92 ),
93 pytest.param(
94 False,
95 f"<function_calls>{SIMPLE_FUNCTION_OUTPUT}</function_calls>",
96 [SIMPLE_FUNCTION_CALL],
97 id="simple_nonstreaming",
98 ),
99 pytest.param(
100 True,
101 f"<function_calls>{MORE_TYPES_FUNCTION_OUTPUT}</function_calls>",
102 [MORE_TYPES_FUNCTION_CALL],
103 id="more_types_streaming",
104 ),
105 pytest.param(
106 False,
107 f"<function_calls>{MORE_TYPES_FUNCTION_OUTPUT}</function_calls>",
108 [MORE_TYPES_FUNCTION_CALL],
109 id="more_types_nonstreaming",
110 ),
111 pytest.param(
112 True,
113 f"<function_calls>{MORE_TYPES_FUNCTION_OUTPUT_JSON_LITERALS}</function_calls>",
114 [MORE_TYPES_FUNCTION_CALL],
115 id="more_types_streaming_json_literals",
116 ),
117 pytest.param(
118 False,
119 f"<function_calls>{MORE_TYPES_FUNCTION_OUTPUT_JSON_LITERALS}</function_calls>",
120 [MORE_TYPES_FUNCTION_CALL],
121 id="more_types_nonstreaming_json_literals",
122 ),
123 pytest.param(
124 True,
125 f"<function_calls>{PARAMETERLESS_FUNCTION_OUTPUT}</function_calls>",
126 [PARAMETERLESS_FUNCTION_CALL],
127 id="parameterless_streaming",
128 ),
129 pytest.param(
130 False,
131 f"<function_calls>{PARAMETERLESS_FUNCTION_OUTPUT}</function_calls>",
132 [PARAMETERLESS_FUNCTION_CALL],
133 id="parameterless_nonstreaming",
134 ),
135 pytest.param(
136 True,
137 f"<function_calls>{EMPTY_DICT_FUNCTION_OUTPUT}</function_calls>",
138 [EMPTY_DICT_FUNCTION_CALL],
139 id="empty_dict_streaming",
140 ),
141 pytest.param(
142 False,
143 f"<function_calls>{EMPTY_DICT_FUNCTION_OUTPUT}</function_calls>",
144 [EMPTY_DICT_FUNCTION_CALL],
145 id="empty_dict_nonstreaming",
146 ),
147 pytest.param(
148 True,
149 f"<function_calls>{EMPTY_LIST_FUNCTION_OUTPUT}</function_calls>",
150 [EMPTY_LIST_FUNCTION_CALL],
151 id="empty_list_streaming",
152 ),
153 pytest.param(
154 False,
155 f"<function_calls>{EMPTY_LIST_FUNCTION_OUTPUT}</function_calls>",
156 [EMPTY_LIST_FUNCTION_CALL],
157 id="empty_list_nonstreaming",
158 ),
159 pytest.param(
160 True,
161 f"<function_calls>{ESCAPED_STRING_FUNCTION_OUTPUT}</function_calls>",
162 [ESCAPED_STRING_FUNCTION_CALL],
163 id="escaped_string_streaming",
164 ),
165 pytest.param(
166 False,
167 f"<function_calls>{ESCAPED_STRING_FUNCTION_OUTPUT}</function_calls>",
168 [ESCAPED_STRING_FUNCTION_CALL],
169 id="escaped_string_nonstreaming",
170 ),
171 pytest.param(
172 True,
173 f"<function_calls>{SIMPLE_FUNCTION_OUTPUT}\n{MORE_TYPES_FUNCTION_OUTPUT}</function_calls>",
174 [SIMPLE_FUNCTION_CALL, MORE_TYPES_FUNCTION_CALL],
175 id="parallel_calls_streaming",
176 ),
177 pytest.param(
178 False,
179 f"<function_calls>{SIMPLE_FUNCTION_OUTPUT}\n{MORE_TYPES_FUNCTION_OUTPUT}</function_calls>",
180 [SIMPLE_FUNCTION_CALL, MORE_TYPES_FUNCTION_CALL],
181 id="parallel_calls_nonstreaming",
182 ),
183 ]
184
185
186 @pytest.mark.parametrize("streaming, model_output, expected_tool_calls", TEST_CASES)
187 def test_tool_call(
188 streaming: bool,
189 model_output: str,
190 expected_tool_calls: list[FunctionCall],
191 default_tokenizer: TokenizerLike,
192 ):
193 tool_parser: ToolParser = ToolParserManager.get_tool_parser("olmo3")(
194 default_tokenizer
195 )
196
197 content, tool_calls = run_tool_extraction(
198 tool_parser, model_output, streaming=streaming
199 )
200
201 assert content is None
202 assert len(tool_calls) == len(expected_tool_calls)
203 for actual, expected in zip(tool_calls, expected_tool_calls):
204 assert actual.type == "function"
205 assert actual.function == expected
206
207
208 def test_streaming_tool_call_with_large_steps(default_tokenizer: TokenizerLike):
209 tool_parser: ToolParser = ToolParserManager.get_tool_parser("olmo3")(
210 default_tokenizer
211 )
212 model_output_deltas = [
213 "<function_calls>get_weather(city='San",
214 " Francisco', metric='celsius')\n"
215 f"{PARAMETERLESS_FUNCTION_OUTPUT}\n"
216 f"{EMPTY_LIST_FUNCTION_OUTPUT}</function_calls>",
217 ]
218
219 reconstructor = run_tool_extraction_streaming(
220 tool_parser, model_output_deltas, assert_one_tool_per_delta=False
221 )
222
223 assert reconstructor.other_content == ""
224 assert len(reconstructor.tool_calls) == 3
225 assert reconstructor.tool_calls[0].function == SIMPLE_FUNCTION_CALL
226 assert reconstructor.tool_calls[1].function == PARAMETERLESS_FUNCTION_CALL
227 assert reconstructor.tool_calls[2].function == EMPTY_LIST_FUNCTION_CALL
228
229
230 @pytest.mark.parametrize("streaming", [False])
231 def test_regex_timeout_handling(streaming: bool, default_tokenizer: TokenizerLike):
232 """Test regex timeout is handled gracefully."""
233 tool_parser: ToolParser = ToolParserManager.get_tool_parser("olmo3")(
234 default_tokenizer
235 )
236
237 fake_problematic_input = "hello world[A(A=" + "\t)A(A=,\t" * 2
238
239 # create a mock regex that raises TimeoutError
240 mock_regex = MagicMock()
241 mock_regex.match.side_effect = TimeoutError("Regex timeout")
242
243 with patch.object(tool_parser, "TOOL_CALL_REGEX", mock_regex):
244 content, tool_calls = run_tool_extraction(
245 tool_parser, fake_problematic_input, streaming=streaming
246 )
247
248 # should treat as regular text when regex times out
249 assert content == fake_problematic_input
250 assert len(tool_calls) == 0
251 mock_regex.match.assert_called_once()