main
py 339 lines 12 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
7 import partial_json_parser
8 import pytest
9 from partial_json_parser.core.options import Allow
10
11 from vllm.entrypoints.generate.base.protocol import DeltaMessage, FunctionCall, ToolCall
12 from vllm.tokenizers import TokenizerLike, get_tokenizer
13 from vllm.tokenizers.detokenizer_utils import detokenize_incrementally
14 from vllm.tool_parsers.jamba_tool_parser import JambaToolParser
15
16 MODEL = "ai21labs/Jamba-tiny-dev"
17
18
19 @pytest.fixture(scope="module")
20 def jamba_tokenizer():
21 return get_tokenizer(tokenizer_name=MODEL)
22
23
24 @pytest.fixture
25 def jamba_tool_parser(jamba_tokenizer):
26 return JambaToolParser(jamba_tokenizer)
27
28
29 def assert_tool_calls(
30 actual_tool_calls: list[ToolCall], expected_tool_calls: list[ToolCall]
31 ):
32 assert len(actual_tool_calls) == len(expected_tool_calls)
33
34 for actual_tool_call, expected_tool_call in zip(
35 actual_tool_calls, expected_tool_calls
36 ):
37 assert isinstance(actual_tool_call.id, str)
38 assert len(actual_tool_call.id) > 16
39
40 assert actual_tool_call.type == "function"
41 assert actual_tool_call.function == expected_tool_call.function
42
43
44 def stream_delta_message_generator(
45 jamba_tool_parser: JambaToolParser,
46 jamba_tokenizer: TokenizerLike,
47 model_output: str,
48 ) -> Generator[DeltaMessage, None, None]:
49 all_token_ids = jamba_tokenizer.encode(model_output, add_special_tokens=False)
50
51 previous_text = ""
52 previous_tokens = None
53 prefix_offset = 0
54 read_offset = 0
55 for i, delta_token in enumerate(all_token_ids):
56 delta_token_ids = [delta_token]
57 previous_token_ids = all_token_ids[:i]
58 current_token_ids = all_token_ids[: i + 1]
59
60 (new_tokens, delta_text, new_prefix_offset, new_read_offset) = (
61 detokenize_incrementally(
62 tokenizer=jamba_tokenizer,
63 all_input_ids=current_token_ids,
64 prev_tokens=previous_tokens,
65 prefix_offset=prefix_offset,
66 read_offset=read_offset,
67 skip_special_tokens=False,
68 spaces_between_special_tokens=True,
69 )
70 )
71
72 current_text = previous_text + delta_text
73
74 delta_message = jamba_tool_parser.extract_tool_calls_streaming(
75 previous_text,
76 current_text,
77 delta_text,
78 previous_token_ids,
79 current_token_ids,
80 delta_token_ids,
81 request=None,
82 )
83 if delta_message:
84 yield delta_message
85
86 previous_text = current_text
87 previous_tokens = (
88 previous_tokens + new_tokens if previous_tokens else new_tokens
89 )
90 prefix_offset = new_prefix_offset
91 read_offset = new_read_offset
92
93
94 def test_extract_tool_calls_no_tools(jamba_tool_parser):
95 model_output = "This is a test"
96 extracted_tool_calls = jamba_tool_parser.extract_tool_calls(
97 model_output, request=None
98 )
99 assert not extracted_tool_calls.tools_called
100 assert extracted_tool_calls.tool_calls == []
101 assert extracted_tool_calls.content == model_output
102
103
104 @pytest.mark.parametrize(
105 ids=[
106 "single_tool",
107 "single_tool_with_content",
108 "parallel_tools",
109 ],
110 argnames=["model_output", "expected_tool_calls", "expected_content"],
111 argvalues=[
112 (
113 """ <tool_calls>[\n {"name": "get_current_weather", "arguments": {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}}\n]</tool_calls>""", # noqa: E501
114 [
115 ToolCall(
116 function=FunctionCall(
117 name="get_current_weather",
118 arguments=json.dumps(
119 {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}
120 ),
121 )
122 )
123 ],
124 None,
125 ),
126 (
127 """ Sure! let me call the tool for you.<tool_calls>[\n {"name": "get_current_weather", "arguments": {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}}\n]</tool_calls>""", # noqa: E501
128 [
129 ToolCall(
130 function=FunctionCall(
131 name="get_current_weather",
132 arguments=json.dumps(
133 {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}
134 ),
135 )
136 )
137 ],
138 " Sure! let me call the tool for you.",
139 ),
140 (
141 """ <tool_calls>[\n {"name": "get_current_weather", "arguments": {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}},\n {"name": "get_current_weather", "arguments": {"city": "Orlando", "state": "FL", "unit": "fahrenheit"}}\n]</tool_calls>""", # noqa: E501
142 [
143 ToolCall(
144 function=FunctionCall(
145 name="get_current_weather",
146 arguments=json.dumps(
147 {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}
148 ),
149 )
150 ),
151 ToolCall(
152 function=FunctionCall(
153 name="get_current_weather",
154 arguments=json.dumps(
155 {"city": "Orlando", "state": "FL", "unit": "fahrenheit"}
156 ),
157 )
158 ),
159 ],
160 None,
161 ),
162 ],
163 )
164 def test_extract_tool_calls(
165 jamba_tool_parser, model_output, expected_tool_calls, expected_content
166 ):
167 extracted_tool_calls = jamba_tool_parser.extract_tool_calls(
168 model_output, request=None
169 )
170 assert extracted_tool_calls.tools_called
171
172 assert_tool_calls(extracted_tool_calls.tool_calls, expected_tool_calls)
173
174 assert extracted_tool_calls.content == expected_content
175
176
177 @pytest.mark.parametrize(
178 ids=[
179 "no_tools",
180 "single_tool",
181 "single_tool_with_content",
182 "parallel_tools",
183 ],
184 argnames=["model_output", "expected_tool_calls", "expected_content"],
185 argvalues=[
186 ("""This is a test""", [], """This is a test"""),
187 (
188 """ <tool_calls>[\n {"name": "get_current_weather", "arguments": {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}}\n]</tool_calls>""", # noqa: E501
189 [
190 ToolCall(
191 function=FunctionCall(
192 name="get_current_weather",
193 arguments=json.dumps(
194 {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}
195 ),
196 )
197 )
198 ],
199 " ",
200 ),
201 (
202 """ Sure! let me call the tool for you.<tool_calls>[\n {"name": "get_current_weather", "arguments": {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}}\n]</tool_calls>""", # noqa: E501
203 [
204 ToolCall(
205 function=FunctionCall(
206 name="get_current_weather",
207 arguments=json.dumps(
208 {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}
209 ),
210 )
211 )
212 ],
213 " Sure! let me call the tool for you.",
214 ),
215 (
216 """ <tool_calls>[\n {"name": "get_current_weather", "arguments": {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}},\n {"name": "get_current_weather", "arguments": {"city": "Orlando", "state": "FL", "unit": "fahrenheit"}}\n]</tool_calls>""", # noqa: E501
217 [
218 ToolCall(
219 function=FunctionCall(
220 name="get_current_weather",
221 arguments=json.dumps(
222 {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}
223 ),
224 )
225 ),
226 ToolCall(
227 function=FunctionCall(
228 name="get_current_weather",
229 arguments=json.dumps(
230 {"city": "Orlando", "state": "FL", "unit": "fahrenheit"}
231 ),
232 )
233 ),
234 ],
235 " ",
236 ),
237 ],
238 )
239 def test_extract_tool_calls_streaming(
240 jamba_tool_parser,
241 jamba_tokenizer,
242 model_output,
243 expected_tool_calls,
244 expected_content,
245 ):
246 other_content: str = ""
247 function_names: list[str] = []
248 function_args_strs: list[str] = []
249 tool_call_idx: int = -1
250 tool_call_ids: list[str | None] = []
251
252 for delta_message in stream_delta_message_generator(
253 jamba_tool_parser, jamba_tokenizer, model_output
254 ):
255 # role should never be streamed from tool parser
256 assert not delta_message.role
257
258 if delta_message.content:
259 other_content += delta_message.content
260
261 streamed_tool_calls = delta_message.tool_calls
262
263 if streamed_tool_calls and len(streamed_tool_calls) > 0:
264 # make sure only one diff is present - correct even for parallel
265 assert len(streamed_tool_calls) == 1
266 tool_call = streamed_tool_calls[0]
267
268 # if a new tool is being called, set up empty arguments
269 if tool_call.index != tool_call_idx:
270 tool_call_idx = tool_call.index
271 function_args_strs.append("")
272 tool_call_ids.append(None)
273
274 # if a tool call ID is streamed, make sure one hasn't been already
275 if tool_call.id and not tool_call_ids[tool_call.index]:
276 tool_call_ids[tool_call.index] = tool_call.id
277
278 # if parts of the function start being streamed
279 if tool_call.function:
280 # if the function name is defined, set it. it should be streamed
281 # IN ENTIRETY, exactly one time.
282 if tool_call.function.name:
283 assert isinstance(tool_call.function.name, str)
284 function_names.append(tool_call.function.name)
285
286 if tool_call.function.arguments:
287 # make sure they're a string and then add them to the list
288 assert isinstance(tool_call.function.arguments, str)
289
290 function_args_strs[tool_call.index] += tool_call.function.arguments
291
292 assert other_content == expected_content
293
294 actual_tool_calls = [
295 ToolCall(
296 id=tool_call_id,
297 function=FunctionCall(
298 name=function_name,
299 arguments=partial_json_parser.ensure_json(
300 function_args_str, Allow.OBJ | Allow.STR
301 ),
302 ),
303 )
304 for tool_call_id, function_name, function_args_str in zip(
305 tool_call_ids, function_names, function_args_strs
306 )
307 ]
308 assert_tool_calls(actual_tool_calls, expected_tool_calls)
309
310
311 def test_extract_tool_calls_streaming_arguments_in_single_delta(jamba_tool_parser):
312 """Arguments delivered whole in one coarse delta must not be dropped."""
313 deltas = [
314 '<tool_calls>[{"name": "get_current_weather"',
315 ",",
316 ' "arguments": {"city": "Dallas", "state": "TX"}}]',
317 "</tool_calls>",
318 ]
319
320 streamed_arguments = ""
321 current_text = ""
322 for delta_text in deltas:
323 previous_text = current_text
324 current_text += delta_text
325 delta_message = jamba_tool_parser.extract_tool_calls_streaming(
326 previous_text=previous_text,
327 current_text=current_text,
328 delta_text=delta_text,
329 previous_token_ids=[],
330 current_token_ids=[],
331 delta_token_ids=[],
332 request=None,
333 )
334 if delta_message and delta_message.tool_calls:
335 arguments = delta_message.tool_calls[0].function.arguments
336 if arguments:
337 streamed_arguments += arguments
338
339 assert json.loads(streamed_arguments) == {"city": "Dallas", "state": "TX"}