main
py 433 lines 15.8 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import json
5 from typing import Any
6 from unittest.mock import MagicMock
7
8 import pytest
9
10 from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
11 from vllm.tool_parsers.apertus_tool_parser import (
12 TOOL_CALLS_PREFIX,
13 TOOL_CALLS_SUFFIX,
14 ApertusToolParser,
15 )
16
17 # ---------------------------------------------------------------------------
18 # Fixtures
19 # ---------------------------------------------------------------------------
20
21
22 @pytest.fixture
23 def mock_tokenizer():
24 tokenizer = MagicMock()
25 tokenizer.encode.return_value = [1, 2, 3]
26 # Include the tool call tokens in the vocab for the parser
27 tokenizer.get_vocab.return_value = {TOOL_CALLS_PREFIX: 100, TOOL_CALLS_SUFFIX: 101}
28 return tokenizer
29
30
31 @pytest.fixture
32 def parser(mock_tokenizer):
33 return ApertusToolParser(mock_tokenizer)
34
35
36 @pytest.fixture
37 def mock_request():
38 request = MagicMock(spec=ChatCompletionRequest)
39 request.tools = []
40 request.tool_choice = "auto"
41 return request
42
43
44 # ---------------------------------------------------------------------------
45 # Non-streaming extraction tests
46 # ---------------------------------------------------------------------------
47
48
49 class TestExtractToolCalls:
50 def test_no_tool_calls(self, parser, mock_request):
51 model_output = "Hello, how can I help you today?"
52 result = parser.extract_tool_calls(model_output, mock_request)
53
54 assert result.tools_called is False
55 assert result.tool_calls == []
56 assert result.content == model_output
57
58 def test_single_tool_call(self, parser, mock_request):
59 model_output = (
60 '<|tools_prefix|>[{"get_weather": {"location": "London"}}]<|tools_suffix|>'
61 )
62 result = parser.extract_tool_calls(model_output, mock_request)
63
64 assert result.tools_called is True
65 assert len(result.tool_calls) == 1
66 assert result.tool_calls[0].function.name == "get_weather"
67 args = json.loads(result.tool_calls[0].function.arguments)
68 assert args == {"location": "London"}
69
70 def test_multiple_arguments(self, parser, mock_request):
71 model_output = (
72 '<|tools_prefix|>[{"get_weather": '
73 '{"location": "San Francisco", '
74 '"unit": "celsius"}}]<|tools_suffix|>'
75 )
76 result = parser.extract_tool_calls(model_output, mock_request)
77
78 assert result.tools_called is True
79 assert len(result.tool_calls) == 1
80 assert result.tool_calls[0].function.name == "get_weather"
81 args = json.loads(result.tool_calls[0].function.arguments)
82 assert args == {"location": "San Francisco", "unit": "celsius"}
83
84 def test_text_before_tool_call(self, parser, mock_request):
85 model_output = (
86 "Let me check the weather for you. "
87 '<|tools_prefix|>[{"get_weather": {"location": "Paris"}}]<|tools_suffix|>'
88 )
89 result = parser.extract_tool_calls(model_output, mock_request)
90
91 assert result.tools_called is True
92 assert result.content == "Let me check the weather for you."
93 assert len(result.tool_calls) == 1
94 assert result.tool_calls[0].function.name == "get_weather"
95
96 def test_multiple_tool_calls(self, parser, mock_request):
97 model_output = (
98 '<|tools_prefix|>[{"get_weather": '
99 '{"location": "London"}}, '
100 '{"get_time": {"location": "London"}}]<|tools_suffix|>'
101 )
102 result = parser.extract_tool_calls(model_output, mock_request)
103
104 assert result.tools_called is True
105 assert len(result.tool_calls) == 2
106 assert result.tool_calls[0].function.name == "get_weather"
107 assert result.tool_calls[1].function.name == "get_time"
108
109 def test_nested_arguments(self, parser, mock_request):
110 model_output = (
111 '<|tools_prefix|>[{"complex_function": '
112 '{"nested": {"inner": "value"}, '
113 '"list": ["a", "b"]}}]<|tools_suffix|>'
114 )
115 result = parser.extract_tool_calls(model_output, mock_request)
116
117 assert result.tools_called is True
118 assert len(result.tool_calls) == 1
119 assert result.tool_calls[0].function.name == "complex_function"
120 args = json.loads(result.tool_calls[0].function.arguments)
121 assert args == {"nested": {"inner": "value"}, "list": ["a", "b"]}
122
123 def test_incomplete_tool_call(self, parser, mock_request):
124 model_output = '<|tools_prefix|>[{"get_weather": {"location": "London"}'
125 result = parser.extract_tool_calls(model_output, mock_request)
126
127 assert result.tools_called is True
128 assert len(result.tool_calls) == 1
129 assert result.tool_calls[0].function.name == "get_weather"
130 args = json.loads(result.tool_calls[0].function.arguments)
131 assert args == {"location": "London"}
132
133 def test_missing_tool_suffix(self, parser, mock_request):
134 model_output = (
135 '<|tools_prefix|>[{"get_weather": '
136 '{"location": "San Francisco", "unit": "celsius"}}]'
137 )
138 result = parser.extract_tool_calls(model_output, mock_request)
139
140 assert result.tools_called is True
141 assert len(result.tool_calls) == 1
142 assert result.tool_calls[0].function.name == "get_weather"
143 args = json.loads(result.tool_calls[0].function.arguments)
144 assert args == {"location": "San Francisco", "unit": "celsius"}
145
146
147 # ---------------------------------------------------------------------------
148 # Streaming extraction tests
149 # ---------------------------------------------------------------------------
150
151
152 class TestStreamingExtraction:
153 def _simulate_streaming(
154 self, parser: ApertusToolParser, mock_request: Any, chunks: list[str]
155 ) -> list[tuple[Any, str]]:
156 results: list[tuple[Any, str]] = []
157 previous_text: str = ""
158 previous_token_ids: list[int] = []
159
160 for chunk in chunks:
161 current_text = previous_text + chunk
162 # Simulate a token ID sequence matching the chunk progression
163 delta_token_ids: list[int] = [0] * max(1, len(chunk) // 4)
164 current_token_ids = previous_token_ids + delta_token_ids
165
166 delta = parser.extract_tool_calls_streaming(
167 previous_text=previous_text,
168 current_text=current_text,
169 delta_text=chunk,
170 previous_token_ids=tuple(previous_token_ids),
171 current_token_ids=tuple(current_token_ids),
172 delta_token_ids=tuple(delta_token_ids),
173 request=mock_request,
174 )
175 results.append((delta, current_text))
176 previous_text = current_text
177 previous_token_ids = list(current_token_ids)
178
179 return results
180
181 def _collect_tool_calls(self, results) -> dict[int, dict[str, Any]]:
182 """Properly tracks and concatenates streamed tool arguments by their Index."""
183 tool_calls = {}
184 for delta, _ in results:
185 if not delta or not getattr(delta, "tool_calls", None):
186 continue
187
188 for tc in delta.tool_calls:
189 idx = (
190 tc.get("index", 0)
191 if isinstance(tc, dict)
192 else getattr(tc, "index", 0)
193 )
194 func = (
195 tc.get("function", {})
196 if isinstance(tc, dict)
197 else getattr(tc, "function", None)
198 )
199 if not func:
200 continue
201
202 name = (
203 func.get("name")
204 if isinstance(func, dict)
205 else getattr(func, "name", None)
206 )
207 args = (
208 func.get("arguments")
209 if isinstance(func, dict)
210 else getattr(func, "arguments", None)
211 )
212
213 if idx not in tool_calls:
214 tool_calls[idx] = {"name": "", "arguments": ""}
215
216 if name:
217 tool_calls[idx]["name"] += name
218 if args:
219 tool_calls[idx]["arguments"] += args
220
221 return tool_calls
222
223 def _collect_content(self, results) -> str:
224 """Collects generated normal text outside of the tool calls."""
225 return "".join(
226 delta.content
227 for delta, _ in results
228 if delta and getattr(delta, "content", None)
229 )
230
231 def test_basic_streaming_single_tool(self, parser, mock_request):
232 chunks = [
233 "<|tools_prefix|>",
234 '[{"get_weather": ',
235 '{"location": "Paris, ',
236 'France"}}]',
237 "<|tools_suffix|>",
238 ]
239
240 results = self._simulate_streaming(parser, mock_request, chunks)
241 tcs = self._collect_tool_calls(results)
242
243 assert len(tcs) == 1
244 assert tcs[0]["name"] == "get_weather"
245 assert json.loads(tcs[0]["arguments"]) == {"location": "Paris, France"}
246
247 def test_streaming_missing_tool_suffix(self, parser, mock_request):
248 chunks = [
249 "<|tools_prefix|>",
250 '[{"get_weather": ',
251 '{"location": "Paris, ',
252 'France"}}]',
253 ]
254
255 results = self._simulate_streaming(parser, mock_request, chunks)
256 tcs = self._collect_tool_calls(results)
257
258 assert len(tcs) == 1
259 assert tcs[0]["name"] == "get_weather"
260 assert json.loads(tcs[0]["arguments"]) == {"location": "Paris, France"}
261
262 def test_streaming_partial_tag_buffering_missing_tool_suffix(
263 self, parser, mock_request
264 ):
265 chunks = ["Content", "<|tools_", "prefix|>", '[{"f": ', '{"a": 1}}]']
266
267 results = self._simulate_streaming(parser, mock_request, chunks)
268 content = self._collect_content(results)
269
270 assert "Content" in content
271 assert "<|tools_prefix|>" not in content
272 assert "<|tools_suffix|>" not in content
273
274 tcs = self._collect_tool_calls(results)
275
276 assert len(tcs) == 1
277 assert tcs[0]["name"] == "f"
278 assert json.loads(tcs[0]["arguments"]) == {"a": 1}
279
280 def test_streaming_multi_tool(self, parser, mock_request):
281 chunks = [
282 "<|tools_prefix|>",
283 '[{"get_weather": {"location": "Tokyo"}}',
284 ', {"get_time": {"location": "Tokyo"}}]',
285 "<|tools_suffix|>",
286 ]
287
288 results = self._simulate_streaming(parser, mock_request, chunks)
289 tcs = self._collect_tool_calls(results)
290
291 assert len(tcs) == 2
292 assert tcs[0]["name"] == "get_weather"
293 assert json.loads(tcs[0]["arguments"]) == {"location": "Tokyo"}
294 assert tcs[1]["name"] == "get_time"
295 assert json.loads(tcs[1]["arguments"]) == {"location": "Tokyo"}
296
297 def test_streaming_text_before_tool_call(self, parser, mock_request):
298 chunks = [
299 "Let me check ",
300 "the weather. ",
301 "<|tools_prefix|>",
302 '[{"get_weather": {"location": "London"}}]',
303 "<|tools_suffix|>",
304 ]
305
306 results = self._simulate_streaming(parser, mock_request, chunks)
307 content = self._collect_content(results)
308
309 assert content.strip() == "Let me check the weather."
310 tcs = self._collect_tool_calls(results)
311
312 assert len(tcs) == 1
313 assert tcs[0]["name"] == "get_weather"
314 assert json.loads(tcs[0]["arguments"]) == {"location": "London"}
315
316 def test_streaming_partial_tag_buffering(self, parser, mock_request):
317 chunks = [
318 "Content",
319 "<|tools_",
320 "prefix|>",
321 '[{"f": {"a": 1}}]',
322 "<|tools_suf",
323 "fix|>",
324 ]
325
326 results = self._simulate_streaming(parser, mock_request, chunks)
327 content = self._collect_content(results)
328
329 assert "Content" in content
330 assert "<|tools_prefix|>" not in content
331 assert "<|tools_suffix|>" not in content
332
333 tc = self._collect_tool_calls(results)
334 assert len(tc) == 1
335 assert tc[0]["name"] == "f"
336 assert json.loads(tc[0]["arguments"]) == {"a": 1}
337
338 # ---------------------------------------------------------------------------
339 # Edge Cases: Multi-Token Prediction (MTP) & vLLM Chunking Anomalies
340 # ---------------------------------------------------------------------------
341
342 def test_mtp_streaming_massive_chunk(self, parser, mock_request):
343 """Simulates MTP predicting text, tool calls,
344 and trailing text all in a single chunk."""
345 chunks = [
346 "Sure! "
347 '<|tools_prefix|>[{"get_weather": {"location": "London"}}]<|tools_suffix|>'
348 ]
349 results = self._simulate_streaming(parser, mock_request, chunks)
350
351 content = self._collect_content(results)
352 assert "Sure! " in content
353
354 tc = self._collect_tool_calls(results)
355 assert len(tc) == 1
356 assert tc[0]["name"] == "get_weather"
357 assert json.loads(tc[0]["arguments"]) == {"location": "London"}
358
359 def test_mtp_streaming_multiple_tools_burst(self, parser, mock_request):
360 """Simulates MTP predicting an array of multiple tools in one single chunk."""
361 chunks = [
362 '<|tools_prefix|>[{"get_weather": '
363 '{"location": "London"}}, '
364 '{"get_time": {"location": "Paris"}}]<|tools_suffix|>'
365 ]
366 results = self._simulate_streaming(parser, mock_request, chunks)
367
368 tc = self._collect_tool_calls(results)
369 assert len(tc) == 2
370 assert tc[0]["name"] == "get_weather"
371 assert json.loads(tc[0]["arguments"]) == {"location": "London"}
372 assert tc[1]["name"] == "get_time"
373 assert json.loads(tc[1]["arguments"]) == {"location": "Paris"}
374
375 def test_mtp_streaming_skip_and_catch_up(self, parser, mock_request):
376 """Simulates MTP chunks that jump over entire tools
377 (e.g., from middle of tool 1 to middle of tool 3)."""
378 chunks = [
379 '<|tools_prefix|>[{"t1": {"a": 1}',
380 '}, {"t2": {"b": 2}}, {"t3": {"c": 3',
381 "}}]<|tools_suffix|>",
382 ]
383 results = self._simulate_streaming(parser, mock_request, chunks)
384
385 tc = self._collect_tool_calls(results)
386 assert len(tc) == 3
387 assert tc[0]["name"] == "t1"
388 assert json.loads(tc[0]["arguments"]) == {"a": 1}
389 assert tc[1]["name"] == "t2"
390 assert json.loads(tc[1]["arguments"]) == {"b": 2}
391 assert tc[2]["name"] == "t3"
392 assert json.loads(tc[2]["arguments"]) == {"c": 3}
393
394 def test_vllm_streaming_character_by_character(self, parser, mock_request):
395 """Simulates worst-case vLLM fragmentation where
396 chunks arrive character-by-character."""
397 text = (
398 'Hi <|tools_prefix|>[{"get_weather": '
399 '{"location": "London"}}]<|tools_suffix|> '
400 )
401 chunks = list(text)
402 results = self._simulate_streaming(parser, mock_request, chunks)
403
404 content = self._collect_content(results)
405 assert "Hi" in content
406
407 tc = self._collect_tool_calls(results)
408 assert len(tc) == 1
409 assert tc[0]["name"] == "get_weather"
410 assert json.loads(tc[0]["arguments"]) == {"location": "London"}
411
412 def test_vllm_streaming_empty_deltas(self, parser, mock_request):
413 """Simulates vLLM stream producing empty string chunks
414 (e.g., hidden tokens or artifacts)."""
415 chunks = [
416 "Wait",
417 "",
418 "<|tools_prefix|>",
419 "",
420 '[{"get_weather": ',
421 "",
422 '{"location": "London"}}]',
423 "<|tools_suffix|>",
424 ]
425 results = self._simulate_streaming(parser, mock_request, chunks)
426
427 content = self._collect_content(results)
428 assert content == "Wait"
429
430 tc = self._collect_tool_calls(results)
431 assert len(tc) == 1
432 assert tc[0]["name"] == "get_weather"
433 assert json.loads(tc[0]["arguments"]) == {"location": "London"}