main
py 1,463 lines 52.4 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Tests for DeepSeek V4-specific parser engine semantics."""
4
5 import json
6
7 import pytest
8
9 from tests.parser.engine.conftest import make_mock_tokenizer
10 from tests.parser.engine.replay_harness import (
11 DUMMY_TOOLS,
12 MockTokenizer,
13 _test_request,
14 collect_output,
15 replay_streaming,
16 )
17 from tests.parser.engine.streaming_helpers import (
18 collect_content,
19 collect_function_name,
20 collect_tool_arguments,
21 simulate_reasoning_streaming,
22 simulate_tool_streaming,
23 )
24 from vllm.parser.abstract_parser import DelegatingParser
25 from vllm.parser.deepseek_v4 import (
26 DSML_INVOKE_END,
27 DSML_INVOKE_NAME_END,
28 DSML_INVOKE_PREFIX,
29 DSML_THINK_END,
30 DSML_THINK_START,
31 DSML_TOOL_END,
32 DSML_TOOL_START,
33 DSML_TOOL_START_VARIANTS,
34 DeepSeekV4Parser,
35 _dsml_arg_converter,
36 _unwrap_wrapper_args,
37 deepseek_v4_config,
38 )
39 from vllm.parser.engine.registered_adapters import (
40 DeepSeekV4ParserReasoningAdapter,
41 DeepSeekV4ParserToolAdapter,
42 )
43
44 _THINK_START_ID = 50
45 _THINK_END_ID = 51
46
47 _PARAM_OPEN = '|DSML|parameter name="{name}" string="{is_str}">'
48 _PARAM_CLOSE = "</|DSML|parameter>"
49
50
51 def _param(name: str, is_str: str, value: str) -> str:
52 return f"<{_PARAM_OPEN.format(name=name, is_str=is_str)}{value}{_PARAM_CLOSE}"
53
54
55 @pytest.fixture
56 def mock_tokenizer():
57 return make_mock_tokenizer(
58 {
59 DSML_THINK_START: _THINK_START_ID,
60 DSML_THINK_END: _THINK_END_ID,
61 }
62 )
63
64
65 # ── Arg converter unit tests ─────────────────────────────────────────
66
67
68 class TestArgConverter:
69 def _raw(self, *params: tuple[str, str, str]) -> str:
70 lines = [_param(n, s, v) for n, s, v in params]
71 return "\n" + "\n".join(lines) + "\n"
72
73 def test_string_param(self):
74 raw = self._raw(("city", "true", "杭州"))
75 result = json.loads(_dsml_arg_converter(raw, partial=False))
76 assert result == {"city": "杭州"}
77
78 def test_string_with_spaces_and_quotes(self):
79 raw = self._raw(("msg", "true", 'He said "hello world"'))
80 result = json.loads(_dsml_arg_converter(raw, partial=False))
81 assert result["msg"] == 'He said "hello world"'
82
83 def test_integer_param(self):
84 raw = self._raw(("count", "false", "42"))
85 result = json.loads(_dsml_arg_converter(raw, partial=False))
86 assert result["count"] == 42
87 assert isinstance(result["count"], int)
88
89 def test_float_param(self):
90 raw = self._raw(("ratio", "false", "3.14"))
91 result = json.loads(_dsml_arg_converter(raw, partial=False))
92 assert abs(result["ratio"] - 3.14) < 1e-9
93
94 def test_bool_param(self):
95 raw = self._raw(("flag", "false", "true"))
96 result = json.loads(_dsml_arg_converter(raw, partial=False))
97 assert result["flag"] is True
98
99 def test_array_param(self):
100 raw = self._raw(("items", "false", '["a", "b", "c"]'))
101 result = json.loads(_dsml_arg_converter(raw, partial=False))
102 assert result["items"] == ["a", "b", "c"]
103
104 def test_object_param(self):
105 raw = self._raw(("opts", "false", '{"key": "val"}'))
106 result = json.loads(_dsml_arg_converter(raw, partial=False))
107 assert result["opts"] == {"key": "val"}
108
109 def test_mixed_types(self):
110 raw = self._raw(
111 ("location", "true", "Tokyo"),
112 ("limit", "false", "10"),
113 ("active", "false", "false"),
114 )
115 result = json.loads(_dsml_arg_converter(raw, partial=False))
116 assert result == {"location": "Tokyo", "limit": 10, "active": False}
117
118 def test_empty_args(self):
119 result = json.loads(_dsml_arg_converter("", partial=False))
120 assert result == {}
121
122 def _bare(self, name: str, value: str) -> str:
123 return f'<|DSML|parameter name="{name}">{value}{_PARAM_CLOSE}'
124
125 def test_missing_string_attribute_keeps_literal(self):
126 """The model sometimes omits ``string``; the parameter must survive."""
127 raw = self._bare("name", "alpha")
128 assert json.loads(_dsml_arg_converter(raw, partial=False)) == {"name": "alpha"}
129
130 def test_missing_string_attribute_parses_json(self):
131 raw = self._bare("count", "7") + self._bare("opts", '{"k": [1]}')
132 assert json.loads(_dsml_arg_converter(raw, partial=False)) == {
133 "count": 7,
134 "opts": {"k": [1]},
135 }
136
137 def test_missing_string_attribute_alongside_annotated(self):
138 raw = self._bare("a", "x") + _param("b", "true", "42")
139 assert json.loads(_dsml_arg_converter(raw, partial=False)) == {
140 "a": "x",
141 "b": "42",
142 }
143
144 def test_missing_string_attribute_partial_value(self):
145 raw = _param("a", "true", "x") + '<|DSML|parameter name="n">12'
146 assert json.loads(_dsml_arg_converter(raw, partial=True)) == {
147 "a": "x",
148 "n": 12,
149 }
150
151 def test_invalid_json_fallback(self):
152 raw = self._raw(("data", "false", "[broken"))
153 result = json.loads(_dsml_arg_converter(raw, partial=False))
154 assert result["data"] == "[broken"
155
156 def test_chinese_chars_preserved_in_json(self):
157 raw = self._raw(("query", "true", "你好世界"))
158 raw_json = _dsml_arg_converter(raw, partial=False)
159 assert "你好世界" in raw_json
160 result = json.loads(raw_json)
161 assert result["query"] == "你好世界"
162
163 def test_partial_complete_plus_in_progress(self):
164 raw = self._raw(("city", "true", "Tokyo"))
165 raw += f"<{_PARAM_OPEN.format(name='unit', is_str='true')}celsi"
166 result = json.loads(_dsml_arg_converter(raw, partial=True))
167 assert result["city"] == "Tokyo"
168 assert result["unit"] == "celsi"
169
170 def test_partial_no_in_progress(self):
171 raw = self._raw(("city", "true", "Tokyo"))
172 result = json.loads(_dsml_arg_converter(raw, partial=True))
173 assert result == {"city": "Tokyo"}
174
175 def test_partial_value_with_angle_bracket(self):
176 raw = f"<{_PARAM_OPEN.format(name='code', is_str='true')}a<b"
177 result = json.loads(_dsml_arg_converter(raw, partial=True))
178 assert result == {"code": "a<b"}
179
180 def test_partial_value_with_angle_bracket_and_complete_param(self):
181 raw = self._raw(("city", "true", "Tokyo"))
182 raw += f"<{_PARAM_OPEN.format(name='expr', is_str='true')}x<5"
183 result = json.loads(_dsml_arg_converter(raw, partial=True))
184 assert result["city"] == "Tokyo"
185 assert result["expr"] == "x<5"
186
187 def test_next_parameter_prefix_implicitly_closes_previous(self):
188 raw = (
189 f"<{_PARAM_OPEN.format(name='city', is_str='true')}Tokyo\n"
190 "<|DSML|parameter name="
191 )
192 result = json.loads(_dsml_arg_converter(raw, partial=False))
193 assert result == {"city": "Tokyo\n"}
194
195 def test_implicit_close_preserves_malformed_text(self):
196 raw = (
197 f"<{_PARAM_OPEN.format(name='city', is_str='true')}"
198 "Tokyo</|DSML|parameter\n"
199 f"<{_PARAM_OPEN.format(name='unit', is_str='true')}celsius{_PARAM_CLOSE}"
200 )
201 result = json.loads(_dsml_arg_converter(raw, partial=False))
202 assert result == {
203 "city": "Tokyo</|DSML|parameter\n",
204 "unit": "celsius",
205 }
206
207 def test_null_string_false(self):
208 raw = self._raw(("val", "false", "null"))
209 result = json.loads(_dsml_arg_converter(raw, partial=False))
210 assert result["val"] is None
211
212 def test_string_true_not_json_parsed(self):
213 raw = self._raw(("n", "true", "42"))
214 result = json.loads(_dsml_arg_converter(raw, partial=False))
215 assert result["n"] == "42"
216 assert isinstance(result["n"], str)
217
218
219 class TestImplicitParameterClose:
220 def test_non_streaming_recovers_next_parameter(self, mock_tokenizer, mock_request):
221 text = (
222 f"{DSML_TOOL_START}"
223 f"{DSML_INVOKE_PREFIX}get_weather{DSML_INVOKE_NAME_END}\n"
224 f"<{_PARAM_OPEN.format(name='location', is_str='true')}Paris "
225 f"<{_PARAM_OPEN.format(name='date', is_str='true')}"
226 f"tomorrow{_PARAM_CLOSE}"
227 f"{DSML_INVOKE_END}{DSML_TOOL_END}"
228 )
229
230 result = DeepSeekV4Parser(mock_tokenizer).extract_tool_calls(text, mock_request)
231
232 assert result.tools_called is True
233 assert json.loads(result.tool_calls[0].function.arguments) == {
234 "location": "Paris ",
235 "date": "tomorrow",
236 }
237
238 def test_streaming_split_next_parameter_tag_is_buffered(
239 self, mock_tokenizer, mock_request
240 ):
241 chunks = [
242 DSML_TOOL_START,
243 f"{DSML_INVOKE_PREFIX}get_weather{DSML_INVOKE_NAME_END}\n",
244 f"<{_PARAM_OPEN.format(name='location', is_str='true')}"
245 "Paris a<b><|DSML|parameter",
246 ' name="date" string="true">tomorrow',
247 _PARAM_CLOSE,
248 DSML_INVOKE_END,
249 DSML_TOOL_END,
250 ]
251
252 results = simulate_tool_streaming(
253 DeepSeekV4Parser(mock_tokenizer), mock_request, chunks
254 )
255
256 arguments = collect_tool_arguments(results)
257 assert "<|DSML|param" not in arguments
258 assert json.loads(arguments) == {
259 "location": "Paris a<b>",
260 "date": "tomorrow",
261 }
262
263
264 class TestMissingStringAttribute:
265 def test_non_streaming_keeps_parameter(self, mock_tokenizer, mock_request):
266 text = (
267 f"{DSML_TOOL_START}"
268 f"{DSML_INVOKE_PREFIX}get_weather{DSML_INVOKE_NAME_END}\n"
269 f'<|DSML|parameter name="location">Paris{_PARAM_CLOSE}\n'
270 f"{_param('date', 'true', 'tomorrow')}\n"
271 f"{DSML_INVOKE_END}{DSML_TOOL_END}"
272 )
273
274 result = DeepSeekV4Parser(mock_tokenizer).extract_tool_calls(text, mock_request)
275
276 assert result.tools_called is True
277 assert json.loads(result.tool_calls[0].function.arguments) == {
278 "location": "Paris",
279 "date": "tomorrow",
280 }
281
282 def test_streaming_keeps_parameter(self, mock_tokenizer, mock_request):
283 chunks = [
284 DSML_TOOL_START,
285 f"{DSML_INVOKE_PREFIX}get_weather{DSML_INVOKE_NAME_END}\n",
286 '<|DSML|parameter name="loc',
287 'ation">Par',
288 "is",
289 _PARAM_CLOSE,
290 f"\n{_param('date', 'true', 'tomorrow')}\n",
291 DSML_INVOKE_END,
292 DSML_TOOL_END,
293 ]
294
295 results = simulate_tool_streaming(
296 DeepSeekV4Parser(mock_tokenizer), mock_request, chunks
297 )
298
299 assert json.loads(collect_tool_arguments(results)) == {
300 "location": "Paris",
301 "date": "tomorrow",
302 }
303
304
305 # ── Bare </think> absorption and duplicate <think> absorption ─────────
306
307
308 class TestThinkTagAbsorption:
309 def test_bare_think_end_not_leaked(self, mock_tokenizer):
310 parser = DeepSeekV4Parser(mock_tokenizer)
311 chunks = ["</think>", "Here is the direct answer."]
312 reasoning, content = simulate_reasoning_streaming(parser, chunks)
313 assert reasoning == ""
314 assert "</think>" not in content
315 assert "Here is the direct answer" in content
316
317 def test_duplicate_think_start_absorbed(self, mock_tokenizer):
318 parser = DeepSeekV4Parser(
319 mock_tokenizer, chat_template_kwargs={"thinking": True}
320 )
321 chunks = [
322 "<think>\n",
323 "Some reasoning.\n",
324 "</think>\n",
325 "Answer.",
326 ]
327 reasoning, content = simulate_reasoning_streaming(parser, chunks)
328 assert "Some reasoning" in reasoning
329 assert "Answer" in content
330
331
332 # ── Missing </|DSML|invoke> before </|DSML|tool_calls> ────────────
333
334
335 class TestMissingInvokeEnd:
336 def test_non_streaming(self, mock_tokenizer, mock_request):
337 parser = DeepSeekV4Parser(mock_tokenizer)
338 text = (
339 f"{DSML_TOOL_START}"
340 f"{DSML_INVOKE_PREFIX}get_weather{DSML_INVOKE_NAME_END}\n"
341 f"{_param('location', 'true', 'NYC')}\n"
342 f"{DSML_TOOL_END}"
343 )
344 result = parser.extract_tool_calls(text, mock_request)
345
346 assert result.tools_called is True
347 assert len(result.tool_calls) == 1
348 assert result.tool_calls[0].function.name == "get_weather"
349 args = json.loads(result.tool_calls[0].function.arguments)
350 assert args == {"location": "NYC"}
351
352 def test_streaming_with_trailing_content(self, mock_tokenizer, mock_request):
353 parser = DeepSeekV4Parser(mock_tokenizer)
354 chunks = [
355 DSML_TOOL_START,
356 f"{DSML_INVOKE_PREFIX}get_weather{DSML_INVOKE_NAME_END}\n"
357 f"{_param('location', 'true', 'NYC')}\n",
358 DSML_TOOL_END,
359 "Done.",
360 ]
361
362 results = simulate_tool_streaming(parser, mock_request, chunks)
363
364 assert collect_function_name(results) == "get_weather"
365 args = json.loads(collect_tool_arguments(results))
366 assert args == {"location": "NYC"}
367 # A tool call ends the turn; anything after the block is dropped.
368 assert "Done." not in collect_content(results)
369 assert "DSML" not in collect_content(results)
370
371
372 class TestMissingToolCallsWrapper:
373 """The model sometimes drops ``<|DSML|tool_calls>`` at long context and
374 opens ``<|DSML|invoke ...>`` directly (#48931). The invoke itself must
375 anchor tool-call detection so the block is never leaked as content.
376 """
377
378 _ORPHAN = (
379 f"{DSML_INVOKE_PREFIX}terminal{DSML_INVOKE_NAME_END}\n"
380 f"{_param('command', 'true', 'echo hi')}\n"
381 f"{DSML_INVOKE_END}\n"
382 )
383
384 @pytest.mark.parametrize("trailing_end", [True, False])
385 def test_non_streaming(self, mock_tokenizer, mock_request, trailing_end):
386 text = "Let me run it.\n" + self._ORPHAN
387 if trailing_end:
388 text += DSML_TOOL_END
389 parser = DeepSeekV4Parser(
390 mock_tokenizer, chat_template_kwargs={"thinking": False}
391 )
392 result = parser.extract_tool_calls(text, mock_request)
393
394 assert result.tools_called is True
395 assert len(result.tool_calls) == 1
396 assert result.tool_calls[0].function.name == "terminal"
397 args = json.loads(result.tool_calls[0].function.arguments)
398 assert args == {"command": "echo hi"}
399 assert result.content == "Let me run it.\n"
400
401 @pytest.mark.parametrize("trailing_end", [True, False])
402 def test_trailing_content_dropped_consistently(
403 self, mock_tokenizer, mock_request, trailing_end
404 ):
405 """Text after a tool block is dropped whether or not the closing
406 wrapper is present, so a missing ``</|DSML|tool_calls>`` does not
407 change what the client sees."""
408 text = "pre\n" + self._ORPHAN
409 if trailing_end:
410 text += DSML_TOOL_END
411 text += "Done."
412 parser = DeepSeekV4Parser(
413 mock_tokenizer, chat_template_kwargs={"thinking": False}
414 )
415 result = parser.extract_tool_calls(text, mock_request)
416 assert [tc.function.name for tc in result.tool_calls] == ["terminal"]
417 assert result.content == "pre\n"
418
419 parser = DeepSeekV4Parser(
420 mock_tokenizer, chat_template_kwargs={"thinking": False}
421 )
422 results = simulate_tool_streaming(parser, mock_request, list(text))
423 assert collect_function_name(results) == "terminal"
424 assert collect_content(results) == "pre\n"
425
426 def test_second_wrapped_block_after_orphan(self, mock_tokenizer, mock_request):
427 parser = DeepSeekV4Parser(
428 mock_tokenizer, chat_template_kwargs={"thinking": False}
429 )
430 text = self._ORPHAN + DSML_TOOL_START + self._ORPHAN + DSML_TOOL_END
431 result = parser.extract_tool_calls(text, mock_request)
432 assert [tc.function.name for tc in result.tool_calls] == [
433 "terminal",
434 "terminal",
435 ]
436 assert result.content is None
437
438 def test_streaming_marker_split_across_deltas(self, mock_tokenizer, mock_request):
439 text = "Let me run it.\n" + self._ORPHAN + DSML_TOOL_END
440 parser = DeepSeekV4Parser(
441 mock_tokenizer, chat_template_kwargs={"thinking": False}
442 )
443 results = simulate_tool_streaming(parser, mock_request, list(text))
444
445 assert collect_function_name(results) == "terminal"
446 args = json.loads(collect_tool_arguments(results))
447 assert args == {"command": "echo hi"}
448 content = collect_content(results)
449 assert "Let me run it." in content
450 assert "DSML" not in content
451
452 def test_parallel_orphan_invokes(self, mock_tokenizer, mock_request):
453 parser = DeepSeekV4Parser(
454 mock_tokenizer, chat_template_kwargs={"thinking": False}
455 )
456 result = parser.extract_tool_calls(self._ORPHAN + self._ORPHAN, mock_request)
457
458 assert [tc.function.name for tc in result.tool_calls] == [
459 "terminal",
460 "terminal",
461 ]
462 assert result.content is None
463
464 def test_orphan_invoke_inside_reasoning_is_not_a_tool_call(
465 self, mock_tokenizer, mock_request
466 ):
467 parser = DeepSeekV4Parser(
468 mock_tokenizer, chat_template_kwargs={"thinking": True}
469 )
470 text = "Let me run it.\n\n" + self._ORPHAN + DSML_TOOL_END
471 reasoning, content, tool_calls = parser.parse(text, mock_request)
472
473 assert not tool_calls
474 assert content is None
475 assert reasoning is not None and reasoning.startswith("Let me run it.")
476
477 def test_drafted_invoke_in_reasoning_does_not_hijack_real_call(
478 self, mock_tokenizer, mock_request
479 ):
480 """A drafted invoke in ``<think>`` must not steal the real call."""
481 parser = DeepSeekV4Parser(
482 mock_tokenizer, chat_template_kwargs={"thinking": True}
483 )
484 text = (
485 f"Maybe {DSML_INVOKE_PREFIX}search{DSML_INVOKE_NAME_END} is wrong; "
486 f"use terminal.{DSML_THINK_END}\n"
487 f"{DSML_TOOL_START}\n{self._ORPHAN}{DSML_TOOL_END}"
488 )
489 reasoning, content, tool_calls = parser.parse(text, mock_request)
490
491 assert tool_calls is not None
492 assert [tc.name for tc in tool_calls] == ["terminal"]
493 assert json.loads(tool_calls[0].arguments) == {"command": "echo hi"}
494 assert reasoning is not None and "use terminal." in reasoning
495
496 def test_streaming_orphan_invoke_inside_reasoning_is_not_a_tool_call(
497 self, mock_tokenizer
498 ):
499 parser = DeepSeekV4Parser(
500 mock_tokenizer, chat_template_kwargs={"thinking": True}
501 )
502 chunks = ["Let me run it.\n\n", *list(self._ORPHAN), DSML_TOOL_END]
503 reasoning, content = simulate_reasoning_streaming(parser, chunks)
504
505 assert reasoning.startswith("Let me run it.")
506 assert content == ""
507
508 def test_wrapper_present_is_unchanged(self, mock_tokenizer, mock_request):
509 parser = DeepSeekV4Parser(mock_tokenizer)
510 text = DSML_TOOL_START + "\n" + self._ORPHAN + DSML_TOOL_END
511 result = parser.extract_tool_calls(text, mock_request)
512
513 assert len(result.tool_calls) == 1
514 assert result.tool_calls[0].function.name == "terminal"
515 assert result.content is None
516
517
518 # ── Thinking mode initial state ──────────────────────────────────────
519
520
521 class TestThinkingModeConfig:
522 def test_thinking_true_starts_in_reasoning(self):
523 cfg = deepseek_v4_config(thinking=True)
524 assert cfg.initial_state.name == "REASONING"
525
526 def test_thinking_false_starts_in_content(self):
527 cfg = deepseek_v4_config(thinking=False)
528 assert cfg.initial_state.name == "CONTENT"
529
530 @pytest.mark.parametrize(
531 ("chat_template_kwargs", "expected_state"),
532 [
533 ({}, "REASONING"),
534 ({"thinking": True}, "REASONING"),
535 ({"enable_thinking": True}, "REASONING"),
536 ({"reasoning_effort": "high"}, "REASONING"),
537 ({"thinking": False}, "CONTENT"),
538 ({"enable_thinking": False}, "CONTENT"),
539 (
540 {"enable_thinking": True, "reasoning_effort": "none"},
541 "CONTENT",
542 ),
543 ],
544 )
545 def test_parser_thinking_mode_matches_tokenizer_default(
546 self, mock_tokenizer, chat_template_kwargs, expected_state
547 ):
548 parser = DeepSeekV4Parser(
549 mock_tokenizer,
550 chat_template_kwargs=chat_template_kwargs,
551 )
552 assert parser.parser_engine_config.initial_state.name == expected_state
553
554 def test_thinking_mode_reasoning_without_tags(self, mock_tokenizer):
555 parser = DeepSeekV4Parser(
556 mock_tokenizer, chat_template_kwargs={"thinking": True}
557 )
558 chunks = [
559 "\n\nLet me consider ",
560 "this carefully.\n",
561 "</think>\n",
562 "Here is the result.",
563 ]
564 reasoning, content = simulate_reasoning_streaming(parser, chunks)
565 assert "Let me consider" in reasoning
566 assert "Here is the result" in content
567
568 def test_thinking_mode_all_reasoning_no_end_tag(self, mock_tokenizer):
569 parser = DeepSeekV4Parser(
570 mock_tokenizer, chat_template_kwargs={"thinking": True}
571 )
572 chunks = ["I'll review ", "the PR."]
573 reasoning, content = simulate_reasoning_streaming(parser, chunks)
574 assert "review" in reasoning
575 assert "the PR" in reasoning
576 assert content == ""
577
578 def test_reasoning_effort_none_overrides_enable_thinking(self, mock_tokenizer):
579 p = DeepSeekV4Parser(
580 mock_tokenizer,
581 chat_template_kwargs={
582 "enable_thinking": True,
583 "reasoning_effort": "none",
584 },
585 )
586 assert p.parser_engine_config.initial_state.name == "CONTENT"
587
588
589 # ── Implicit reasoning end (missing </think> before tool calls) ─────
590
591
592 class TestImplicitReasoningEnd:
593 """Tool call markers end reasoning implicitly when </think> is missing.
594
595 DeepSeek V4 models occasionally omit </think> before emitting tool calls.
596 The (REASONING, TOOL_START) transition handles this gracefully.
597 """
598
599 @pytest.fixture
600 def thinking_parser(self, mock_tokenizer):
601 return DeepSeekV4Parser(mock_tokenizer, chat_template_kwargs={"thinking": True})
602
603 def _reasoning_then_tool(self, reasoning_text: str) -> str:
604 return reasoning_text + _tool_calls(
605 _invoke("get_weather", ("location", "true", "NYC")),
606 )
607
608 def test_non_streaming_extract_reasoning_implicit_end(self, thinking_parser):
609 text = self._reasoning_then_tool("Let me look up the weather.\n\n")
610 reasoning, content = thinking_parser.extract_reasoning(text, None)
611 assert reasoning == "Let me look up the weather."
612 assert DSML_TOOL_START not in reasoning
613 assert DSML_INVOKE_PREFIX not in reasoning
614 assert content is None
615
616 def test_non_streaming_extract_tool_calls_implicit_end(
617 self, thinking_parser, mock_request
618 ):
619 text = self._reasoning_then_tool("Let me look up the weather.\n\n")
620 result = thinking_parser.extract_tool_calls(text, mock_request)
621 assert result.tools_called is True
622 assert len(result.tool_calls) == 1
623 assert result.tool_calls[0].function.name == "get_weather"
624 args = json.loads(result.tool_calls[0].function.arguments)
625 assert args == {"location": "NYC"}
626
627 def test_non_streaming_parse_implicit_end(self, thinking_parser, mock_request):
628 text = self._reasoning_then_tool("Let me look up the weather.\n\n")
629 reasoning, content, tool_calls = thinking_parser.parse(text, mock_request)
630 assert reasoning == "Let me look up the weather."
631 assert content is None
632 assert tool_calls is not None
633 assert len(tool_calls) == 1
634 assert tool_calls[0].name == "get_weather"
635 args = json.loads(tool_calls[0].arguments)
636 assert args == {"location": "NYC"}
637
638 def test_reasoning_end_token_ids_drop_multi_token_tool_opener(
639 self, thinking_parser
640 ):
641 # Like the real DeepSeek-V4 tokenizer, the mock vocab has no single
642 # token for the DSML tool opener, so only </think> is tracked on the
643 # token-ID fast path; the implicit tool-call exit still goes through
644 # the text lexer.
645 assert thinking_parser.reasoning_end_token_ids == {_THINK_END_ID}
646 assert thinking_parser.find_reasoning_end_offset([7, _THINK_END_ID, 8]) == 1
647 assert thinking_parser.find_reasoning_end_offset([7, 8]) == 2
648
649 def test_streaming_reasoning_implicit_end(self, thinking_parser):
650 chunks = [
651 "Let me look up the weather.\n\n",
652 DSML_TOOL_START,
653 DSML_INVOKE_PREFIX + "get_weather" + DSML_INVOKE_NAME_END,
654 ]
655 reasoning, content = simulate_reasoning_streaming(thinking_parser, chunks)
656 assert reasoning == "Let me look up the weather."
657 assert DSML_TOOL_START not in reasoning
658 assert DSML_INVOKE_PREFIX not in reasoning
659
660 def test_streaming_tool_extraction_implicit_end(
661 self, thinking_parser, mock_request
662 ):
663 chunks = [
664 "Let me check.\n\n",
665 DSML_TOOL_START,
666 DSML_INVOKE_PREFIX
667 + "get_weather"
668 + DSML_INVOKE_NAME_END
669 + "\n"
670 + _param("location", "true", "NYC")
671 + "\n"
672 + DSML_INVOKE_END,
673 DSML_TOOL_END,
674 ]
675 results = simulate_tool_streaming(thinking_parser, mock_request, chunks)
676 assert collect_function_name(results) == "get_weather"
677 args = json.loads(collect_tool_arguments(results))
678 assert args == {"location": "NYC"}
679
680 def test_thinking_false_explicit_think_then_tool_call(self, mock_tokenizer):
681 parser = DeepSeekV4Parser(mock_tokenizer)
682 chunks = [
683 DSML_THINK_START,
684 "Let me check the weather.",
685 DSML_TOOL_START,
686 DSML_INVOKE_PREFIX + "get_weather" + DSML_INVOKE_NAME_END,
687 ]
688 reasoning, content = simulate_reasoning_streaming(parser, chunks)
689 assert "Let me check the weather" in reasoning
690 assert DSML_TOOL_START not in reasoning
691 assert DSML_THINK_START not in reasoning
692
693 def test_non_streaming_parallel_tools_after_implicit_end(
694 self, thinking_parser, mock_request
695 ):
696 text = "I need both.\n\n" + _tool_calls(
697 _invoke("get_weather", ("location", "true", "NYC")),
698 _invoke("get_time", ("timezone", "true", "EST")),
699 )
700 result = thinking_parser.extract_tool_calls(text, mock_request)
701 assert result.tools_called is True
702 assert len(result.tool_calls) == 2
703 assert result.tool_calls[0].function.name == "get_weather"
704 assert result.tool_calls[1].function.name == "get_time"
705
706 def test_streaming_implicit_end_trailing_whitespace_stripped(self, thinking_parser):
707 chunks = [
708 "Reasoning.\n\n\n",
709 DSML_TOOL_START,
710 DSML_INVOKE_PREFIX + "func" + DSML_INVOKE_NAME_END,
711 ]
712 reasoning, content = simulate_reasoning_streaming(thinking_parser, chunks)
713 assert reasoning == "Reasoning."
714
715
716 # ── Wrapper argument unwrapping ──────────────────────────────────────
717
718
719 class TestWrapperUnwrapping:
720 def test_unwrap_arguments_wrapper(self):
721 from vllm.entrypoints.openai.chat_completion.protocol import (
722 ChatCompletionToolsParam,
723 )
724
725 tool = ChatCompletionToolsParam(
726 type="function",
727 function={
728 "name": "get_weather",
729 "parameters": {
730 "type": "object",
731 "properties": {"location": {"type": "string"}},
732 },
733 },
734 )
735
736 result = _unwrap_wrapper_args(
737 '{"arguments": {"location": "Beijing"}}',
738 [tool],
739 "get_weather",
740 )
741 assert json.loads(result) == {"location": "Beijing"}
742
743 def test_unwrap_input_wrapper(self):
744 from vllm.entrypoints.openai.chat_completion.protocol import (
745 ChatCompletionToolsParam,
746 )
747
748 tool = ChatCompletionToolsParam(
749 type="function",
750 function={
751 "name": "get_weather",
752 "parameters": {
753 "type": "object",
754 "properties": {"location": {"type": "string"}},
755 },
756 },
757 )
758
759 result = _unwrap_wrapper_args(
760 '{"input": {"location": "Beijing"}}',
761 [tool],
762 "get_weather",
763 )
764 assert json.loads(result) == {"location": "Beijing"}
765
766 def test_no_unwrap_when_key_in_schema(self):
767 from vllm.entrypoints.openai.chat_completion.protocol import (
768 ChatCompletionToolsParam,
769 )
770
771 tool = ChatCompletionToolsParam(
772 type="function",
773 function={
774 "name": "func",
775 "parameters": {
776 "type": "object",
777 "properties": {"arguments": {"type": "string"}},
778 },
779 },
780 )
781
782 result = _unwrap_wrapper_args(
783 '{"arguments": "some value"}',
784 [tool],
785 "func",
786 )
787 assert json.loads(result) == {"arguments": "some value"}
788
789 def test_no_unwrap_when_no_tools(self):
790 result = _unwrap_wrapper_args(
791 '{"arguments": {"location": "Beijing"}}',
792 None,
793 "get_weather",
794 )
795 assert json.loads(result) == {"arguments": {"location": "Beijing"}}
796
797 def test_unwrap_json_string_inner(self):
798 from vllm.entrypoints.openai.chat_completion.protocol import (
799 ChatCompletionToolsParam,
800 )
801
802 tool = ChatCompletionToolsParam(
803 type="function",
804 function={
805 "name": "get_weather",
806 "parameters": {
807 "type": "object",
808 "properties": {"location": {"type": "string"}},
809 },
810 },
811 )
812
813 result = _unwrap_wrapper_args(
814 '{"arguments": "{\\"location\\": \\"Beijing\\"}"}',
815 [tool],
816 "get_weather",
817 )
818 assert json.loads(result) == {"location": "Beijing"}
819
820
821 # ── Parallel tool call wrapper unwrapping ───────────────────────────
822
823
824 def _make_tool(name, properties):
825 from vllm.entrypoints.openai.chat_completion.protocol import ( # noqa: E501
826 ChatCompletionToolsParam,
827 )
828
829 return ChatCompletionToolsParam(
830 type="function",
831 function={
832 "name": name,
833 "parameters": {
834 "type": "object",
835 "properties": properties,
836 },
837 },
838 )
839
840
841 def _invoke(name, *params):
842 body = "\n".join(_param(n, s, v) for n, s, v in params)
843 return (
844 f"{DSML_INVOKE_PREFIX}{name}{DSML_INVOKE_NAME_END}\n{body}\n{DSML_INVOKE_END}"
845 )
846
847
848 def _tool_calls(*invokes):
849 return DSML_TOOL_START + "\n".join(invokes) + DSML_TOOL_END
850
851
852 class TestParallelUnwrapping:
853 @pytest.fixture
854 def weather_tool(self):
855 return _make_tool(
856 "get_weather",
857 {
858 "location": {"type": "string"},
859 "unit": {"type": "string"},
860 },
861 )
862
863 @pytest.fixture
864 def time_tool(self):
865 return _make_tool(
866 "get_time",
867 {"timezone": {"type": "string"}},
868 )
869
870 @pytest.mark.parametrize(
871 "weather_args, expected",
872 [
873 (
874 '{"location": "NYC", "unit": "celsius"}',
875 {"location": "NYC", "unit": "celsius"},
876 ),
877 ('{"location": "NYC"}', {"location": "NYC"}),
878 ],
879 ids=["all_props", "subset_props"],
880 )
881 def test_unwrap_parallel_uses_correct_schema(
882 self,
883 mock_tokenizer,
884 mock_request,
885 weather_tool,
886 time_tool,
887 weather_args,
888 expected,
889 ):
890 tools = [weather_tool, time_tool]
891 parser = DeepSeekV4Parser(mock_tokenizer, tools=tools)
892 mock_request.tools = tools
893
894 text = _tool_calls(
895 _invoke("get_weather", ("arguments", "false", weather_args)),
896 _invoke("get_time", ("timezone", "true", "EST")),
897 )
898
899 result = parser.extract_tool_calls(text, mock_request)
900
901 assert result.tools_called is True
902 assert len(result.tool_calls) == 2
903 assert result.tool_calls[0].function.name == "get_weather"
904 args0 = json.loads(result.tool_calls[0].function.arguments)
905 assert args0 == expected
906 assert result.tool_calls[1].function.name == "get_time"
907 args1 = json.loads(result.tool_calls[1].function.arguments)
908 assert args1 == {"timezone": "EST"}
909
910 def test_unwrap_parallel_streaming(
911 self, mock_tokenizer, mock_request, weather_tool, time_tool
912 ):
913 tools = [weather_tool, time_tool]
914 parser = DeepSeekV4Parser(mock_tokenizer, tools=tools)
915 mock_request.tools = tools
916
917 chunks = [
918 DSML_TOOL_START,
919 _invoke(
920 "get_weather",
921 ("arguments", "false", '{"location": "NYC"}'),
922 ),
923 _invoke("get_time", ("timezone", "true", "EST")),
924 DSML_TOOL_END,
925 ]
926
927 results = simulate_tool_streaming(parser, mock_request, chunks)
928 final_delta, _ = results[-1]
929 finish_delta = parser.finish_streaming()
930 extracted = parser._build_extracted_result(final_delta, finish_delta)
931
932 assert extracted.tools_called is True
933 assert len(extracted.tool_calls) == 2
934 args0 = json.loads(extracted.tool_calls[0].function.arguments)
935 assert args0 == {"location": "NYC"}
936 args1 = json.loads(extracted.tool_calls[1].function.arguments)
937 assert args1 == {"timezone": "EST"}
938
939 def test_no_unwrap_parallel_when_no_match(
940 self, mock_tokenizer, mock_request, weather_tool, time_tool
941 ):
942 tools = [weather_tool, time_tool]
943 parser = DeepSeekV4Parser(mock_tokenizer, tools=tools)
944 mock_request.tools = tools
945
946 text = _tool_calls(
947 _invoke(
948 "get_weather",
949 ("arguments", "false", '{"unknown_key": "val"}'),
950 ),
951 _invoke("get_time", ("timezone", "true", "EST")),
952 )
953
954 result = parser.extract_tool_calls(text, mock_request)
955
956 assert len(result.tool_calls) == 2
957 args0 = json.loads(result.tool_calls[0].function.arguments)
958 assert args0 == {"arguments": {"unknown_key": "val"}}
959 args1 = json.loads(result.tool_calls[1].function.arguments)
960 assert args1 == {"timezone": "EST"}
961
962 def test_unwrap_single_tool_still_works(self, mock_tokenizer, mock_request):
963 tool = _make_tool("get_weather", {"location": {"type": "string"}})
964 tools = [tool]
965 parser = DeepSeekV4Parser(mock_tokenizer, tools=tools)
966 mock_request.tools = tools
967
968 text = _tool_calls(
969 _invoke(
970 "get_weather",
971 ("arguments", "false", '{"location": "Beijing"}'),
972 ),
973 )
974
975 result = parser.extract_tool_calls(text, mock_request)
976
977 assert result.tools_called is True
978 assert len(result.tool_calls) == 1
979 args = json.loads(result.tool_calls[0].function.arguments)
980 assert args == {"location": "Beijing"}
981
982
983 # ── Streaming wrapper consistency ─────────────────────────────────────
984
985
986 class TestStreamingWrapperConsistency:
987 """Streamed arg deltas must stay consistent with final extraction
988 when wrapper params like 'arguments' are unwrapped."""
989
990 def test_streaming_wrapper_unwrap_consistency(self, mock_tokenizer, mock_request):
991 tool = _make_tool("get_weather", {"location": {"type": "string"}})
992 tools = [tool]
993 parser = DeepSeekV4Parser(mock_tokenizer, tools=tools)
994 mock_request.tools = tools
995
996 chunks = [
997 DSML_TOOL_START,
998 _invoke(
999 "get_weather",
1000 ("arguments", "false", '{"location": "NYC"}'),
1001 ),
1002 DSML_TOOL_END,
1003 ]
1004
1005 results = simulate_tool_streaming(parser, mock_request, chunks)
1006 streamed_args = collect_tool_arguments(results)
1007
1008 final_delta, _ = results[-1]
1009 finish_delta = parser.finish_streaming()
1010 extracted = parser._build_extracted_result(final_delta, finish_delta)
1011
1012 assert extracted.tools_called is True
1013 assert len(extracted.tool_calls) == 1
1014
1015 final_args = extracted.tool_calls[0].function.arguments
1016 assert json.loads(final_args) == {"location": "NYC"}
1017
1018 assert '"arguments"' not in streamed_args, (
1019 f"Streamed args should not contain wrapper key, got: {streamed_args!r}"
1020 )
1021
1022 assert final_args.startswith(streamed_args), (
1023 f"Extracted args {final_args!r} "
1024 f"should start with streamed args {streamed_args!r}"
1025 )
1026
1027
1028 # ── DelegatingParser: large delta with </think> + tool calls ─────────
1029
1030 _DSV4_FULL_VOCAB = {
1031 DSML_THINK_START: 128821,
1032 DSML_THINK_END: 128822,
1033 DSML_TOOL_START: 128823,
1034 DSML_TOOL_END: 128824,
1035 }
1036
1037
1038 class _DeepSeekV4Delegating(DelegatingParser):
1039 reasoning_parser_cls = DeepSeekV4ParserReasoningAdapter
1040 tool_parser_cls = DeepSeekV4ParserToolAdapter
1041
1042
1043 def _dsv4_tokens(
1044 reasoning: str,
1045 tool_name: str,
1046 params: list[tuple[str, str, str]],
1047 ) -> list[tuple[int, str]]:
1048 """Build a token sequence: reasoning + </think> + DSML tool block."""
1049 tokens: list[tuple[int, str]] = []
1050 tid = 100
1051
1052 for word in reasoning.split(" "):
1053 prefix = " " if tokens else ""
1054 tokens.append((tid, prefix + word))
1055 tid += 1
1056
1057 tokens.append((_DSV4_FULL_VOCAB[DSML_THINK_END], DSML_THINK_END))
1058
1059 tokens.append((tid, "\n\n"))
1060 tid += 1
1061
1062 tokens.append((_DSV4_FULL_VOCAB[DSML_TOOL_START], DSML_TOOL_START))
1063
1064 tokens.append((tid, "\n"))
1065 tid += 1
1066
1067 invoke_prefix_text = f"{DSML_INVOKE_PREFIX}{tool_name}{DSML_INVOKE_NAME_END}"
1068 tokens.append((tid, invoke_prefix_text))
1069 tid += 1
1070
1071 tokens.append((tid, "\n"))
1072 tid += 1
1073
1074 for name, is_str, value in params:
1075 param_text = _param(name, is_str, value)
1076 tokens.append((tid, param_text))
1077 tid += 1
1078 tokens.append((tid, "\n"))
1079 tid += 1
1080
1081 tokens.append((tid, DSML_INVOKE_END))
1082 tid += 1
1083
1084 tokens.append((tid, "\n"))
1085 tid += 1
1086
1087 tokens.append((_DSV4_FULL_VOCAB[DSML_TOOL_END], DSML_TOOL_END))
1088
1089 return tokens
1090
1091
1092 class TestDelegatingParserLargeDelta:
1093 """Regression: tool calls lost when </think> + DSML arrive in same delta.
1094
1095 The DelegatingParser used by the serving layer splits reasoning and
1096 tool parsing across two separate engine instances. When </think> and
1097 the entire DSML tool block arrive in a single large streaming delta,
1098 the content transfer from reasoning adapter to tool adapter must
1099 preserve the tool call text.
1100 """
1101
1102 @pytest.fixture
1103 def dsv4_tokens(self):
1104 return _dsv4_tokens(
1105 reasoning="The user wants the current weather in Berlin.",
1106 tool_name="get_weather",
1107 params=[
1108 ("location", "true", "Berlin"),
1109 ("units", "true", "celsius"),
1110 ],
1111 )
1112
1113 @pytest.fixture
1114 def dsv4_tokenizer(self, dsv4_tokens):
1115 return MockTokenizer(
1116 vocab=dict(_DSV4_FULL_VOCAB),
1117 tokens=dsv4_tokens,
1118 )
1119
1120 @pytest.mark.parametrize(
1121 "chunk_size",
1122 [1, 2, 3, 5, None],
1123 ids=lambda c: f"chunk={c}",
1124 )
1125 def test_tool_calls_extracted_at_all_chunk_sizes(
1126 self, dsv4_tokenizer, dsv4_tokens, chunk_size
1127 ):
1128 parser = _DeepSeekV4Delegating(
1129 dsv4_tokenizer,
1130 chat_template_kwargs={"thinking": True},
1131 )
1132 deltas = replay_streaming(
1133 parser,
1134 dsv4_tokens,
1135 chunk_size=chunk_size,
1136 finished_on_last=True,
1137 tools=DUMMY_TOOLS,
1138 )
1139 output = collect_output(deltas)
1140
1141 assert "The user wants" in output.reasoning
1142 assert len(output.tool_calls) == 1, (
1143 f"Expected 1 tool call but got {len(output.tool_calls)}; "
1144 f"reasoning={output.reasoning!r}, content={output.content!r}"
1145 )
1146 assert output.tool_calls[0]["name"] == "get_weather"
1147 args = json.loads(output.tool_calls[0]["arguments"])
1148 assert args == {"location": "Berlin", "units": "celsius"}
1149
1150 def test_default_thinking_extracts_tool_call_without_think_end(self, dsv4_tokens):
1151 tokens = [
1152 token
1153 for token in dsv4_tokens
1154 if token[0] != _DSV4_FULL_VOCAB[DSML_THINK_END]
1155 ]
1156 tokenizer = MockTokenizer(
1157 vocab=dict(_DSV4_FULL_VOCAB),
1158 tokens=tokens,
1159 )
1160 parser = _DeepSeekV4Delegating(tokenizer)
1161
1162 deltas = replay_streaming(
1163 parser,
1164 tokens,
1165 chunk_size=1,
1166 finished_on_last=True,
1167 tools=DUMMY_TOOLS,
1168 prompt_token_ids=[_DSV4_FULL_VOCAB[DSML_THINK_START]],
1169 )
1170 output = collect_output(deltas)
1171
1172 assert "The user wants" in output.reasoning
1173 assert output.content == ""
1174 assert len(output.tool_calls) == 1
1175 assert output.tool_calls[0]["name"] == "get_weather"
1176 args = json.loads(output.tool_calls[0]["arguments"])
1177 assert args == {"location": "Berlin", "units": "celsius"}
1178
1179 def test_eos_drop_token_does_not_swallow_tool_calls(self):
1180 """Tool calls must survive when an EOS DROP token's ID is in
1181 delta_token_ids but its text is absent from delta_text.
1182
1183 At large stream_interval the EOS token ID arrives in the same
1184 delta as </think> + tool calls but the detokenizer strips the
1185 EOS text. The scanner's _rebuild_from_anchors defers all text
1186 after </think> when it can't find the EOS anchor text. The
1187 reasoning adapter's finish_streaming must flush deferred text
1188 as content (with skip_tool_parsing), not as tool calls.
1189 """
1190 eos_text = "<|end▁of▁sentence|>"
1191 eos_id = 128801
1192 vocab = {
1193 DSML_THINK_START: 128821,
1194 DSML_THINK_END: 128822,
1195 eos_text: eos_id,
1196 }
1197
1198 reasoning = "The user wants weather."
1199 tool_block = (
1200 "\n\n"
1201 + DSML_TOOL_START
1202 + "\n"
1203 + DSML_INVOKE_PREFIX
1204 + "get_weather"
1205 + DSML_INVOKE_NAME_END
1206 + "\n"
1207 + _param("location", "true", "Berlin")
1208 + "\n"
1209 + DSML_INVOKE_END
1210 + "\n"
1211 + DSML_TOOL_END
1212 )
1213 # delta_text does NOT include EOS text (detokenizer strips it)
1214 full_text = reasoning + DSML_THINK_END + tool_block
1215 # Build token list: word-split reasoning, then special tokens,
1216 # then word-split tool block content, then EOS.
1217 # EOS ID is present but its text is NOT in delta_text.
1218 tokens: list[tuple[int, str]] = []
1219 tid = 100
1220 for word in reasoning.split(" "):
1221 pfx = " " if tokens else ""
1222 tokens.append((tid, pfx + word))
1223 tid += 1
1224 tokens.append((128822, DSML_THINK_END))
1225 for ch in tool_block:
1226 tokens.append((tid, ch))
1227 tid += 1
1228 tokens.append((eos_id, eos_text))
1229
1230 all_ids = [t[0] for t in tokens]
1231 tokenizer = MockTokenizer(vocab=vocab, tokens=tokens)
1232 request = _test_request(tools=DUMMY_TOOLS)
1233
1234 # All-in-one delta: EOS ID in token_ids but text NOT in
1235 # delta_text (detokenizer strips EOS). This is the scenario
1236 # at large stream_interval.
1237 parser = _DeepSeekV4Delegating(
1238 tokenizer,
1239 chat_template_kwargs={"thinking": True},
1240 )
1241 deltas = [
1242 parser.parse_delta(
1243 full_text,
1244 all_ids,
1245 request,
1246 prompt_token_ids=[],
1247 finished=True,
1248 )
1249 ]
1250
1251 output = collect_output(deltas)
1252
1253 assert "The user wants" in output.reasoning
1254 assert len(output.tool_calls) == 1, (
1255 f"Expected 1 tool call but got {len(output.tool_calls)}; "
1256 f"reasoning={output.reasoning!r}, content={output.content!r}"
1257 )
1258 assert output.tool_calls[0]["name"] == "get_weather"
1259 args = json.loads(output.tool_calls[0]["arguments"])
1260 assert args == {"location": "Berlin"}
1261
1262 @pytest.mark.parametrize(
1263 "chunk_size",
1264 [1, 2, 3, 5, None],
1265 ids=lambda c: f"chunk={c}",
1266 )
1267 def test_eos_not_leaked_when_reasoning_never_ends(self, chunk_size):
1268 """EOS must not leak into reasoning_content when the model never
1269 emits </think> (generation ends while still in REASONING state)."""
1270 eos_text = "<|end▁of▁sentence|>"
1271 eos_id = 128801
1272 vocab = {
1273 **_DSV4_FULL_VOCAB,
1274 eos_text: eos_id,
1275 }
1276
1277 reasoning_text = "Good morning! How can I help you today?"
1278 tokens: list[tuple[int, str]] = []
1279 tid = 100
1280 for word in reasoning_text.split(" "):
1281 prefix = " " if tokens else ""
1282 tokens.append((tid, prefix + word))
1283 tid += 1
1284 tokens.append((eos_id, eos_text))
1285
1286 tokenizer = MockTokenizer(vocab=vocab, tokens=tokens)
1287 parser = _DeepSeekV4Delegating(
1288 tokenizer,
1289 chat_template_kwargs={"thinking": True},
1290 )
1291 deltas = replay_streaming(
1292 parser,
1293 tokens,
1294 chunk_size=chunk_size,
1295 finished_on_last=True,
1296 )
1297 output = collect_output(deltas)
1298
1299 assert reasoning_text in output.reasoning
1300 assert eos_text not in output.reasoning
1301 assert output.content == ""
1302 assert output.tool_calls == []
1303
1304
1305 # ── Malformed DSML markup must not leak into content (#51914) ─────────
1306
1307 _WRAPPER_VARIANTS = list(DSML_TOOL_START_VARIANTS)
1308
1309
1310 class TestMalformedDsmlNoise:
1311 """DeepSeek-V4-Flash intermittently misspells the tool-call opener, e.g.
1312 ``<|DSML|toolcalls>``. Known corrupted openers open a tool block like
1313 the real wrapper so the markup never surfaces as assistant content.
1314 """
1315
1316 _INNER = (
1317 f"{DSML_INVOKE_PREFIX}get_weather{DSML_INVOKE_NAME_END}\n"
1318 f"{_param('city', 'true', 'Seoul')}\n"
1319 f"{DSML_INVOKE_END}\n"
1320 f"{DSML_TOOL_END}"
1321 )
1322
1323 @staticmethod
1324 def _parser(mock_tokenizer, thinking: bool = False):
1325 return DeepSeekV4Parser(
1326 mock_tokenizer, chat_template_kwargs={"thinking": thinking}
1327 )
1328
1329 @pytest.mark.parametrize("wrapper", _WRAPPER_VARIANTS)
1330 @pytest.mark.parametrize("preamble", ["", "Let me check.\n"])
1331 def test_corrupted_wrapper_behaves_like_real_one(
1332 self, mock_tokenizer, mock_request, wrapper, preamble
1333 ):
1334 text = f"{preamble}{wrapper}\n{self._INNER}"
1335 reference = f"{preamble}{DSML_TOOL_START}\n{self._INNER}"
1336
1337 result = self._parser(mock_tokenizer).extract_tool_calls(text, mock_request)
1338 expected = self._parser(mock_tokenizer).extract_tool_calls(
1339 reference, mock_request
1340 )
1341 assert [tc.function.name for tc in result.tool_calls] == ["get_weather"]
1342 assert json.loads(result.tool_calls[0].function.arguments) == {"city": "Seoul"}
1343 assert result.content == expected.content
1344 assert "DSML" not in (result.content or "")
1345
1346 results = simulate_tool_streaming(
1347 self._parser(mock_tokenizer), mock_request, list(text)
1348 )
1349 assert collect_function_name(results) == "get_weather"
1350 assert json.loads(collect_tool_arguments(results)) == {"city": "Seoul"}
1351 assert collect_content(results) == (expected.content or "")
1352
1353 @pytest.mark.parametrize("wrapper", _WRAPPER_VARIANTS)
1354 def test_corrupted_wrapper_ends_reasoning(
1355 self, mock_tokenizer, mock_request, wrapper
1356 ):
1357 """Like the real wrapper, a corrupted one inside ``<think>`` closes
1358 the reasoning block and starts the tool call."""
1359 text = f"thinking{wrapper}\n{self._INNER}"
1360 parser = self._parser(mock_tokenizer, thinking=True)
1361 reasoning, content = parser.extract_reasoning(text, mock_request)
1362 assert reasoning == "thinking"
1363 assert "DSML" not in (content or "")
1364
1365 result = self._parser(mock_tokenizer, thinking=True).extract_tool_calls(
1366 text, mock_request
1367 )
1368 assert [tc.function.name for tc in result.tool_calls] == ["get_weather"]
1369
1370 @pytest.mark.parametrize(
1371 "text",
1372 [
1373 # Fullwidth bars, DSML namespace, but not a registered spelling.
1374 "see <|DSML|foo> and <|DSML|tool_call> here",
1375 # Unclosed prefix.
1376 "see <|DSML| here\nnext line",
1377 # ASCII pipes are not the DSML sigil.
1378 "see <|DSML|tool> and <|DSML|toolcalls> there",
1379 ],
1380 )
1381 def test_unregistered_markup_is_ordinary_content(
1382 self, mock_tokenizer, mock_request, text
1383 ):
1384 """Only the registered spellings are treated as wrappers."""
1385 result = self._parser(mock_tokenizer).extract_tool_calls(text, mock_request)
1386 assert result.tools_called is False
1387 assert result.content == text
1388
1389 results = simulate_tool_streaming(
1390 self._parser(mock_tokenizer), mock_request, list(text)
1391 )
1392 assert collect_content(results) == text
1393
1394 @pytest.mark.parametrize("wrapper", _WRAPPER_VARIANTS)
1395 def test_prose_mention_matches_canonical_wrapper(
1396 self, mock_tokenizer, mock_request, wrapper
1397 ):
1398 """A registered spelling mentioned in prose behaves exactly like a
1399 prose mention of the real wrapper: the opener starts a tool block and
1400 the rest of the message is dropped. That is pre-existing behavior of
1401 ``<|DSML|tool_calls>``; the variants must not differ from it either
1402 way."""
1403 template = "The opener looks like {} and then params follow. Done."
1404 text = template.format(wrapper)
1405 reference = template.format(DSML_TOOL_START)
1406
1407 result = self._parser(mock_tokenizer).extract_tool_calls(text, mock_request)
1408 expected = self._parser(mock_tokenizer).extract_tool_calls(
1409 reference, mock_request
1410 )
1411 assert result.tools_called == expected.tools_called
1412 assert result.content == expected.content
1413
1414 results = simulate_tool_streaming(
1415 self._parser(mock_tokenizer), mock_request, list(text)
1416 )
1417 expected_results = simulate_tool_streaming(
1418 self._parser(mock_tokenizer), mock_request, list(reference)
1419 )
1420 assert collect_content(results) == collect_content(expected_results)
1421
1422 @pytest.mark.parametrize("wrapper", _WRAPPER_VARIANTS)
1423 @pytest.mark.parametrize("chunk_size", [1, 3, None], ids=lambda c: f"chunk={c}")
1424 def test_delegating_parser_corrupted_wrapper(self, wrapper, chunk_size):
1425 """The serving-layer shape: reasoning and tool adapters on separate
1426 engines, with the corrupted opener arriving as plain text."""
1427 tokens = _dsv4_tokens(
1428 reasoning="Checking the weather.",
1429 tool_name="get_weather",
1430 params=[("city", "true", "Seoul")],
1431 )
1432 start_id = _DSV4_FULL_VOCAB[DSML_TOOL_START]
1433 tokens = [
1434 (900, wrapper) if tid == start_id else (tid, text) for tid, text in tokens
1435 ]
1436 # Only the think markers are single tokens in the real
1437 # DeepSeek-V4-Flash vocab; the DSML wrappers are ordinary text and
1438 # must be matched by the lexer. Mirror that here.
1439 vocab = {
1440 DSML_THINK_START: _DSV4_FULL_VOCAB[DSML_THINK_START],
1441 DSML_THINK_END: _DSV4_FULL_VOCAB[DSML_THINK_END],
1442 }
1443 tokenizer = MockTokenizer(vocab=vocab, tokens=tokens)
1444 parser = _DeepSeekV4Delegating(
1445 tokenizer, chat_template_kwargs={"thinking": True}
1446 )
1447
1448 deltas = replay_streaming(
1449 parser,
1450 tokens,
1451 chunk_size=chunk_size,
1452 finished_on_last=True,
1453 tools=DUMMY_TOOLS,
1454 )
1455 output = collect_output(deltas)
1456
1457 assert "Checking" in output.reasoning
1458 assert len(output.tool_calls) == 1, (
1459 f"reasoning={output.reasoning!r}, content={output.content!r}"
1460 )
1461 assert output.tool_calls[0]["name"] == "get_weather"
1462 assert json.loads(output.tool_calls[0]["arguments"]) == {"city": "Seoul"}
1463 assert "DSML" not in (output.content or "")