main
py 1,007 lines 36.4 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.parser.gemma4 import (
12 TOOL_CALL_END,
13 TOOL_CALL_START,
14 _parse_gemma4_args,
15 _parse_gemma4_array,
16 )
17 from vllm.tool_parsers.gemma4_engine_tool_parser import Gemma4EngineToolParser
18
19 # ---------------------------------------------------------------------------
20 # Fixtures
21 # ---------------------------------------------------------------------------
22
23
24 TOOL_CALL_START_ID = 48
25 TOOL_CALL_END_ID = 49
26 CHANNEL_START = "<|channel>"
27 CHANNEL_END = "<channel|>"
28 CHANNEL_START_ID = 50
29 CHANNEL_END_ID = 51
30
31
32 def _make_tool(name, properties):
33 from vllm.entrypoints.openai.chat_completion.protocol import (
34 ChatCompletionToolsParam,
35 )
36
37 return ChatCompletionToolsParam(
38 type="function",
39 function={
40 "name": name,
41 "parameters": {"type": "object", "properties": properties},
42 },
43 )
44
45
46 _TOOLS = [
47 _make_tool(
48 "set_status",
49 {
50 "is_active": {"type": "boolean"},
51 "count": {"type": "integer"},
52 "score": {"type": "number"},
53 },
54 ),
55 _make_tool(
56 "set_config",
57 {
58 "count": {"type": "integer"},
59 "active": {"type": "boolean"},
60 },
61 ),
62 _make_tool(
63 "search",
64 {
65 "input": {
66 "type": "object",
67 "properties": {"all": {"type": "boolean"}},
68 },
69 },
70 ),
71 _make_tool(
72 "set",
73 {
74 "flag": {"type": "boolean"},
75 "count": {"type": "integer"},
76 },
77 ),
78 _make_tool(
79 "Edit",
80 {
81 "file_path": {"type": "string"},
82 "old_string": {"type": "string"},
83 "new_string": {"type": "string"},
84 "replace_all": {"type": "boolean"},
85 },
86 ),
87 ]
88
89
90 @pytest.fixture
91 def mock_tokenizer():
92 vocab = {
93 TOOL_CALL_START: TOOL_CALL_START_ID,
94 TOOL_CALL_END: TOOL_CALL_END_ID,
95 CHANNEL_START: CHANNEL_START_ID,
96 CHANNEL_END: CHANNEL_END_ID,
97 }
98 decode_map = {v: k for k, v in vocab.items()}
99
100 tokenizer = MagicMock()
101 tokenizer.encode.return_value = [1, 2, 3]
102 tokenizer.get_vocab.return_value = vocab
103 tokenizer.decode.side_effect = lambda ids: decode_map.get(ids[0], f"tok{ids[0]}")
104 return tokenizer
105
106
107 @pytest.fixture
108 def parser(mock_tokenizer):
109 return Gemma4EngineToolParser(mock_tokenizer, tools=_TOOLS)
110
111
112 @pytest.fixture
113 def mock_request():
114 request = MagicMock(spec=ChatCompletionRequest)
115 request.tools = []
116 request.tool_choice = "auto"
117 return request
118
119
120 # ---------------------------------------------------------------------------
121 # Unit tests for _parse_gemma4_args (shared parser logic)
122 # ---------------------------------------------------------------------------
123
124
125 class TestParseGemma4Args:
126 """Values are returned as strings; type coercion to proper JSON types
127 happens at the engine layer."""
128
129 def test_empty_string(self):
130 assert _parse_gemma4_args("") == {}
131
132 def test_whitespace_only(self):
133 assert _parse_gemma4_args(" ") == {}
134
135 def test_single_string_value(self):
136 result = _parse_gemma4_args('location:<|"|>Paris<|"|>')
137 assert result == {"location": "Paris"}
138
139 def test_string_value_with_comma(self):
140 result = _parse_gemma4_args('location:<|"|>Paris, France<|"|>')
141 assert result == {"location": "Paris, France"}
142
143 def test_multiple_string_values(self):
144 result = _parse_gemma4_args(
145 'location:<|"|>San Francisco<|"|>,unit:<|"|>celsius<|"|>'
146 )
147 assert result == {"location": "San Francisco", "unit": "celsius"}
148
149 def test_integer_value(self):
150 result = _parse_gemma4_args("count:42")
151 assert result == {"count": "42"}
152
153 def test_float_value(self):
154 result = _parse_gemma4_args("score:3.14")
155 assert result == {"score": "3.14"}
156
157 def test_boolean_true(self):
158 result = _parse_gemma4_args("flag:true")
159 assert result == {"flag": "true"}
160
161 def test_boolean_false(self):
162 result = _parse_gemma4_args("flag:false")
163 assert result == {"flag": "false"}
164
165 def test_null_value(self):
166 result = _parse_gemma4_args("param:null")
167 assert result == {"param": "null"}
168
169 def test_mixed_types(self):
170 result = _parse_gemma4_args(
171 'name:<|"|>test<|"|>,count:42,active:true,score:3.14'
172 )
173 assert result == {
174 "name": "test",
175 "count": "42",
176 "active": "true",
177 "score": "3.14",
178 }
179
180 def test_nested_object(self):
181 result = _parse_gemma4_args('nested:{inner:<|"|>value<|"|>}')
182 assert result == {"nested": {"inner": "value"}}
183
184 def test_array_of_strings(self):
185 result = _parse_gemma4_args('items:[<|"|>a<|"|>,<|"|>b<|"|>]')
186 assert result == {"items": ["a", "b"]}
187
188 def test_delimited_keys_stripped(self):
189 """Keys wrapped in <|"|> delimiters are stripped."""
190 result = _parse_gemma4_args('<|"|>location<|"|>:<|"|>Paris<|"|>')
191 assert result == {"location": "Paris"}
192
193 result = _parse_gemma4_args('outer:{<|"|>inner<|"|>:<|"|>val<|"|>}')
194 assert result == {"outer": {"inner": "val"}}
195
196 result = _parse_gemma4_args('<|"|>name<|"|>:<|"|>Alice<|"|>,count:42')
197 assert result == {"name": "Alice", "count": "42"}
198
199 def test_unterminated_string(self):
200 """Unterminated strings should take everything after the delimiter."""
201 result = _parse_gemma4_args('key:<|"|>unterminated')
202 assert result == {"key": "unterminated"}
203
204 def test_empty_value(self):
205 """Key with no value after colon."""
206 result = _parse_gemma4_args("key:")
207 assert result == {"key": ""}
208
209 def test_empty_value_partial_withheld(self):
210 """Key with no value is withheld in partial mode to avoid premature emission."""
211 result = _parse_gemma4_args("key:", partial=True)
212 assert result == {}
213 # also with a space after the colon
214 result = _parse_gemma4_args("key: ", partial=True)
215 assert result == {}
216
217 def test_empty_value_after_other_keys_partial_withheld(self):
218 """Trailing key with no value is withheld; earlier keys are kept."""
219 result = _parse_gemma4_args('name:<|"|>test<|"|>,flag:', partial=True)
220 assert result == {"name": "test"}
221
222 def test_trailing_dot_float_partial_withheld(self):
223 """Bare float ending with '.' is withheld in partial mode.
224
225 Regression test for #42047: float("108.") → 108.0 causes
226 streaming diff corruption (108.0 → 108.2 becomes 108.02).
227 """
228 # Single key with trailing dot — withheld entirely
229 result = _parse_gemma4_args("left:108.,right:22.8", partial=True)
230 assert result == {}
231
232 # Stable key before trailing-dot key — stable key is kept
233 result = _parse_gemma4_args(
234 'name:<|"|>test<|"|>,score:3.,count:1', partial=True
235 )
236 assert result == {"name": "test"}
237
238 # Non-partial mode parses trailing dot normally
239 result = _parse_gemma4_args("left:108.,right:22.8", partial=False)
240 assert result == {"left": "108.", "right": "22.8"}
241
242 @pytest.mark.timeout(5)
243 def test_malformed_partial_array(self):
244 result = _parse_gemma4_args(":[t:[]")
245 assert isinstance(result, dict)
246
247
248 class TestParseGemma4Array:
249 def test_string_array(self):
250 result = _parse_gemma4_array('<|"|>a<|"|>,<|"|>b<|"|>')
251 assert result == ["a", "b"]
252
253 def test_empty_array(self):
254 result = _parse_gemma4_array("")
255 assert result == []
256
257 def test_bare_values(self):
258 result = _parse_gemma4_array("42,true,3.14")
259 assert result == ["42", "true", "3.14"]
260
261 @pytest.mark.timeout(5)
262 def test_string_element_with_closing_bracket(self):
263 result = _parse_gemma4_array('[<|"|>a]b<|"|>,<|"|>c<|"|>],<|"|>tail<|"|>')
264 assert result == [["a]b", "c"], "tail"]
265
266 @pytest.mark.timeout(5)
267 def test_stray_closing_bracket(self):
268 result = _parse_gemma4_array("42,]trailing")
269 assert result == ["42"]
270
271 def test_trailing_dot_float_partial_withheld(self):
272 """Array elements with trailing dot withheld in partial mode."""
273 result = _parse_gemma4_array("108.,22.8", partial=True)
274 assert result == []
275
276 # Stable elements before trailing-dot element are kept
277 result = _parse_gemma4_array("42,108.,3", partial=True)
278 assert result == ["42"]
279
280
281 # ---------------------------------------------------------------------------
282 # Non-streaming extraction tests
283 # ---------------------------------------------------------------------------
284
285
286 class TestExtractToolCalls:
287 def test_no_tool_calls(self, parser, mock_request):
288 model_output = "Hello, how can I help you today?"
289 result = parser.extract_tool_calls(model_output, mock_request)
290
291 assert result.tools_called is False
292 assert result.tool_calls == []
293 assert result.content == model_output
294
295 def test_single_tool_call(self, parser, mock_request):
296 model_output = (
297 '<|tool_call>call:get_weather{location:<|"|>London<|"|>}<tool_call|>'
298 )
299 result = parser.extract_tool_calls(model_output, mock_request)
300
301 assert result.tools_called is True
302 assert len(result.tool_calls) == 1
303 assert result.tool_calls[0].function.name == "get_weather"
304 args = json.loads(result.tool_calls[0].function.arguments)
305 assert args == {"location": "London"}
306
307 def test_multiple_arguments(self, parser, mock_request):
308 model_output = (
309 "<|tool_call>call:get_weather{"
310 'location:<|"|>San Francisco<|"|>,'
311 'unit:<|"|>celsius<|"|>}'
312 "<tool_call|>"
313 )
314 result = parser.extract_tool_calls(model_output, mock_request)
315
316 assert result.tools_called is True
317 assert len(result.tool_calls) == 1
318 assert result.tool_calls[0].function.name == "get_weather"
319 args = json.loads(result.tool_calls[0].function.arguments)
320 assert args == {"location": "San Francisco", "unit": "celsius"}
321
322 def test_text_before_tool_call(self, parser, mock_request):
323 model_output = (
324 "Let me check the weather for you. "
325 '<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}'
326 "<tool_call|>"
327 )
328 result = parser.extract_tool_calls(model_output, mock_request)
329
330 assert result.tools_called is True
331 assert result.content == "Let me check the weather for you."
332 assert len(result.tool_calls) == 1
333 assert result.tool_calls[0].function.name == "get_weather"
334
335 def test_multiple_tool_calls(self, parser, mock_request):
336 model_output = (
337 '<|tool_call>call:get_weather{location:<|"|>London<|"|>}'
338 "<tool_call|>"
339 '<|tool_call>call:get_time{location:<|"|>London<|"|>}'
340 "<tool_call|>"
341 )
342 result = parser.extract_tool_calls(model_output, mock_request)
343
344 assert result.tools_called is True
345 assert len(result.tool_calls) == 2
346 assert result.tool_calls[0].function.name == "get_weather"
347 assert result.tool_calls[1].function.name == "get_time"
348
349 def test_nested_arguments(self, parser, mock_request):
350 model_output = (
351 "<|tool_call>call:complex_function{"
352 'nested:{inner:<|"|>value<|"|>},'
353 'list:[<|"|>a<|"|>,<|"|>b<|"|>]}'
354 "<tool_call|>"
355 )
356 result = parser.extract_tool_calls(model_output, mock_request)
357
358 assert result.tools_called is True
359 assert len(result.tool_calls) == 1
360 assert result.tool_calls[0].function.name == "complex_function"
361 args = json.loads(result.tool_calls[0].function.arguments)
362 assert args == {"nested": {"inner": "value"}, "list": ["a", "b"]}
363
364 def test_tool_call_with_number_and_boolean(self, parser, mock_request):
365 model_output = (
366 "<|tool_call>call:set_status{"
367 "is_active:true,"
368 "count:42,"
369 "score:3.14}"
370 "<tool_call|>"
371 )
372 result = parser.extract_tool_calls(model_output, mock_request)
373
374 assert result.tools_called is True
375 assert len(result.tool_calls) == 1
376 assert result.tool_calls[0].function.name == "set_status"
377 args = json.loads(result.tool_calls[0].function.arguments)
378 assert args == {"is_active": True, "count": 42, "score": 3.14}
379
380 def test_incomplete_tool_call(self, parser, mock_request):
381 model_output = '<|tool_call>call:get_weather{location:<|"|>London'
382 result = parser.extract_tool_calls(model_output, mock_request)
383
384 assert result.tools_called is True
385 assert len(result.tool_calls) == 1
386 assert result.tool_calls[0].function.name == "get_weather"
387 args = json.loads(result.tool_calls[0].function.arguments)
388 assert args == {"location": "London"}
389
390 def test_hyphenated_function_name(self, parser, mock_request):
391 """Ensure function names with hyphens are parsed correctly."""
392 model_output = (
393 '<|tool_call>call:get-weather{location:<|"|>London<|"|>}<tool_call|>'
394 )
395 result = parser.extract_tool_calls(model_output, mock_request)
396
397 assert result.tools_called is True
398 assert result.tool_calls[0].function.name == "get-weather"
399
400 def test_dotted_function_name(self, parser, mock_request):
401 """Ensure function names with dots are parsed correctly."""
402 model_output = (
403 '<|tool_call>call:weather.get{location:<|"|>London<|"|>}<tool_call|>'
404 )
405 result = parser.extract_tool_calls(model_output, mock_request)
406
407 assert result.tools_called is True
408 assert result.tool_calls[0].function.name == "weather.get"
409
410 def test_no_arguments(self, parser, mock_request):
411 """Tool calls with empty arguments."""
412 model_output = "<|tool_call>call:get_status{}<tool_call|>"
413 result = parser.extract_tool_calls(model_output, mock_request)
414
415 assert result.tools_called is True
416 assert result.tool_calls[0].function.name == "get_status"
417 args = json.loads(result.tool_calls[0].function.arguments)
418 assert args == {}
419
420
421 # ---------------------------------------------------------------------------
422 # Streaming extraction tests
423 # ---------------------------------------------------------------------------
424
425
426 class TestStreamingExtraction:
427 """Tests for the streaming tool call extraction.
428
429 These simulate the token-by-token streaming that vLLM performs,
430 feeding incremental text to extract_tool_calls_streaming() and
431 verifying that the accumulated argument deltas form valid JSON.
432 """
433
434 _SPECIAL_TOKEN_IDS = {
435 TOOL_CALL_START: TOOL_CALL_START_ID,
436 TOOL_CALL_END: TOOL_CALL_END_ID,
437 CHANNEL_START: CHANNEL_START_ID,
438 CHANNEL_END: CHANNEL_END_ID,
439 }
440
441 def _simulate_streaming(
442 self, parser: Any, mock_request: Any, chunks: list[str]
443 ) -> list[tuple[Any, str]]:
444 """Feed chunks through the streaming parser and collect results.
445
446 Returns a list of (delta_message, accumulated_text) tuples.
447 """
448 results: list[tuple[Any, str]] = []
449 previous_text: str = ""
450 previous_token_ids: list[int] = []
451
452 for chunk in chunks:
453 current_text = previous_text + chunk
454 found: list[tuple[int, int]] = []
455 for token, tid in self._SPECIAL_TOKEN_IDS.items():
456 pos = 0
457 while True:
458 idx = chunk.find(token, pos)
459 if idx < 0:
460 break
461 found.append((idx, tid))
462 pos = idx + len(token)
463 found.sort()
464 delta_token_ids: list[int] = [tid for _, tid in found] if found else [0]
465
466 current_token_ids = previous_token_ids + delta_token_ids
467
468 delta = parser.extract_tool_calls_streaming(
469 previous_text=previous_text,
470 current_text=current_text,
471 delta_text=chunk,
472 previous_token_ids=tuple(previous_token_ids),
473 current_token_ids=tuple(current_token_ids),
474 delta_token_ids=tuple(delta_token_ids),
475 request=mock_request,
476 )
477 results.append((delta, current_text))
478 previous_text = current_text
479 previous_token_ids = list(current_token_ids)
480
481 return results
482
483 def _collect_arguments(self, results):
484 """Collect all argument deltas from streaming results into one string."""
485 args_text = ""
486 for delta, _ in results:
487 if delta and delta.tool_calls:
488 for tc in delta.tool_calls:
489 func = tc.function if isinstance(tc.function, dict) else tc.function
490 if isinstance(func, dict):
491 arg = func.get("arguments", "")
492 else:
493 arg = getattr(func, "arguments", "") or ""
494 if arg:
495 args_text += arg
496 return args_text
497
498 def _collect_function_name(self, results):
499 """Extract the function name from streaming results."""
500 for delta, _ in results:
501 if delta and delta.tool_calls:
502 for tc in delta.tool_calls:
503 func = tc.function if isinstance(tc.function, dict) else tc.function
504 if isinstance(func, dict):
505 name = func.get("name")
506 else:
507 name = getattr(func, "name", None)
508 if name:
509 return name
510 return None
511
512 def test_basic_streaming_single_tool(self, parser, mock_request):
513 """Simulate the exact streaming scenario from the bug report.
514
515 Model generates:
516 <|tool_call>call:get_weather{location:<|"|>Paris, France<|"|>}<tool_call|>
517
518 Expected: arguments should be valid JSON {"location": "Paris, France"}
519 """
520 chunks = [
521 "<|tool_call>",
522 "call:get_weather{",
523 'location:<|"|>Paris',
524 ", France",
525 '<|"|>}',
526 "<tool_call|>",
527 ]
528
529 results = self._simulate_streaming(parser, mock_request, chunks)
530
531 # Verify function name
532 name = self._collect_function_name(results)
533 assert name == "get_weather", f"Expected 'get_weather', got '{name}'"
534
535 # Verify arguments form valid JSON
536 args_text = self._collect_arguments(results)
537 assert args_text, "No arguments were streamed"
538 parsed_args = json.loads(args_text)
539 assert parsed_args == {"location": "Paris, France"}
540
541 def test_streaming_multi_arg(self, parser, mock_request):
542 """Streaming with multiple arguments."""
543 chunks = [
544 "<|tool_call>",
545 "call:get_weather{",
546 'location:<|"|>Tokyo<|"|>,',
547 'unit:<|"|>celsius<|"|>}',
548 "<tool_call|>",
549 ]
550
551 results = self._simulate_streaming(parser, mock_request, chunks)
552
553 name = self._collect_function_name(results)
554 assert name == "get_weather"
555
556 args_text = self._collect_arguments(results)
557 assert args_text
558 parsed_args = json.loads(args_text)
559 assert parsed_args == {"location": "Tokyo", "unit": "celsius"}
560
561 def test_streaming_no_extra_brace(self, parser, mock_request):
562 """Verify the closing } is NOT leaked into arguments (Bug #2)."""
563 chunks = [
564 "<|tool_call>",
565 "call:get_weather{",
566 'location:<|"|>London<|"|>}',
567 "<tool_call|>",
568 ]
569
570 results = self._simulate_streaming(parser, mock_request, chunks)
571 args_text = self._collect_arguments(results)
572 assert args_text
573
574 # The args text must be valid JSON (no extra })
575 parsed = json.loads(args_text)
576 assert parsed == {"location": "London"}
577
578 # Specifically assert no double-brace
579 assert args_text.count("}") <= 1, (
580 f"Arguments contain extra closing brace: {args_text!r}"
581 )
582
583 def test_streaming_no_unquoted_keys(self, parser, mock_request):
584 """Verify keys are properly quoted in JSON (Bug #1)."""
585 chunks = [
586 "<|tool_call>",
587 "call:get_weather{",
588 'location:<|"|>Paris<|"|>}',
589 "<tool_call|>",
590 ]
591
592 results = self._simulate_streaming(parser, mock_request, chunks)
593 args_text = self._collect_arguments(results)
594
595 # Must start with { and contain quoted key
596 assert args_text.lstrip().startswith("{"), (
597 f"Arguments don't start with '{{': {args_text!r}"
598 )
599 assert '"location"' in args_text, (
600 f"Key 'location' not properly quoted: {args_text!r}"
601 )
602
603 def test_streaming_name_no_call_prefix(self, parser, mock_request):
604 """Verify function name has no 'call:' prefix."""
605 chunks = [
606 "<|tool_call>",
607 "call:get_weather{",
608 'location:<|"|>Paris<|"|>}',
609 "<tool_call|>",
610 ]
611
612 results = self._simulate_streaming(parser, mock_request, chunks)
613 name = self._collect_function_name(results)
614 assert name == "get_weather"
615 assert not name.startswith("call:"), f"Name has 'call:' prefix: {name!r}"
616
617 def test_streaming_text_before_tool_call(self, parser, mock_request):
618 """Text before tool call should be emitted as content."""
619 chunks = [
620 "Let me check ",
621 "the weather. ",
622 "<|tool_call>",
623 "call:get_weather{",
624 'location:<|"|>London<|"|>}',
625 "<tool_call|>",
626 ]
627
628 results = self._simulate_streaming(parser, mock_request, chunks)
629
630 # First chunks should be content
631 content_parts = []
632 for delta, _ in results:
633 if delta and delta.content:
634 content_parts.append(delta.content)
635
636 assert "".join(content_parts).strip().startswith("Let me check")
637
638 def test_streaming_numeric_args(self, parser, mock_request):
639 """Streaming with numeric and boolean argument values."""
640 chunks = [
641 "<|tool_call>",
642 "call:set_config{",
643 "count:42,",
644 "active:true}",
645 "<tool_call|>",
646 ]
647
648 results = self._simulate_streaming(parser, mock_request, chunks)
649 args_text = self._collect_arguments(results)
650 assert args_text is not None
651 parsed_args = json.loads(args_text)
652 assert parsed_args["count"] == 42
653 assert parsed_args["active"] is True
654
655 def test_streaming_boolean_split_across_chunks(self, parser, mock_request):
656 """Boolean value split across token boundaries must not corrupt JSON."""
657 chunks = [
658 "<|tool_call>",
659 "call:search{input:{all:" + "true"[:3],
660 "e}}",
661 "<tool_call|>",
662 ]
663
664 results = self._simulate_streaming(parser, mock_request, chunks)
665 args_text = self._collect_arguments(results)
666 assert args_text, "No arguments were streamed"
667 parsed_args = json.loads(args_text)
668 assert parsed_args["input"]["all"] is True
669
670 def test_streaming_false_split_across_chunks(self, parser, mock_request):
671 """Boolean false split across chunks."""
672 chunks = [
673 "<|tool_call>",
674 "call:set{flag:" + "false"[:4],
675 "e}",
676 "<tool_call|>",
677 ]
678
679 results = self._simulate_streaming(parser, mock_request, chunks)
680 args_text = self._collect_arguments(results)
681 assert args_text, "No arguments were streamed"
682 parsed_args = json.loads(args_text)
683 assert parsed_args["flag"] is False
684
685 def test_streaming_number_split_across_chunks(self, parser, mock_request):
686 """Number split across chunks must not change type."""
687 chunks = [
688 "<|tool_call>",
689 "call:set{count:4",
690 "2}",
691 "<tool_call|>",
692 ]
693
694 results = self._simulate_streaming(parser, mock_request, chunks)
695 args_text = self._collect_arguments(results)
696 assert args_text, "No arguments were streamed"
697 parsed_args = json.loads(args_text)
698 assert parsed_args["count"] == 42
699
700 def test_streaming_empty_args(self, parser, mock_request):
701 """Tool call with no arguments."""
702 chunks = [
703 "<|tool_call>",
704 "call:get_status{}",
705 "<tool_call|>",
706 ]
707
708 results = self._simulate_streaming(parser, mock_request, chunks)
709 name = self._collect_function_name(results)
710 assert name == "get_status"
711
712 def test_streaming_split_delimiter_no_invalid_json(self, parser, mock_request):
713 """Partial <|"|> delimiter chars must not leak into streamed JSON.
714
715 Reproduces the bug from https://github.com/vllm-project/vllm/issues/38946
716 where a token boundary splits the string delimiter, leaving fragments
717 like '<|' at the end of a parsed value which then corrupt the JSON.
718 """
719 chunks = [
720 "<|tool_call>",
721 "call:todowrite{",
722 'content:<|"|>Buy milk<|',
723 '"|>}',
724 "<tool_call|>",
725 ]
726
727 results = self._simulate_streaming(parser, mock_request, chunks)
728
729 args_text = self._collect_arguments(results)
730 assert args_text, "No arguments were streamed"
731
732 # Must be valid JSON — the original bug caused a JSON parse error
733 parsed_args = json.loads(args_text)
734 assert parsed_args["content"] == "Buy milk"
735
736 # Ensure no raw delimiter fragments leaked into the JSON
737 assert "<|" not in args_text, (
738 f"Partial delimiter leaked into JSON: {args_text!r}"
739 )
740
741 def test_streaming_does_not_duplicate_plain_text_after_tool_call(
742 self, parser, mock_request
743 ):
744 """Buffered plain text after a tool call must not corrupt content."""
745 chunks = [
746 "<|tool_call>",
747 "call:get_weather{",
748 'location:<|"|>Paris<|"|>}',
749 "<tool_call|>",
750 "<",
751 "div>",
752 ]
753
754 results = self._simulate_streaming(parser, mock_request, chunks)
755 content_parts = [
756 delta.content for delta, _ in results if delta is not None and delta.content
757 ]
758 assert "".join(content_parts) == "<div>"
759 assert "<<div>" not in "".join(content_parts)
760
761 def test_streaming_html_argument_does_not_duplicate_tag_prefixes(
762 self, parser, mock_request
763 ):
764 """HTML content inside tool arguments must not be duplicated."""
765 chunks = [
766 "<|tool_call>",
767 "call:write_file{",
768 'path:<|"|>index.html<|"|>,',
769 'content:<|"|><!DOCTYPE html>\n<',
770 'html lang="zh-CN">\n<',
771 "head>\n <",
772 'meta charset="UTF-8">\n <',
773 'meta name="viewport" content="width=device-width">\n',
774 '<|"|>}',
775 "<tool_call|>",
776 ]
777
778 results = self._simulate_streaming(parser, mock_request, chunks)
779 args_text = self._collect_arguments(results)
780 assert args_text
781
782 parsed_args = json.loads(args_text)
783 assert parsed_args["path"] == "index.html"
784 assert (
785 parsed_args["content"] == "<!DOCTYPE html>\n"
786 '<html lang="zh-CN">\n'
787 "<head>\n"
788 ' <meta charset="UTF-8">\n'
789 ' <meta name="viewport" content="width=device-width">\n'
790 )
791
792 def _collect_tool_calls_by_index(self, results):
793 """Group streamed tool-call fragments by their ``index``.
794
795 Returns ``{index: {"name": str | None, "arguments": str}}`` where
796 ``arguments`` is the concatenation of every streamed argument
797 fragment for that index (which should form valid JSON once complete).
798 """
799 by_index: dict[int, dict[str, Any]] = {}
800 for delta, _ in results:
801 if not (delta and delta.tool_calls):
802 continue
803 for tc in delta.tool_calls:
804 entry = by_index.setdefault(tc.index, {"name": None, "arguments": ""})
805 func = tc.function
806 if isinstance(func, dict):
807 name = func.get("name")
808 arg = func.get("arguments", "")
809 else:
810 name = getattr(func, "name", None)
811 arg = getattr(func, "arguments", "") or ""
812 if name:
813 entry["name"] = name
814 if arg:
815 entry["arguments"] += arg
816 return by_index
817
818 def test_streaming_single_chunk_complete_tool_call(self, parser, mock_request):
819 """A backend may deliver a whole tool call in one streaming delta.
820
821 The start token, ``call:name{...}`` payload and the end token all
822 arrive in a single chunk. The parser must still emit one
823 ``DeltaToolCall`` with the correct name + complete arguments JSON
824 (rather than swallowing it and finishing with finish_reason="stop").
825 """
826 chunks = [
827 '<|tool_call>call:name_a_color{color_hex:<|"|>00ff11<|"|>}<tool_call|>',
828 ]
829
830 results = self._simulate_streaming(parser, mock_request, chunks)
831
832 # Exactly one delta should carry tool_calls, and it must not be
833 # emitted as plain content (which would yield finish_reason="stop").
834 tool_call_deltas = [
835 delta for delta, _ in results if delta is not None and delta.tool_calls
836 ]
837 assert len(tool_call_deltas) == 1, (
838 "Expected exactly one delta carrying the batched tool call"
839 )
840 assert all(
841 delta.content is None for delta, _ in results if delta is not None
842 ), "Complete tool call must not leak as content"
843
844 by_index = self._collect_tool_calls_by_index(results)
845 assert set(by_index) == {0}
846 assert by_index[0]["name"] == "name_a_color"
847 assert json.loads(by_index[0]["arguments"]) == {"color_hex": "00ff11"}
848
849 def test_streaming_multi_chunk_batched_tool_calls(self, parser, mock_request):
850 """A single delta may batch MULTIPLE complete tool calls.
851
852 ``<|tool_call>...<tool_call|><|tool_call>...<tool_call|>`` arriving in
853 one chunk must emit BOTH calls (one DeltaToolCall each, with distinct
854 indices), not just the first.
855 """
856 chunks = [
857 '<|tool_call>call:get_weather{location:<|"|>London<|"|>}<tool_call|>'
858 '<|tool_call>call:get_time{timezone:<|"|>GMT<|"|>}<tool_call|>',
859 ]
860
861 results = self._simulate_streaming(parser, mock_request, chunks)
862
863 by_index = self._collect_tool_calls_by_index(results)
864 assert set(by_index) == {0, 1}, (
865 f"Expected two tool calls (indices 0 and 1), got {sorted(by_index)}"
866 )
867
868 assert by_index[0]["name"] == "get_weather"
869 assert json.loads(by_index[0]["arguments"]) == {"location": "London"}
870
871 assert by_index[1]["name"] == "get_time"
872 assert json.loads(by_index[1]["arguments"]) == {"timezone": "GMT"}
873
874 def test_streaming_trailing_bare_bool_not_duplicated(self, parser, mock_request):
875 """Trailing bare boolean must not be streamed twice."""
876 chunks = [
877 "<|tool_call>",
878 "call:Edit{",
879 'file_path:<|"|>src/env.py<|"|>,',
880 'old_string:<|"|>old_val<|"|>,',
881 'new_string:<|"|>new_val<|"|>,',
882 "replace_all:",
883 "false}",
884 "<tool_call|>",
885 ]
886
887 results = self._simulate_streaming(parser, mock_request, chunks)
888 args_text = self._collect_arguments(results)
889 assert args_text, "No arguments were streamed"
890
891 parsed_args = json.loads(args_text)
892 assert parsed_args == {
893 "file_path": "src/env.py",
894 "old_string": "old_val",
895 "new_string": "new_val",
896 "replace_all": False,
897 }
898
899 assert args_text.count("replace_all") == 1
900
901
902 class TestBareAndMalformedOpeners:
903 """Tests for bare and malformed tool call openers.
904
905 See https://github.com/vllm-project/vllm/issues/53431.
906 """
907
908 def test_bare_opener_non_streaming(self, parser, mock_request):
909 model_output = '<|tool_call>:get_weather{location:<|"|>London<|"|>}<tool_call|>'
910 result = parser.extract_tool_calls(model_output, mock_request)
911
912 assert result.tools_called is True
913 assert len(result.tool_calls) == 1
914 assert result.tool_calls[0].function.name == "get_weather"
915 args = json.loads(result.tool_calls[0].function.arguments)
916 assert args == {"location": "London"}
917
918 def test_documented_form_still_works(self, parser, mock_request):
919 model_output = (
920 '<|tool_call>call:get_weather{location:<|"|>London<|"|>}<tool_call|>'
921 )
922 result = parser.extract_tool_calls(model_output, mock_request)
923
924 assert result.tools_called is True
925 assert len(result.tool_calls) == 1
926 assert result.tool_calls[0].function.name == "get_weather"
927
928 def test_bare_opener_multiple_args(self, parser, mock_request):
929 model_output = (
930 "<|tool_call>:get_weather{"
931 'location:<|"|>Tokyo<|"|>,unit:<|"|>celsius<|"|>}'
932 "<tool_call|>"
933 )
934 result = parser.extract_tool_calls(model_output, mock_request)
935
936 assert result.tools_called is True
937 assert result.tool_calls[0].function.name == "get_weather"
938 args = json.loads(result.tool_calls[0].function.arguments)
939 assert args == {"location": "Tokyo", "unit": "celsius"}
940
941 def test_malformed_no_brace_bounded(self, parser, mock_request):
942 model_output = "<|tool_call>call:bad_func no brace<tool_call|>"
943 result = parser.extract_tool_calls(model_output, mock_request)
944
945 assert result.tools_called is True
946 assert len(result.tool_calls) == 1
947 name = result.tool_calls[0].function.name
948 assert "<tool_call|>" not in name
949
950 def test_malformed_then_well_formed(self, parser, mock_request):
951 model_output = (
952 "<|tool_call>call:bad_func no brace<tool_call|>"
953 '<|tool_call>call:get_weather{location:<|"|>London<|"|>}'
954 "<tool_call|>"
955 )
956 result = parser.extract_tool_calls(model_output, mock_request)
957
958 assert result.tools_called is True
959 assert len(result.tool_calls) == 2
960 assert result.tool_calls[0].function.name == "bad_func no brace"
961 assert result.tool_calls[1].function.name == "get_weather"
962 assert "London" in result.tool_calls[1].function.arguments
963
964 def test_bare_malformed_then_well_formed(self, parser, mock_request):
965 model_output = (
966 "<|tool_call>:bad_func no brace<tool_call|>"
967 '<|tool_call>call:get_weather{location:<|"|>London<|"|>}'
968 "<tool_call|>"
969 )
970 result = parser.extract_tool_calls(model_output, mock_request)
971
972 assert result.tools_called is True
973 assert len(result.tool_calls) == 2
974 assert result.tool_calls[0].function.name == "bad_func no brace"
975 assert result.tool_calls[1].function.name == "get_weather"
976
977 def test_three_mixed_calls(self, parser, mock_request):
978 model_output = (
979 "<|tool_call>:bad no brace<tool_call|>"
980 '<|tool_call>call:search{input:<|"|>hello<|"|>}<tool_call|>'
981 "<|tool_call>:set{flag:true,count:5}<tool_call|>"
982 )
983 result = parser.extract_tool_calls(model_output, mock_request)
984
985 assert result.tools_called is True
986 assert len(result.tool_calls) == 3
987 assert result.tool_calls[0].function.name == "bad no brace"
988 assert result.tool_calls[1].function.name == "search"
989 assert result.tool_calls[2].function.name == "set"
990
991 def test_colon_in_content_not_broken(self, parser, mock_request):
992 model_output = (
993 "Hello: world"
994 '<|tool_call>:get_weather{location:<|"|>London<|"|>}<tool_call|>'
995 )
996 result = parser.extract_tool_calls(model_output, mock_request)
997
998 assert result.tools_called is True
999 assert result.content is not None
1000 assert "Hello" in result.content
1001
1002 def test_bare_opener_empty_args(self, parser, mock_request):
1003 model_output = "<|tool_call>:set_status{}<tool_call|>"
1004 result = parser.extract_tool_calls(model_output, mock_request)
1005
1006 assert result.tools_called is True
1007 assert result.tool_calls[0].function.name == "set_status"