main
py 1,208 lines 45.2 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 """Unit tests for DeepSeekV32EngineToolParser.
5
6 These tests use a minimal mock tokenizer so no real model weights are required.
7 """
8
9 import json
10 from unittest.mock import MagicMock
11
12 import pytest
13 from openai.types.responses.function_tool import FunctionTool
14
15 from tests.tool_parsers.utils import run_tool_extraction_streaming
16 from vllm.entrypoints.openai.chat_completion.protocol import (
17 ChatCompletionToolsParam,
18 FunctionDefinition,
19 )
20 from vllm.tool_parsers.deepseekv32_engine_tool_parser import (
21 DeepSeekV32EngineToolParser,
22 )
23
24 pytestmark = pytest.mark.skip_global_cleanup
25
26 # ---------------------------------------------------------------------------
27 # Helpers
28 # ---------------------------------------------------------------------------
29
30 # Token IDs are not used by the V32 parser logic, so we only need the
31 # tokenizer object to be truthy (the parser checks `if not self.model_tokenizer`).
32 MOCK_TOKENIZER = MagicMock()
33 MOCK_TOKENIZER.get_vocab.return_value = {}
34 MOCK_TOKENIZER.tokenize.return_value = []
35
36
37 def make_parser(tools=None) -> DeepSeekV32EngineToolParser:
38 return DeepSeekV32EngineToolParser(MOCK_TOKENIZER, tools=tools)
39
40
41 def make_tool_param(name: str, params: dict) -> MagicMock:
42 """Build a mock tool matching the ChatCompletionToolsParam shape."""
43 tool = MagicMock()
44 tool.function.name = name
45 tool.function.parameters = params
46 return tool
47
48
49 def make_request(tools=None) -> MagicMock:
50 req = MagicMock()
51 req.tools = tools
52 return req
53
54
55 # Shorthand for the DSML tokens used throughout
56 FC_START = "<|DSML|function_calls>"
57 FC_END = "</|DSML|function_calls>"
58 INV_START = '<|DSML|invoke name="'
59 INV_END = "</|DSML|invoke>"
60 PARAM_START = '<|DSML|parameter name="'
61 PARAM_END = "</|DSML|parameter>"
62
63
64 def build_tool_call(func_name: str, params: dict[str, str]) -> str:
65 """Build a complete model-output tool call string."""
66 param_strs = "".join(
67 f'{PARAM_START}{k}" string="true">{v}{PARAM_END}' for k, v in params.items()
68 )
69 return f'{FC_START}\n{INV_START}{func_name}">\n{param_strs}\n{INV_END}\n{FC_END}'
70
71
72 # ---------------------------------------------------------------------------
73 # Tests: extract_tool_calls (non-streaming)
74 # ---------------------------------------------------------------------------
75
76
77 class TestExtractToolCalls:
78 @pytest.fixture
79 def parser(self):
80 return make_parser()
81
82 def test_no_tool_call(self, parser):
83 result = parser.extract_tool_calls("just some text", None)
84 assert not result.tools_called
85 assert result.tool_calls == []
86 assert result.content == "just some text"
87
88 def test_single_tool_no_params(self, parser):
89 model_output = f'{FC_START}\n{INV_START}get_time">\n{INV_END}\n{FC_END}'
90 result = parser.extract_tool_calls(model_output, None)
91 assert result.tools_called
92 assert len(result.tool_calls) == 1
93 assert result.tool_calls[0].function.name == "get_time"
94 assert json.loads(result.tool_calls[0].function.arguments) == {}
95
96 def test_single_tool_with_params(self, parser):
97 model_output = build_tool_call(
98 "get_weather", {"location": "SF", "date": "2024-01-16"}
99 )
100 result = parser.extract_tool_calls(model_output, None)
101 assert result.tools_called
102 assert len(result.tool_calls) == 1
103 tc = result.tool_calls[0]
104 assert tc.function.name == "get_weather"
105 assert json.loads(tc.function.arguments) == {
106 "location": "SF",
107 "date": "2024-01-16",
108 }
109
110 def test_content_before_tool_call(self, parser):
111 model_output = "Sure, let me check! " + build_tool_call(
112 "get_weather", {"location": "NYC"}
113 )
114 result = parser.extract_tool_calls(model_output, None)
115 assert result.tools_called
116 assert result.content == "Sure, let me check! "
117
118 def test_no_content_prefix_returns_none(self, parser):
119 model_output = build_tool_call("get_weather", {"location": "NYC"})
120 result = parser.extract_tool_calls(model_output, None)
121 assert result.tools_called
122 assert result.content is None
123
124 def test_multiple_tools(self, parser):
125 model_output = (
126 f"{FC_START}\n"
127 f'{INV_START}get_weather">\n'
128 f'{PARAM_START}location" string="true">SF{PARAM_END}\n'
129 f"{INV_END}\n"
130 f'{INV_START}get_weather">\n'
131 f'{PARAM_START}location" string="true">NYC{PARAM_END}\n'
132 f"{INV_END}\n"
133 f"{FC_END}"
134 )
135 result = parser.extract_tool_calls(model_output, None)
136 assert result.tools_called
137 assert len(result.tool_calls) == 2
138 assert json.loads(result.tool_calls[0].function.arguments) == {"location": "SF"}
139 assert json.loads(result.tool_calls[1].function.arguments) == {
140 "location": "NYC"
141 }
142
143 def test_type_conversion_in_non_streaming(self):
144 """Non-streaming extraction must convert params using the tool schema."""
145 tool = ChatCompletionToolsParam(
146 function=FunctionDefinition(
147 name="toggle",
148 parameters={
149 "type": "object",
150 "properties": {
151 "enabled": {"type": "boolean"},
152 "count": {"type": "integer"},
153 },
154 },
155 ),
156 )
157 parser = make_parser(tools=[tool])
158 model_output = (
159 f"{FC_START}\n"
160 f'{INV_START}toggle">\n'
161 f'{PARAM_START}enabled" string="false">true{PARAM_END}\n'
162 f'{PARAM_START}count" string="false">42{PARAM_END}\n'
163 f"{INV_END}\n"
164 f"{FC_END}"
165 )
166 result = parser.extract_tool_calls(model_output, None)
167 assert result.tools_called
168 assert len(result.tool_calls) == 1
169 args = json.loads(result.tool_calls[0].function.arguments)
170 assert args == {"enabled": True, "count": 42}
171 assert isinstance(args["enabled"], bool)
172 assert isinstance(args["count"], int)
173
174 def test_string_attr_true_coerced_by_schema(self):
175 """string="true" delivers a string, but the engine's schema-aware
176 type fixer coerces it to the schema type (integer)."""
177 tool = ChatCompletionToolsParam(
178 function=FunctionDefinition(
179 name="score",
180 parameters={
181 "type": "object",
182 "properties": {
183 "value": {"type": "integer"},
184 },
185 },
186 ),
187 )
188 parser = make_parser(tools=[tool])
189 model_output = (
190 f"{FC_START}\n"
191 f'{INV_START}score">\n'
192 f'{PARAM_START}value" string="true">42{PARAM_END}\n'
193 f"{INV_END}\n"
194 f"{FC_END}"
195 )
196 result = parser.extract_tool_calls(model_output, None)
197 assert result.tools_called
198 args = json.loads(result.tool_calls[0].function.arguments)
199 assert args == {"value": 42}
200 assert isinstance(args["value"], int)
201
202 def test_string_attr_false_allows_schema_conversion(self):
203 """string="false" allows the parser to convert via the tool schema."""
204 tool = ChatCompletionToolsParam(
205 function=FunctionDefinition(
206 name="score",
207 parameters={
208 "type": "object",
209 "properties": {
210 "value": {"type": "integer"},
211 },
212 },
213 ),
214 )
215 parser = make_parser(tools=[tool])
216 model_output = (
217 f"{FC_START}\n"
218 f'{INV_START}score">\n'
219 f'{PARAM_START}value" string="false">42{PARAM_END}\n'
220 f"{INV_END}\n"
221 f"{FC_END}"
222 )
223 result = parser.extract_tool_calls(model_output, None)
224 assert result.tools_called
225 args = json.loads(result.tool_calls[0].function.arguments)
226 assert args == {"value": 42}
227 assert isinstance(args["value"], int)
228
229 def test_composed_schema_converts_object_and_array_params(self):
230 """Composed JSON Schema types must still drive DSML type coercion."""
231 tool = ChatCompletionToolsParam(
232 function=FunctionDefinition(
233 name="set_timer",
234 parameters={
235 "type": "object",
236 "properties": {
237 "wait": {
238 "anyOf": [
239 {
240 "type": "object",
241 "properties": {
242 "type": {"const": "until"},
243 "date": {"type": "string"},
244 },
245 },
246 {
247 "type": "object",
248 "properties": {
249 "type": {"const": "for"},
250 "minutes": {"type": "number"},
251 },
252 },
253 ],
254 },
255 "patches": {
256 "oneOf": [
257 {"type": "array", "items": {"type": "object"}},
258 {"type": "null"},
259 ],
260 },
261 },
262 },
263 ),
264 )
265 parser = make_parser(tools=[tool])
266 model_output = (
267 f"{FC_START}\n"
268 f'{INV_START}set_timer">\n'
269 f'{PARAM_START}wait" string="false">'
270 f'{{"type":"for","minutes":2880}}'
271 f"{PARAM_END}\n"
272 f'{PARAM_START}patches" string="false">'
273 f'[{{"op":"replace","path":"/schedule","value":"quiet"}}]'
274 f"{PARAM_END}\n"
275 f"{INV_END}\n"
276 f"{FC_END}"
277 )
278 result = parser.extract_tool_calls(model_output, None)
279 assert result.tools_called
280 args = json.loads(result.tool_calls[0].function.arguments)
281 assert args == {
282 "wait": {"type": "for", "minutes": 2880},
283 "patches": [{"op": "replace", "path": "/schedule", "value": "quiet"}],
284 }
285 assert isinstance(args["wait"], dict)
286 assert isinstance(args["patches"], list)
287
288 def test_string_attr_true_coerced_by_composed_schema(self):
289 """string="true" delivers a JSON string, but the engine's schema-aware
290 type fixer coerces it to the composed schema type (object)."""
291 tool = ChatCompletionToolsParam(
292 function=FunctionDefinition(
293 name="set_timer",
294 parameters={
295 "type": "object",
296 "properties": {
297 "wait": {
298 "anyOf": [
299 {"type": "object"},
300 {"type": "null"},
301 ],
302 },
303 },
304 },
305 ),
306 )
307 parser = make_parser(tools=[tool])
308 model_output = (
309 f"{FC_START}\n"
310 f'{INV_START}set_timer">\n'
311 f'{PARAM_START}wait" string="true">'
312 f'{{"type":"for","minutes":2880}}'
313 f"{PARAM_END}\n"
314 f"{INV_END}\n"
315 f"{FC_END}"
316 )
317 result = parser.extract_tool_calls(model_output, None)
318 assert result.tools_called
319 args = json.loads(result.tool_calls[0].function.arguments)
320 assert args == {"wait": {"type": "for", "minutes": 2880}}
321
322 def test_arguments_wrapper_repaired(self):
323 """A single 'arguments' wrapper parameter must be unwrapped when it
324 is not part of the tool schema and the inner object matches schema fields."""
325 tool = ChatCompletionToolsParam(
326 function=FunctionDefinition(
327 name="get_weather",
328 parameters={
329 "type": "object",
330 "properties": {
331 "location": {"type": "string"},
332 },
333 },
334 ),
335 )
336 parser = make_parser(tools=[tool])
337 model_output = (
338 f"{FC_START}\n"
339 f'{INV_START}get_weather">\n'
340 f'{PARAM_START}arguments" string="false">'
341 f'{{"location":"Beijing"}}'
342 f"{PARAM_END}\n"
343 f"{INV_END}\n"
344 f"{FC_END}"
345 )
346 result = parser.extract_tool_calls(model_output, None)
347 assert result.tools_called
348 args = json.loads(result.tool_calls[0].function.arguments)
349 assert args == {"location": "Beijing"}
350
351 def test_input_wrapper_repaired(self):
352 """A single 'input' wrapper parameter must be unwrapped similarly."""
353 tool = ChatCompletionToolsParam(
354 function=FunctionDefinition(
355 name="get_weather",
356 parameters={
357 "type": "object",
358 "properties": {
359 "location": {"type": "string"},
360 },
361 },
362 ),
363 )
364 parser = make_parser(tools=[tool])
365 model_output = (
366 f"{FC_START}\n"
367 f'{INV_START}get_weather">\n'
368 f'{PARAM_START}input" string="true">'
369 f'{{"location":"Beijing"}}'
370 f"{PARAM_END}\n"
371 f"{INV_END}\n"
372 f"{FC_END}"
373 )
374 result = parser.extract_tool_calls(model_output, None)
375 assert result.tools_called
376 args = json.loads(result.tool_calls[0].function.arguments)
377 assert args == {"location": "Beijing"}
378
379 def test_object_and_array_params(self):
380 """Object and array schema types must be JSON-parsed, not left as strings."""
381 tool = ChatCompletionToolsParam(
382 function=FunctionDefinition(
383 name="update",
384 parameters={
385 "type": "object",
386 "properties": {
387 "tags": {"type": "array"},
388 "meta": {"type": "object"},
389 },
390 },
391 ),
392 )
393 parser = make_parser(tools=[tool])
394 model_output = (
395 f"{FC_START}\n"
396 f'{INV_START}update">\n'
397 f'{PARAM_START}tags" string="false">["a", "b"]{PARAM_END}\n'
398 f'{PARAM_START}meta" string="false">{{"k": 1}}{PARAM_END}\n'
399 f"{INV_END}\n"
400 f"{FC_END}"
401 )
402 result = parser.extract_tool_calls(model_output, None)
403 assert result.tools_called
404 args = json.loads(result.tool_calls[0].function.arguments)
405 assert args["tags"] == ["a", "b"]
406 assert isinstance(args["tags"], list)
407 assert args["meta"] == {"k": 1}
408 assert isinstance(args["meta"], dict)
409
410 def test_number_param(self):
411 """Number (float) schema type must be converted correctly."""
412 tool = ChatCompletionToolsParam(
413 function=FunctionDefinition(
414 name="measure",
415 parameters={
416 "type": "object",
417 "properties": {
418 "ratio": {"type": "number"},
419 "whole": {"type": "number"},
420 },
421 },
422 ),
423 )
424 parser = make_parser(tools=[tool])
425 model_output = (
426 f"{FC_START}\n"
427 f'{INV_START}measure">\n'
428 f'{PARAM_START}ratio" string="false">3.14{PARAM_END}\n'
429 f'{PARAM_START}whole" string="false">5.0{PARAM_END}\n'
430 f"{INV_END}\n"
431 f"{FC_END}"
432 )
433 result = parser.extract_tool_calls(model_output, None)
434 args = json.loads(result.tool_calls[0].function.arguments)
435 assert args["ratio"] == pytest.approx(3.14)
436 assert args["whole"] == 5
437 assert isinstance(args["whole"], int)
438
439 def test_multi_typed_schema(self):
440 """Schema with type: ["integer", "null"] must handle both cases."""
441 tool = ChatCompletionToolsParam(
442 function=FunctionDefinition(
443 name="set_val",
444 parameters={
445 "type": "object",
446 "properties": {
447 "count": {"type": ["integer", "null"]},
448 "label": {"type": ["string", "null"]},
449 },
450 },
451 ),
452 )
453 parser = make_parser(tools=[tool])
454 model_output = (
455 f"{FC_START}\n"
456 f'{INV_START}set_val">\n'
457 f'{PARAM_START}count" string="false">42{PARAM_END}\n'
458 f'{PARAM_START}label" string="false">hello{PARAM_END}\n'
459 f"{INV_END}\n"
460 f"{FC_END}"
461 )
462 result = parser.extract_tool_calls(model_output, None)
463 args = json.loads(result.tool_calls[0].function.arguments)
464 assert args["count"] == 42
465 assert isinstance(args["count"], int)
466 assert args["label"] == "hello"
467
468 def test_multi_typed_null_value(self):
469 """Literal 'null' must become None when the schema includes 'null'."""
470 tool = ChatCompletionToolsParam(
471 function=FunctionDefinition(
472 name="clear",
473 parameters={
474 "type": "object",
475 "properties": {
476 "value": {"type": ["integer", "null"]},
477 },
478 },
479 ),
480 )
481 parser = make_parser(tools=[tool])
482 model_output = (
483 f"{FC_START}\n"
484 f'{INV_START}clear">\n'
485 f'{PARAM_START}value" string="false">null{PARAM_END}\n'
486 f"{INV_END}\n"
487 f"{FC_END}"
488 )
489 result = parser.extract_tool_calls(model_output, None)
490 args = json.loads(result.tool_calls[0].function.arguments)
491 assert args["value"] is None
492
493 def test_null_coerced_back_to_string_by_schema(self):
494 """string="false" with 'null' is json-parsed to None, but the
495 engine's schema fixer coerces it back to "null" for string schemas."""
496 tool = ChatCompletionToolsParam(
497 function=FunctionDefinition(
498 name="echo",
499 parameters={
500 "type": "object",
501 "properties": {
502 "text": {"type": "string"},
503 },
504 },
505 ),
506 )
507 parser = make_parser(tools=[tool])
508 model_output = (
509 f"{FC_START}\n"
510 f'{INV_START}echo">\n'
511 f'{PARAM_START}text" string="false">null{PARAM_END}\n'
512 f"{INV_END}\n"
513 f"{FC_END}"
514 )
515 result = parser.extract_tool_calls(model_output, None)
516 args = json.loads(result.tool_calls[0].function.arguments)
517 assert args["text"] == "null"
518 assert isinstance(args["text"], str)
519
520 def test_no_schema_parses_json(self):
521 """Without a tool schema, string='false' params are JSON-parsed."""
522 parser = make_parser(tools=None)
523 model_output = (
524 f"{FC_START}\n"
525 f'{INV_START}unknown_fn">\n'
526 f'{PARAM_START}count" string="false">42{PARAM_END}\n'
527 f'{PARAM_START}flag" string="false">true{PARAM_END}\n'
528 f"{INV_END}\n"
529 f"{FC_END}"
530 )
531 result = parser.extract_tool_calls(model_output, None)
532 assert result.tools_called
533 args = json.loads(result.tool_calls[0].function.arguments)
534 assert args["count"] == 42
535 assert args["flag"] is True
536
537
538 # ---------------------------------------------------------------------------
539 # Tests: extract_tool_calls_streaming
540 # ---------------------------------------------------------------------------
541
542
543 class TestExtractToolCallsStreaming:
544 """Simulate character-by-character streaming and verify reconstructed args."""
545
546 @pytest.fixture
547 def parser(self):
548 return make_parser()
549
550 def _stream(self, parser, full_text: str, request=None):
551 """Drive the parser line-by-line and collect non-None deltas.
552
553 Real tokenizers emit multi-character chunks, not individual characters.
554 Streaming character-by-character would never deliver the full sentinel
555 token (e.g. '|DSML|') in a single delta, so we split on newlines to
556 ensure each sentinel always lands in one chunk.
557 """
558 if request is None:
559 request = make_request()
560 # Split into lines, preserving the trailing newline in each chunk.
561 chunks: list[str] = []
562 remaining = full_text
563 while remaining:
564 nl = remaining.find("\n")
565 if nl == -1:
566 chunks.append(remaining)
567 break
568 chunks.append(remaining[: nl + 1])
569 remaining = remaining[nl + 1 :]
570
571 deltas = []
572 prev = ""
573 for chunk in chunks:
574 curr = prev + chunk
575 result = parser.extract_tool_calls_streaming(
576 previous_text=prev,
577 current_text=curr,
578 delta_text=chunk,
579 previous_token_ids=[],
580 current_token_ids=[],
581 delta_token_ids=[1],
582 request=request,
583 )
584 prev = curr
585 if result is not None:
586 deltas.append(result)
587 return deltas
588
589 def _reconstruct_args(self, deltas, tool_index=0) -> str:
590 """Concatenate all argument fragments for a given tool index."""
591 fragments = []
592 for d in deltas:
593 if d.tool_calls:
594 for tc in d.tool_calls:
595 if tc.index == tool_index and tc.function and tc.function.arguments:
596 fragments.append(tc.function.arguments)
597 return "".join(fragments)
598
599 def test_plain_content_no_tool(self, parser):
600 full_text = "Hello, world!"
601 deltas = self._stream(parser, full_text)
602 content = "".join(d.content for d in deltas if d.content is not None)
603 assert "Hello, world!" in content
604 assert all(not d.tool_calls for d in deltas)
605
606 def test_single_tool_streaming(self, parser):
607 full_text = build_tool_call("get_weather", {"location": "SF"})
608 deltas = self._stream(parser, full_text)
609 args_str = self._reconstruct_args(deltas)
610 assert json.loads(args_str) == {"location": "SF"}
611
612 def test_tool_name_emitted(self, parser):
613 full_text = build_tool_call("my_func", {"x": "1"})
614 deltas = self._stream(parser, full_text)
615 func_names = [
616 tc.function.name
617 for d in deltas
618 if d.tool_calls
619 for tc in d.tool_calls
620 if tc.function and tc.function.name
621 ]
622 assert any("my_func" in n for n in func_names)
623
624 def test_content_before_tool_call_streaming(self, parser):
625 full_text = "Thinking... " + build_tool_call("fn", {"a": "b"})
626 deltas = self._stream(parser, full_text)
627 content = "".join(d.content for d in deltas if d.content is not None)
628 assert "Thinking" in content
629
630 def test_type_conversion_in_streaming(self):
631 tool = ChatCompletionToolsParam(
632 function=FunctionDefinition(
633 name="add",
634 parameters={
635 "type": "object",
636 "properties": {
637 "x": {"type": "integer"},
638 "y": {"type": "integer"},
639 },
640 },
641 ),
642 )
643 parser = make_parser(tools=[tool])
644 full_text = (
645 f"{FC_START}\n"
646 f'{INV_START}add">\n'
647 f'{PARAM_START}x" string="false">3{PARAM_END}\n'
648 f'{PARAM_START}y" string="false">4{PARAM_END}\n'
649 f"{INV_END}\n"
650 f"{FC_END}"
651 )
652 deltas = self._stream(parser, full_text)
653 args_str = self._reconstruct_args(deltas)
654 assert json.loads(args_str) == {"x": 3, "y": 4}
655
656 def test_string_attr_true_coerced_by_schema_streaming(self):
657 """Streaming: string='true' delivers a string but the engine's
658 schema fixer coerces it to the schema type (integer)."""
659 tool = ChatCompletionToolsParam(
660 function=FunctionDefinition(
661 name="score",
662 parameters={
663 "type": "object",
664 "properties": {
665 "value": {"type": "integer"},
666 },
667 },
668 ),
669 )
670 parser = make_parser(tools=[tool])
671 full_text = (
672 f"{FC_START}\n"
673 f'{INV_START}score">\n'
674 f'{PARAM_START}value" string="true">42{PARAM_END}\n'
675 f"{INV_END}\n"
676 f"{FC_END}"
677 )
678 deltas = self._stream(parser, full_text)
679 args_str = self._reconstruct_args(deltas)
680 args = json.loads(args_str)
681 assert args == {"value": 42}
682 assert isinstance(args["value"], int)
683
684 def test_composed_schema_conversion_in_streaming(self):
685 tool = ChatCompletionToolsParam(
686 function=FunctionDefinition(
687 name="set_timer",
688 parameters={
689 "type": "object",
690 "properties": {
691 "wait": {
692 "anyOf": [
693 {"type": "object"},
694 {"type": "null"},
695 ],
696 },
697 "patches": {
698 "oneOf": [
699 {"type": "array", "items": {"type": "object"}},
700 {"type": "null"},
701 ],
702 },
703 },
704 },
705 ),
706 )
707 parser = make_parser(tools=[tool])
708 full_text = (
709 f"{FC_START}\n"
710 f'{INV_START}set_timer">\n'
711 f'{PARAM_START}wait" string="false">'
712 f'{{"type":"for","minutes":2880}}'
713 f"{PARAM_END}\n"
714 f'{PARAM_START}patches" string="false">'
715 f'[{{"op":"replace","path":"/schedule","value":"quiet"}}]'
716 f"{PARAM_END}\n"
717 f"{INV_END}\n"
718 f"{FC_END}"
719 )
720 deltas = self._stream(parser, full_text)
721 args = json.loads(self._reconstruct_args(deltas))
722 assert args == {
723 "wait": {"type": "for", "minutes": 2880},
724 "patches": [{"op": "replace", "path": "/schedule", "value": "quiet"}],
725 }
726
727 def test_responses_function_tool_schema_in_streaming(self):
728 """Responses API FunctionTool schemas must drive streaming conversion."""
729 tool = FunctionTool(
730 type="function",
731 name="toggle",
732 parameters={
733 "type": "object",
734 "properties": {
735 "enabled": {"type": "boolean"},
736 "count": {"type": "integer"},
737 },
738 },
739 )
740 parser = make_parser(tools=[tool])
741 full_text = (
742 f"{FC_START}\n"
743 f'{INV_START}toggle">\n'
744 f'{PARAM_START}enabled" string="false">true{PARAM_END}\n'
745 f'{PARAM_START}count" string="false">42{PARAM_END}\n'
746 f"{INV_END}\n"
747 f"{FC_END}"
748 )
749
750 deltas = self._stream(parser, full_text)
751 args = json.loads(self._reconstruct_args(deltas))
752
753 assert args == {"enabled": True, "count": 42}
754 assert isinstance(args["enabled"], bool)
755 assert isinstance(args["count"], int)
756
757 def test_streaming_matches_non_streaming_conversion_fallbacks(self):
758 """Streaming must reuse conversion fallback semantics for string=false."""
759 tool = ChatCompletionToolsParam(
760 function=FunctionDefinition(
761 name="coerce",
762 parameters={
763 "type": "object",
764 "properties": {
765 "union_value": {"type": ["null", "string"]},
766 "bad_int": {"type": "integer"},
767 "nullable_string": {"type": ["null", "string"]},
768 "null_string": {"type": "string"},
769 "whole_number": {"type": "number"},
770 },
771 },
772 ),
773 )
774 parser = make_parser(tools=[tool])
775 full_text = (
776 f"{FC_START}\n"
777 f'{INV_START}coerce">\n'
778 f'{PARAM_START}union_value" string="false">hello{PARAM_END}\n'
779 f'{PARAM_START}bad_int" string="false">abc{PARAM_END}\n'
780 f'{PARAM_START}nullable_string" string="false">null{PARAM_END}\n'
781 f'{PARAM_START}null_string" string="false">null{PARAM_END}\n'
782 f'{PARAM_START}whole_number" string="false">3.0{PARAM_END}\n'
783 f"{INV_END}\n"
784 f"{FC_END}"
785 )
786
787 non_stream = parser.extract_tool_calls(full_text, None)
788 non_stream_args = json.loads(non_stream.tool_calls[0].function.arguments)
789
790 deltas = self._stream(parser, full_text)
791 stream_args = json.loads(self._reconstruct_args(deltas))
792
793 assert stream_args == non_stream_args
794 assert stream_args == {
795 "union_value": "hello",
796 "bad_int": "abc",
797 "nullable_string": None,
798 "null_string": "null",
799 "whole_number": 3,
800 }
801
802 def test_multiple_tools_streaming(self, parser):
803 full_text = (
804 f"{FC_START}\n"
805 f'{INV_START}func_a">\n'
806 f'{PARAM_START}p" string="true">v1{PARAM_END}\n'
807 f"{INV_END}\n"
808 f'{INV_START}func_b">\n'
809 f'{PARAM_START}q" string="true">v2{PARAM_END}\n'
810 f"{INV_END}\n"
811 f"{FC_END}"
812 )
813 deltas = self._stream(parser, full_text)
814
815 # Collect function names by index
816 names_by_index: dict[int, str] = {}
817 for d in deltas:
818 if d.tool_calls:
819 for tc in d.tool_calls:
820 if tc.function and tc.function.name:
821 names_by_index[tc.index] = tc.function.name
822
823 assert names_by_index.get(0) == "func_a"
824 assert names_by_index.get(1) == "func_b"
825
826 assert json.loads(self._reconstruct_args(deltas, tool_index=0)) == {"p": "v1"}
827 assert json.loads(self._reconstruct_args(deltas, tool_index=1)) == {"q": "v2"}
828
829 def test_state_reset_on_new_stream(self):
830 """A fresh parser instance must produce identical results."""
831 full_text = build_tool_call("fn", {"k": "v"})
832 # First stream
833 self._stream(make_parser(), full_text)
834 # Second stream with fresh parser
835 deltas2 = self._stream(make_parser(), full_text)
836 assert json.loads(self._reconstruct_args(deltas2)) == {"k": "v"}
837
838 def test_empty_arguments_streaming(self, parser):
839 """Invoke block with zero parameters should produce empty JSON."""
840 full_text = f'{FC_START}\n{INV_START}get_time">\n{INV_END}\n{FC_END}'
841 deltas = self._stream(parser, full_text)
842 args_str = self._reconstruct_args(deltas)
843 assert json.loads(args_str) == {}
844
845 def test_unique_tool_call_ids(self, parser):
846 """Each tool call in a parallel stream must get a distinct id."""
847 full_text = (
848 f"{FC_START}\n"
849 f'{INV_START}fn_a">\n'
850 f'{PARAM_START}x" string="true">1{PARAM_END}\n'
851 f"{INV_END}\n"
852 f'{INV_START}fn_b">\n'
853 f'{PARAM_START}y" string="true">2{PARAM_END}\n'
854 f"{INV_END}\n"
855 f"{FC_END}"
856 )
857 deltas = self._stream(parser, full_text)
858 ids = [
859 tc.id
860 for d in deltas
861 if d.tool_calls
862 for tc in d.tool_calls
863 if tc.id is not None
864 ]
865 assert len(ids) == 2
866 assert ids[0] != ids[1]
867
868 def test_streaming_matches_non_streaming(self, parser):
869 """Streaming and non-streaming must produce the same result."""
870 full_text = build_tool_call(
871 "get_weather", {"location": "SF", "date": "2024-01-16"}
872 )
873 # Non-streaming
874 non_stream = parser.extract_tool_calls(full_text, None)
875 assert non_stream.tools_called
876 ns_name = non_stream.tool_calls[0].function.name
877 ns_args = json.loads(non_stream.tool_calls[0].function.arguments)
878 # Streaming
879 deltas = self._stream(parser, full_text)
880 s_names = [
881 tc.function.name
882 for d in deltas
883 if d.tool_calls
884 for tc in d.tool_calls
885 if tc.function and tc.function.name
886 ]
887 s_args = json.loads(self._reconstruct_args(deltas))
888 assert s_names[0] == ns_name
889 assert s_args == ns_args
890
891 def _stream_chunked(self, parser, full_text: str, chunk_size: int, request=None):
892 """Drive the parser with fixed-size chunks (simulates stream interval).
893
894 Unlike ``_stream`` which splits on newlines, this splits the text
895 into ``chunk_size``-character pieces so the start token can be
896 split across chunks — exactly what happens with stream interval > 1.
897 """
898 if request is None:
899 request = make_request()
900 chunks = [
901 full_text[i : i + chunk_size] for i in range(0, len(full_text), chunk_size)
902 ]
903 deltas = []
904 prev = ""
905 for chunk in chunks:
906 curr = prev + chunk
907 result = parser.extract_tool_calls_streaming(
908 previous_text=prev,
909 current_text=curr,
910 delta_text=chunk,
911 previous_token_ids=[],
912 current_token_ids=[],
913 delta_token_ids=[1],
914 request=request,
915 )
916 prev = curr
917 if result is not None:
918 deltas.append(result)
919 return deltas
920
921 def test_single_tool_chunked_stream_interval(self, parser):
922 """Start token split across chunks (stream interval > 1)."""
923 full_text = build_tool_call("get_weather", {"location": "SF"})
924 # Use a chunk size that splits the start token
925 deltas = self._stream_chunked(parser, full_text, chunk_size=5)
926 args_str = self._reconstruct_args(deltas)
927 assert json.loads(args_str) == {"location": "SF"}
928
929 def test_content_before_tool_chunked(self, parser):
930 """Content before tool call with chunked streaming."""
931 full_text = "Thinking... " + build_tool_call("fn", {"a": "b"})
932 deltas = self._stream_chunked(parser, full_text, chunk_size=7)
933 content = "".join(d.content for d in deltas if d.content is not None)
934 assert "Thinking" in content
935 args_str = self._reconstruct_args(deltas)
936 assert json.loads(args_str) == {"a": "b"}
937
938 def test_multiple_tools_chunked(self, parser):
939 """Multiple tools with chunked streaming."""
940 full_text = (
941 f"{FC_START}\n"
942 f'{INV_START}func_a">\n'
943 f'{PARAM_START}p" string="true">v1{PARAM_END}\n'
944 f"{INV_END}\n"
945 f'{INV_START}func_b">\n'
946 f'{PARAM_START}q" string="true">v2{PARAM_END}\n'
947 f"{INV_END}\n"
948 f"{FC_END}"
949 )
950 deltas = self._stream_chunked(parser, full_text, chunk_size=10)
951 assert json.loads(self._reconstruct_args(deltas, tool_index=0)) == {"p": "v1"}
952 assert json.loads(self._reconstruct_args(deltas, tool_index=1)) == {"q": "v2"}
953
954 def test_emits_arguments_before_invoke_completes(self, parser):
955 """Argument deltas should stream before the invoke block closes."""
956 partial_text = (
957 f"{FC_START}\n"
958 f'{INV_START}fn">\n'
959 f'{PARAM_START}k" string="true">val{PARAM_END}\n'
960 )
961 deltas = self._stream(parser, partial_text)
962 arg_chunks = [
963 tc.function.arguments
964 for delta in deltas
965 for tc in delta.tool_calls or []
966 if tc.function and tc.function.arguments is not None
967 ]
968 combined = "".join(arg_chunks)
969 assert combined # some partial args emitted
970 assert combined.startswith('{"k"')
971
972 def test_no_marker_leak_chunked(self, parser):
973 """Chunked streaming must NOT leak DSML start-marker fragments
974 as content (GitHub #40801)."""
975 full_text = build_tool_call("fn", {"k": "v"})
976 deltas = self._stream_chunked(parser, full_text, chunk_size=5)
977 content = "".join(d.content for d in deltas if d.content is not None)
978 assert content == ""
979 args_str = self._reconstruct_args(deltas)
980 assert json.loads(args_str) == {"k": "v"}
981
982 def test_no_marker_leak_with_prefix_chunked(self, parser):
983 """Content before a tool call must not include start-marker
984 fragments when chunked (GitHub #40801)."""
985 full_text = "Hello!" + build_tool_call("fn", {"a": "b"})
986 deltas = self._stream_chunked(parser, full_text, chunk_size=5)
987 content = "".join(d.content for d in deltas if d.content is not None)
988 assert content == "Hello!"
989 assert "DSML" not in content
990 assert "<|" not in content
991 args_str = self._reconstruct_args(deltas)
992 assert json.loads(args_str) == {"a": "b"}
993
994 def test_no_marker_leak_char_by_char(self, parser):
995 """Character-by-character streaming must not leak marker
996 fragments (GitHub #40801)."""
997 full_text = build_tool_call("fn", {"k": "v"})
998 deltas = self._stream_chunked(parser, full_text, chunk_size=1)
999 content = "".join(d.content for d in deltas if d.content is not None)
1000 assert content == ""
1001 args_str = self._reconstruct_args(deltas)
1002 assert json.loads(args_str) == {"k": "v"}
1003
1004 def test_no_marker_leak_all_split_points(self, parser):
1005 """Start token split at every possible boundary must not
1006 leak (GitHub #40801)."""
1007 for chunk_size in range(1, len(FC_START) + 2):
1008 p = make_parser()
1009 full_text = build_tool_call("fn", {"k": "v"})
1010 deltas = self._stream_chunked(p, full_text, chunk_size=chunk_size)
1011 content = "".join(d.content for d in deltas if d.content is not None)
1012 assert content == "", (
1013 f"Leaked content {content!r} at chunk_size={chunk_size}"
1014 )
1015
1016 def test_false_partial_marker_emitted(self, parser):
1017 """Text ending with a prefix of the start token that turns out
1018 NOT to be a marker must still be emitted as content."""
1019 full_text = "<|DSM some regular text"
1020 deltas = self._stream_chunked(parser, full_text, chunk_size=3)
1021 content = "".join(d.content for d in deltas if d.content is not None)
1022 assert content == full_text
1023
1024 def test_object_and_array_params_streaming(self):
1025 """Streaming: object/array params must be JSON-parsed."""
1026 tool = ChatCompletionToolsParam(
1027 function=FunctionDefinition(
1028 name="update",
1029 parameters={
1030 "type": "object",
1031 "properties": {
1032 "tags": {"type": "array"},
1033 "meta": {"type": "object"},
1034 },
1035 },
1036 ),
1037 )
1038 parser = make_parser(tools=[tool])
1039 full_text = (
1040 f"{FC_START}\n"
1041 f'{INV_START}update">\n'
1042 f'{PARAM_START}tags" string="false">["a", "b"]{PARAM_END}\n'
1043 f'{PARAM_START}meta" string="false">{{"k": 1}}{PARAM_END}\n'
1044 f"{INV_END}\n"
1045 f"{FC_END}"
1046 )
1047 deltas = self._stream(parser, full_text)
1048 args = json.loads(self._reconstruct_args(deltas))
1049 assert args["tags"] == ["a", "b"]
1050 assert args["meta"] == {"k": 1}
1051
1052 def test_multi_typed_schema_streaming(self):
1053 """Streaming: type: ["integer", "null"] must coerce correctly."""
1054 tool = ChatCompletionToolsParam(
1055 function=FunctionDefinition(
1056 name="set_val",
1057 parameters={
1058 "type": "object",
1059 "properties": {
1060 "count": {"type": ["integer", "null"]},
1061 },
1062 },
1063 ),
1064 )
1065 parser = make_parser(tools=[tool])
1066 full_text = (
1067 f"{FC_START}\n"
1068 f'{INV_START}set_val">\n'
1069 f'{PARAM_START}count" string="false">42{PARAM_END}\n'
1070 f"{INV_END}\n"
1071 f"{FC_END}"
1072 )
1073 deltas = self._stream(parser, full_text)
1074 args = json.loads(self._reconstruct_args(deltas))
1075 assert args["count"] == 42
1076 assert isinstance(args["count"], int)
1077
1078 def test_multi_typed_null_streaming(self):
1079 """Streaming: 'null' with ["integer", "null"] schema must become None."""
1080 tool = ChatCompletionToolsParam(
1081 function=FunctionDefinition(
1082 name="clear",
1083 parameters={
1084 "type": "object",
1085 "properties": {
1086 "value": {"type": ["integer", "null"]},
1087 },
1088 },
1089 ),
1090 )
1091 parser = make_parser(tools=[tool])
1092 full_text = (
1093 f"{FC_START}\n"
1094 f'{INV_START}clear">\n'
1095 f'{PARAM_START}value" string="false">null{PARAM_END}\n'
1096 f"{INV_END}\n"
1097 f"{FC_END}"
1098 )
1099 deltas = self._stream(parser, full_text)
1100 args = json.loads(self._reconstruct_args(deltas))
1101 assert args["value"] is None
1102
1103 def test_number_param_streaming(self):
1104 """Streaming: number type must be converted."""
1105 tool = ChatCompletionToolsParam(
1106 function=FunctionDefinition(
1107 name="measure",
1108 parameters={
1109 "type": "object",
1110 "properties": {
1111 "ratio": {"type": "number"},
1112 },
1113 },
1114 ),
1115 )
1116 parser = make_parser(tools=[tool])
1117 full_text = (
1118 f"{FC_START}\n"
1119 f'{INV_START}measure">\n'
1120 f'{PARAM_START}ratio" string="false">3.14{PARAM_END}\n'
1121 f"{INV_END}\n"
1122 f"{FC_END}"
1123 )
1124 deltas = self._stream(parser, full_text)
1125 args = json.loads(self._reconstruct_args(deltas))
1126 assert args["ratio"] == pytest.approx(3.14)
1127
1128
1129 class TestDelimiterPreservation:
1130 """Regression: fast detokenization skipping DSML delimiters (PR #33964)."""
1131
1132 @pytest.fixture
1133 def parser(self):
1134 return make_parser()
1135
1136 def test_delimiter_preserved_fast_detokenization(self, parser):
1137 """DSML delimiters as literal text must still be detected."""
1138 # Delimiters appear as regular text (fast detokenization scenario).
1139 model_output = (
1140 f"{FC_START}\n"
1141 f'{INV_START}get_weather">\n'
1142 f'{PARAM_START}location" string="true">Tokyo{PARAM_END}\n'
1143 f"{INV_END}\n"
1144 f"{FC_END}"
1145 )
1146
1147 # Non-streaming: parser must detect the tool call
1148 result = parser.extract_tool_calls(model_output, None)
1149 assert result.tools_called
1150 assert len(result.tool_calls) == 1
1151 assert result.tool_calls[0].function.name == "get_weather"
1152 assert json.loads(result.tool_calls[0].function.arguments) == {
1153 "location": "Tokyo"
1154 }
1155
1156 assert result.content is None
1157
1158 # With content prefix
1159 prefixed_output = "Here is the weather: " + model_output
1160 result2 = parser.extract_tool_calls(prefixed_output, None)
1161 assert result2.tools_called
1162 assert result2.content == "Here is the weather: "
1163
1164 def test_tool_detection_skip_special_tokens_false(self, parser):
1165 """Regression: skip_special_tokens must be False when tools are enabled."""
1166 # adjust_request must set skip_special_tokens=False
1167 tool = make_tool_param(
1168 "search",
1169 {
1170 "type": "object",
1171 "properties": {
1172 "query": {"type": "string"},
1173 },
1174 },
1175 )
1176 request = make_request(tools=[tool])
1177 request.tool_choice = "auto"
1178 adjusted = parser.adjust_request(request)
1179 assert adjusted.skip_special_tokens is False
1180
1181 full_text = build_tool_call("search", {"query": "vllm documentation"})
1182
1183 # Non-streaming extraction
1184 non_stream_result = parser.extract_tool_calls(full_text, request)
1185 assert non_stream_result.tools_called
1186 assert len(non_stream_result.tool_calls) == 1
1187 assert non_stream_result.tool_calls[0].function.name == "search"
1188 ns_args = json.loads(non_stream_result.tool_calls[0].function.arguments)
1189 assert ns_args == {"query": "vllm documentation"}
1190
1191 # Streaming extraction: drive the parser line-by-line
1192 chunks: list[str] = []
1193 remaining = full_text
1194 while remaining:
1195 nl = remaining.find("\n")
1196 if nl == -1:
1197 chunks.append(remaining)
1198 break
1199 chunks.append(remaining[: nl + 1])
1200 remaining = remaining[nl + 1 :]
1201
1202 reconstructor = run_tool_extraction_streaming(
1203 parser, chunks, request, assert_one_tool_per_delta=False
1204 )
1205 assert len(reconstructor.tool_calls) == 1
1206 assert reconstructor.tool_calls[0].function.name == "search"
1207 streamed_args = json.loads(reconstructor.tool_calls[0].function.arguments)
1208 assert streamed_args == ns_args