diff --git a/tests/parser/engine/test_deepseek_v32.py b/tests/parser/engine/test_deepseek_v32.py index 85706a02dc..8db4e0cc76 100644 --- a/tests/parser/engine/test_deepseek_v32.py +++ b/tests/parser/engine/test_deepseek_v32.py @@ -285,3 +285,27 @@ class TestStreaming: assert collect_function_name(results) == "fn" args = json.loads(collect_tool_arguments(results)) assert args == {"k": "v"} + + +class TestMissingFunctionCallsWrapper: + """An invoke without the ``<|DSML|function_calls>`` wrapper is still a + tool call (V3.2 counterpart of #48931).""" + + def test_non_streaming(self, mock_tokenizer, mock_request): + text = _invoke("fn", _param("k", "true", "v")) + parser = DeepSeekV32Parser(mock_tokenizer) + result = parser.extract_tool_calls(text, mock_request) + + assert result.tools_called is True + assert result.tool_calls[0].function.name == "fn" + assert json.loads(result.tool_calls[0].function.arguments) == {"k": "v"} + assert result.content is None + + def test_streaming(self, mock_tokenizer, mock_request): + text = "Sure.\n" + _invoke("fn", _param("k", "true", "v")) + parser = DeepSeekV32Parser(mock_tokenizer) + results = simulate_tool_streaming(parser, mock_request, list(text)) + + assert collect_function_name(results) == "fn" + assert json.loads(collect_tool_arguments(results)) == {"k": "v"} + assert "DSML" not in collect_content(results) diff --git a/tests/parser/engine/test_deepseek_v4.py b/tests/parser/engine/test_deepseek_v4.py index 0936dccfea..7bea4f913d 100644 --- a/tests/parser/engine/test_deepseek_v4.py +++ b/tests/parser/engine/test_deepseek_v4.py @@ -293,7 +293,155 @@ class TestMissingInvokeEnd: assert collect_function_name(results) == "get_weather" args = json.loads(collect_tool_arguments(results)) assert args == {"location": "NYC"} - assert "Done." in collect_content(results) + # A tool call ends the turn; anything after the block is dropped. + assert "Done." not in collect_content(results) + assert "DSML" not in collect_content(results) + + +class TestMissingToolCallsWrapper: + """The model sometimes drops ``<|DSML|tool_calls>`` at long context and + opens ``<|DSML|invoke ...>`` directly (#48931). The invoke itself must + anchor tool-call detection so the block is never leaked as content. + """ + + _ORPHAN = ( + f"{DSML_INVOKE_PREFIX}terminal{DSML_INVOKE_NAME_END}\n" + f"{_param('command', 'true', 'echo hi')}\n" + f"{DSML_INVOKE_END}\n" + ) + + @pytest.mark.parametrize("trailing_end", [True, False]) + def test_non_streaming(self, mock_tokenizer, mock_request, trailing_end): + text = "Let me run it.\n" + self._ORPHAN + if trailing_end: + text += DSML_TOOL_END + parser = DeepSeekV4Parser( + mock_tokenizer, chat_template_kwargs={"thinking": False} + ) + result = parser.extract_tool_calls(text, mock_request) + + assert result.tools_called is True + assert len(result.tool_calls) == 1 + assert result.tool_calls[0].function.name == "terminal" + args = json.loads(result.tool_calls[0].function.arguments) + assert args == {"command": "echo hi"} + assert result.content == "Let me run it.\n" + + @pytest.mark.parametrize("trailing_end", [True, False]) + def test_trailing_content_dropped_consistently( + self, mock_tokenizer, mock_request, trailing_end + ): + """Text after a tool block is dropped whether or not the closing + wrapper is present, so a missing ```` does not + change what the client sees.""" + text = "pre\n" + self._ORPHAN + if trailing_end: + text += DSML_TOOL_END + text += "Done." + parser = DeepSeekV4Parser( + mock_tokenizer, chat_template_kwargs={"thinking": False} + ) + result = parser.extract_tool_calls(text, mock_request) + assert [tc.function.name for tc in result.tool_calls] == ["terminal"] + assert result.content == "pre\n" + + parser = DeepSeekV4Parser( + mock_tokenizer, chat_template_kwargs={"thinking": False} + ) + results = simulate_tool_streaming(parser, mock_request, list(text)) + assert collect_function_name(results) == "terminal" + assert collect_content(results) == "pre\n" + + def test_second_wrapped_block_after_orphan(self, mock_tokenizer, mock_request): + parser = DeepSeekV4Parser( + mock_tokenizer, chat_template_kwargs={"thinking": False} + ) + text = self._ORPHAN + DSML_TOOL_START + self._ORPHAN + DSML_TOOL_END + result = parser.extract_tool_calls(text, mock_request) + assert [tc.function.name for tc in result.tool_calls] == [ + "terminal", + "terminal", + ] + assert result.content is None + + def test_streaming_marker_split_across_deltas(self, mock_tokenizer, mock_request): + text = "Let me run it.\n" + self._ORPHAN + DSML_TOOL_END + parser = DeepSeekV4Parser( + mock_tokenizer, chat_template_kwargs={"thinking": False} + ) + results = simulate_tool_streaming(parser, mock_request, list(text)) + + assert collect_function_name(results) == "terminal" + args = json.loads(collect_tool_arguments(results)) + assert args == {"command": "echo hi"} + content = collect_content(results) + assert "Let me run it." in content + assert "DSML" not in content + + def test_parallel_orphan_invokes(self, mock_tokenizer, mock_request): + parser = DeepSeekV4Parser( + mock_tokenizer, chat_template_kwargs={"thinking": False} + ) + result = parser.extract_tool_calls(self._ORPHAN + self._ORPHAN, mock_request) + + assert [tc.function.name for tc in result.tool_calls] == [ + "terminal", + "terminal", + ] + assert result.content is None + + def test_orphan_invoke_inside_reasoning_is_not_a_tool_call( + self, mock_tokenizer, mock_request + ): + parser = DeepSeekV4Parser( + mock_tokenizer, chat_template_kwargs={"thinking": True} + ) + text = "Let me run it.\n\n" + self._ORPHAN + DSML_TOOL_END + reasoning, content, tool_calls = parser.parse(text, mock_request) + + assert not tool_calls + assert content is None + assert reasoning is not None and reasoning.startswith("Let me run it.") + + def test_drafted_invoke_in_reasoning_does_not_hijack_real_call( + self, mock_tokenizer, mock_request + ): + """A drafted invoke in ```` must not steal the real call.""" + parser = DeepSeekV4Parser( + mock_tokenizer, chat_template_kwargs={"thinking": True} + ) + text = ( + f"Maybe {DSML_INVOKE_PREFIX}search{DSML_INVOKE_NAME_END} is wrong; " + f"use terminal.{DSML_THINK_END}\n" + f"{DSML_TOOL_START}\n{self._ORPHAN}{DSML_TOOL_END}" + ) + reasoning, content, tool_calls = parser.parse(text, mock_request) + + assert tool_calls is not None + assert [tc.name for tc in tool_calls] == ["terminal"] + assert json.loads(tool_calls[0].arguments) == {"command": "echo hi"} + assert reasoning is not None and "use terminal." in reasoning + + def test_streaming_orphan_invoke_inside_reasoning_is_not_a_tool_call( + self, mock_tokenizer + ): + parser = DeepSeekV4Parser( + mock_tokenizer, chat_template_kwargs={"thinking": True} + ) + chunks = ["Let me run it.\n\n", *list(self._ORPHAN), DSML_TOOL_END] + reasoning, content = simulate_reasoning_streaming(parser, chunks) + + assert reasoning.startswith("Let me run it.") + assert content == "" + + def test_wrapper_present_is_unchanged(self, mock_tokenizer, mock_request): + parser = DeepSeekV4Parser(mock_tokenizer) + text = DSML_TOOL_START + "\n" + self._ORPHAN + DSML_TOOL_END + result = parser.extract_tool_calls(text, mock_request) + + assert len(result.tool_calls) == 1 + assert result.tool_calls[0].function.name == "terminal" + assert result.content is None # ── Thinking mode initial state ────────────────────────────────────── diff --git a/tests/tool_parsers/test_deepseekv4_tool_parser.py b/tests/tool_parsers/test_deepseekv4_tool_parser.py index e7109626c0..e79465ed9b 100644 --- a/tests/tool_parsers/test_deepseekv4_tool_parser.py +++ b/tests/tool_parsers/test_deepseekv4_tool_parser.py @@ -148,7 +148,9 @@ def test_extract_tool_calls(): } -def test_function_calls_block_is_not_accepted(): +def test_function_calls_wrapper_is_not_recognized(): + # The V3.2 wrapper is not a V4 terminal, so it passes through as content, + # but the invoke inside it is still parsed (tool calls anchor on the invoke). parser = make_parser() model_output = build_tool_call("search", {"query": "vllm"}).replace( "tool_calls", "function_calls" @@ -156,8 +158,38 @@ def test_function_calls_block_is_not_accepted(): result = parser.extract_tool_calls(model_output, make_request()) - assert not result.tools_called - assert result.content == model_output + assert result.tools_called + assert result.tool_calls[0].function.name == "search" + assert result.content == "<|DSML|function_calls>\n" + + +def test_missing_tool_calls_wrapper_is_recovered(): + # Regression for #48931: at long context the model omits the + # <|DSML|tool_calls> START token but still emits a complete invoke. + parser = make_parser() + model_output = build_tool_call("search", {"query": "vllm"}).replace( + TC_START + "\n", "" + ) + assert TC_START not in model_output + + result = parser.extract_tool_calls(model_output, make_request()) + + assert result.tools_called + assert result.tool_calls[0].function.name == "search" + assert json.loads(result.tool_calls[0].function.arguments) == {"query": "vllm"} + assert result.content is None + + deltas = stream(make_parser(), model_output, chunk_size=3) + names = [ + tc.function.name + for d in deltas + if d.tool_calls + for tc in d.tool_calls + if tc.function.name + ] + assert names == ["search"] + assert json.loads(reconstruct_args(deltas)) == {"query": "vllm"} + assert not any(d.content and "DSML" in d.content for d in deltas) def test_streaming_extracts_complete_invokes(): diff --git a/vllm/parser/deepseek_v32.py b/vllm/parser/deepseek_v32.py index e59bd06219..f76c954a93 100644 --- a/vllm/parser/deepseek_v32.py +++ b/vllm/parser/deepseek_v32.py @@ -75,6 +75,10 @@ def deepseek_v32_config() -> ParserEngineConfig: ParserState.TOOL_NAME, (EventType.TOOL_CALL_START,), ), + (ParserState.CONTENT, "INVOKE_PREFIX"): Transition( + ParserState.TOOL_NAME, + (EventType.TOOL_CALL_START,), + ), (ParserState.TOOL_NAME, "INVOKE_NAME_END"): Transition( ParserState.TOOL_ARGS, (), @@ -84,7 +88,7 @@ def deepseek_v32_config() -> ParserEngineConfig: (EventType.TOOL_CALL_END,), ), (ParserState.TOOL_ARGS, "TOOL_END"): Transition( - ParserState.CONTENT, + ParserState.TOOL_BETWEEN, (EventType.TOOL_CALL_END,), ), # Parallel tool calls @@ -92,8 +96,14 @@ def deepseek_v32_config() -> ParserEngineConfig: ParserState.TOOL_NAME, (EventType.TOOL_CALL_START,), ), + # A tool call ends the turn: stay in TOOL_BETWEEN so any text + # after the block is dropped. (ParserState.TOOL_BETWEEN, "TOOL_END"): Transition( - ParserState.CONTENT, + ParserState.TOOL_BETWEEN, + (), + ), + (ParserState.TOOL_BETWEEN, "TOOL_START"): Transition( + ParserState.TOOL_PREAMBLE, (), ), }, diff --git a/vllm/parser/deepseek_v4.py b/vllm/parser/deepseek_v4.py index 7f383d6956..8159924f93 100644 --- a/vllm/parser/deepseek_v4.py +++ b/vllm/parser/deepseek_v4.py @@ -177,6 +177,10 @@ def deepseek_v4_config(thinking: bool = False) -> ParserEngineConfig: ParserState.TOOL_NAME, (EventType.TOOL_CALL_START,), ), + (ParserState.CONTENT, "INVOKE_PREFIX"): Transition( + ParserState.TOOL_NAME, + (EventType.TOOL_CALL_START,), + ), (ParserState.TOOL_NAME, "INVOKE_NAME_END"): Transition( ParserState.TOOL_ARGS, (), @@ -186,7 +190,7 @@ def deepseek_v4_config(thinking: bool = False) -> ParserEngineConfig: (EventType.TOOL_CALL_END,), ), (ParserState.TOOL_ARGS, "TOOL_END"): Transition( - ParserState.CONTENT, + ParserState.TOOL_BETWEEN, (EventType.TOOL_CALL_END,), ), # Parallel tool calls @@ -194,8 +198,14 @@ def deepseek_v4_config(thinking: bool = False) -> ParserEngineConfig: ParserState.TOOL_NAME, (EventType.TOOL_CALL_START,), ), + # A tool call ends the turn: stay in TOOL_BETWEEN so any text + # after the block is dropped. (ParserState.TOOL_BETWEEN, "TOOL_END"): Transition( - ParserState.CONTENT, + ParserState.TOOL_BETWEEN, + (), + ), + (ParserState.TOOL_BETWEEN, "TOOL_START"): Transition( + ParserState.TOOL_PREAMBLE, (), ), },