main
py 359 lines 11.3 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 # ruff: noqa: E501
4
5 import json
6 from collections.abc import Generator
7
8 import pytest
9
10 from vllm.entrypoints.generate.base.protocol import (
11 DeltaMessage,
12 FunctionCall,
13 ToolCall,
14 )
15 from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
16 from vllm.tokenizers import TokenizerLike, get_tokenizer
17 from vllm.tokenizers.detokenizer_utils import detokenize_incrementally
18 from vllm.tool_parsers.ernie45_tool_parser import Ernie45ToolParser
19
20 # Use a common model that is likely to be available
21 MODEL = "baidu/ERNIE-4.5-21B-A3B-Thinking"
22
23
24 @pytest.fixture(scope="module")
25 def ernie45_tokenizer():
26 return get_tokenizer(tokenizer_name=MODEL, trust_remote_code=True)
27
28
29 @pytest.fixture
30 def ernie45_tool_parser(ernie45_tokenizer):
31 return Ernie45ToolParser(ernie45_tokenizer)
32
33
34 def assert_tool_calls(
35 actual_tool_calls: list[ToolCall], expected_tool_calls: list[ToolCall]
36 ):
37 assert len(actual_tool_calls) == len(expected_tool_calls)
38
39 for actual_tool_call, expected_tool_call in zip(
40 actual_tool_calls, expected_tool_calls
41 ):
42 assert isinstance(actual_tool_call.id, str)
43 assert len(actual_tool_call.id) > 0
44
45 assert actual_tool_call.type == "function"
46 assert actual_tool_call.function.name == expected_tool_call.function.name
47 # Compare arguments as JSON objects to handle formatting differences
48 actual_args = json.loads(actual_tool_call.function.arguments)
49 expected_args = json.loads(expected_tool_call.function.arguments)
50 assert actual_args == expected_args
51
52
53 def test_extract_tool_calls_no_tools(ernie45_tool_parser):
54 model_output = "This is a test"
55 extracted_tool_calls = ernie45_tool_parser.extract_tool_calls(
56 model_output, request=None
57 )
58 assert not extracted_tool_calls.tools_called
59 assert extracted_tool_calls.tool_calls == []
60 assert extracted_tool_calls.content == model_output
61
62
63 @pytest.mark.parametrize(
64 ids=[
65 "single_tool_call",
66 "multiple_tool_calls",
67 "tool_call_with_content_before",
68 ],
69 argnames=["model_output", "expected_tool_calls", "expected_content"],
70 argvalues=[
71 (
72 """<tool_call>
73 {"name": "get_current_temperature", "arguments": {"location": "Beijing"}}
74 </tool_call>
75 """,
76 [
77 ToolCall(
78 function=FunctionCall(
79 name="get_current_temperature",
80 arguments=json.dumps(
81 {
82 "location": "Beijing",
83 }
84 ),
85 )
86 )
87 ],
88 None,
89 ),
90 (
91 """<tool_call>
92 {"name": "get_current_temperature", "arguments": {"location": "Beijing"}}
93 </tool_call>
94 <tool_call>
95 {"name": "get_temperature_unit", "arguments": {"location": "Guangzhou", "unit": "c"}}
96 </tool_call>
97 """,
98 [
99 ToolCall(
100 function=FunctionCall(
101 name="get_current_temperature",
102 arguments=json.dumps(
103 {
104 "location": "Beijing",
105 }
106 ),
107 )
108 ),
109 ToolCall(
110 function=FunctionCall(
111 name="get_temperature_unit",
112 arguments=json.dumps(
113 {
114 "location": "Guangzhou",
115 "unit": "c",
116 }
117 ),
118 )
119 ),
120 ],
121 None,
122 ),
123 (
124 """I need to call two tools to handle these two issues separately.
125 </think>
126
127 <tool_call>
128 {"name": "get_current_temperature", "arguments": {"location": "Beijing"}}
129 </tool_call>
130 <tool_call>
131 {"name": "get_temperature_unit", "arguments": {"location": "Guangzhou", "unit": "c"}}
132 </tool_call>
133 """,
134 [
135 ToolCall(
136 function=FunctionCall(
137 name="get_current_temperature",
138 arguments=json.dumps(
139 {
140 "location": "Beijing",
141 }
142 ),
143 )
144 ),
145 ToolCall(
146 function=FunctionCall(
147 name="get_temperature_unit",
148 arguments=json.dumps(
149 {
150 "location": "Guangzhou",
151 "unit": "c",
152 }
153 ),
154 )
155 ),
156 ],
157 "I need to call two tools to handle these two issues separately.\n</think>",
158 ),
159 ],
160 )
161 def test_extract_tool_calls(
162 ernie45_tool_parser, model_output, expected_tool_calls, expected_content
163 ):
164 extracted_tool_calls = ernie45_tool_parser.extract_tool_calls(
165 model_output, request=None
166 )
167 assert extracted_tool_calls.tools_called
168
169 assert_tool_calls(extracted_tool_calls.tool_calls, expected_tool_calls)
170
171 assert extracted_tool_calls.content == expected_content
172
173
174 def stream_delta_message_generator(
175 ernie45_tool_parser: Ernie45ToolParser,
176 ernie45_tokenizer: TokenizerLike,
177 model_output: str,
178 request: ChatCompletionRequest | None = None,
179 ) -> Generator[DeltaMessage, None, None]:
180 all_token_ids = ernie45_tokenizer.encode(model_output, add_special_tokens=False)
181
182 previous_text = ""
183 previous_tokens = None
184 prefix_offset = 0
185 read_offset = 0
186 for i, delta_token in enumerate(all_token_ids):
187 delta_token_ids = [delta_token]
188 previous_token_ids = all_token_ids[:i]
189 current_token_ids = all_token_ids[: i + 1]
190
191 (new_tokens, delta_text, new_prefix_offset, new_read_offset) = (
192 detokenize_incrementally(
193 tokenizer=ernie45_tokenizer,
194 all_input_ids=current_token_ids,
195 prev_tokens=previous_tokens,
196 prefix_offset=prefix_offset,
197 read_offset=read_offset,
198 skip_special_tokens=False,
199 spaces_between_special_tokens=True,
200 )
201 )
202
203 current_text = previous_text + delta_text
204
205 delta_message = ernie45_tool_parser.extract_tool_calls_streaming(
206 previous_text,
207 current_text,
208 delta_text,
209 previous_token_ids,
210 current_token_ids,
211 delta_token_ids,
212 request=request,
213 )
214 if delta_message:
215 yield delta_message
216
217 previous_text = current_text
218 previous_tokens = (
219 previous_tokens + new_tokens if previous_tokens else new_tokens
220 )
221 prefix_offset = new_prefix_offset
222 read_offset = new_read_offset
223
224
225 @pytest.mark.parametrize(
226 ids=[
227 "single_tool_call",
228 "multiple_tool_calls",
229 "tool_call_with_content_before",
230 ],
231 argnames=["model_output", "expected_tool_calls", "expected_content"],
232 argvalues=[
233 (
234 """<tool_call>
235 {"name": "get_current_temperature", "arguments": {"location": "Beijing"}}
236 </tool_call>
237 """,
238 [
239 ToolCall(
240 function=FunctionCall(
241 name="get_current_temperature",
242 arguments=json.dumps(
243 {
244 "location": "Beijing",
245 }
246 ),
247 )
248 )
249 ],
250 None,
251 ),
252 (
253 """<tool_call>
254 {"name": "get_current_temperature", "arguments": {"location": "Beijing"}}
255 </tool_call>
256 <tool_call>
257 {"name": "get_temperature_unit", "arguments": {"location": "Guangzhou", "unit": "c"}}
258 </tool_call>
259 """,
260 [
261 ToolCall(
262 function=FunctionCall(
263 name="get_current_temperature",
264 arguments=json.dumps(
265 {
266 "location": "Beijing",
267 }
268 ),
269 )
270 ),
271 ToolCall(
272 function=FunctionCall(
273 name="get_temperature_unit",
274 arguments=json.dumps(
275 {
276 "location": "Guangzhou",
277 "unit": "c",
278 }
279 ),
280 )
281 ),
282 ],
283 None,
284 ),
285 (
286 """I need to call two tools to handle these two issues separately.
287 </think>
288
289 <tool_call>
290 {"name": "get_current_temperature", "arguments": {"location": "Beijing"}}
291 </tool_call>
292 <tool_call>
293 {"name": "get_temperature_unit", "arguments": {"location": "Guangzhou", "unit": "c"}}
294 </tool_call>
295 """,
296 [
297 ToolCall(
298 function=FunctionCall(
299 name="get_current_temperature",
300 arguments=json.dumps(
301 {
302 "location": "Beijing",
303 }
304 ),
305 )
306 ),
307 ToolCall(
308 function=FunctionCall(
309 name="get_temperature_unit",
310 arguments=json.dumps(
311 {
312 "location": "Guangzhou",
313 "unit": "c",
314 }
315 ),
316 )
317 ),
318 ],
319 "I need to call two tools to handle these two issues separately.\n</think>",
320 ),
321 ],
322 )
323 def test_extract_tool_calls_streaming_incremental(
324 ernie45_tool_parser,
325 ernie45_tokenizer,
326 model_output,
327 expected_tool_calls,
328 expected_content,
329 ):
330 """Verify the Ernie45 Parser streaming behavior by verifying each chunk is as expected.""" # noqa: E501
331 request = ChatCompletionRequest(model=MODEL, messages=[])
332
333 tool_calls_dict = {}
334 for delta_message in stream_delta_message_generator(
335 ernie45_tool_parser, ernie45_tokenizer, model_output, request
336 ):
337 if (
338 delta_message.role is None
339 and delta_message.content is None
340 and delta_message.reasoning is None
341 and len(delta_message.tool_calls) == 0
342 ):
343 continue
344 tool_calls = delta_message.tool_calls
345 for tool_call_chunk in tool_calls:
346 index = tool_call_chunk.index
347 if index not in tool_calls_dict:
348 if tool_call_chunk.function.arguments is None:
349 tool_call_chunk.function.arguments = ""
350 tool_calls_dict[index] = tool_call_chunk
351 else:
352 tool_calls_dict[
353 index
354 ].function.arguments += tool_call_chunk.function.arguments
355 actual_tool_calls = list(tool_calls_dict.values())
356
357 assert len(actual_tool_calls) > 0
358 # check tool call format
359 assert_tool_calls(actual_tool_calls, expected_tool_calls)