diff --git a/tests/parser/engine/test_deepseek_v32.py b/tests/parser/engine/test_deepseek_v32.py new file mode 100644 index 0000000000..7825f5add8 --- /dev/null +++ b/tests/parser/engine/test_deepseek_v32.py @@ -0,0 +1,268 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for DeepSeek V3.2 parser engine semantics. + +V3.2 uses the same DSML parameter format as V4 but wraps tool calls in +``<|DSML|function_calls>`` instead of ``<|DSML|tool_calls>`` and has +no reasoning (````/````) support. +""" + +import json + +import pytest + +from tests.parser.engine.conftest import make_mock_tokenizer +from tests.parser.engine.streaming_helpers import ( + collect_content, + collect_function_name, + collect_tool_arguments, + simulate_tool_streaming, +) +from vllm.parser.deepseek_v4 import ( + DSML_INVOKE_END, + DSML_INVOKE_NAME_END, + DSML_INVOKE_PREFIX, +) +from vllm.parser.deepseek_v32 import ( + DSML_FUNC_END, + DSML_FUNC_START, + DeepSeekV32Parser, +) +from vllm.parser.engine.parser_engine_config import ParserState + +_PARAM_OPEN = '|DSML|parameter name="{name}" string="{is_str}">' +_PARAM_CLOSE = "" + + +def _param(name: str, is_str: str, value: str) -> str: + return f"<{_PARAM_OPEN.format(name=name, is_str=is_str)}{value}{_PARAM_CLOSE}" + + +def _invoke(name: str, *params: str) -> str: + body = "\n".join(params) + return ( + f"{DSML_INVOKE_PREFIX}{name}{DSML_INVOKE_NAME_END}\n{body}\n{DSML_INVOKE_END}" + ) + + +def _func_calls(*invocations: str) -> str: + body = "\n".join(invocations) + return f"{DSML_FUNC_START}\n{body}\n{DSML_FUNC_END}" + + +def _make_tool(name, properties): + from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionToolsParam, + ) + + return ChatCompletionToolsParam( + type="function", + function={ + "name": name, + "parameters": { + "type": "object", + "properties": properties, + }, + }, + ) + + +@pytest.fixture +def mock_tokenizer(): + return make_mock_tokenizer({}) + + +@pytest.fixture +def mock_request(): + from unittest.mock import MagicMock + + from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionRequest, + ) + + req = MagicMock(spec=ChatCompletionRequest) + req.tools = [] + req.tool_choice = "auto" + return req + + +# ── Non-streaming extraction ──────────────────────────────────────── + + +class TestNonStreaming: + def test_no_tool_call(self, mock_tokenizer, mock_request): + parser = DeepSeekV32Parser(mock_tokenizer) + result = parser.extract_tool_calls("Hello world", mock_request) + assert not result.tools_called + assert result.content == "Hello world" + + def test_single_tool(self, mock_tokenizer, mock_request): + text = _func_calls( + _invoke("get_weather", _param("city", "true", "SF")), + ) + parser = DeepSeekV32Parser(mock_tokenizer) + result = parser.extract_tool_calls(text, mock_request) + assert result.tools_called + assert len(result.tool_calls) == 1 + assert result.tool_calls[0].function.name == "get_weather" + args = json.loads(result.tool_calls[0].function.arguments) + assert args == {"city": "SF"} + + def test_parallel_tools(self, mock_tokenizer, mock_request): + text = _func_calls( + _invoke("get_weather", _param("city", "true", "SF")), + _invoke("get_weather", _param("city", "true", "NYC")), + ) + parser = DeepSeekV32Parser(mock_tokenizer) + result = parser.extract_tool_calls(text, mock_request) + assert result.tools_called + assert len(result.tool_calls) == 2 + assert json.loads(result.tool_calls[0].function.arguments) == {"city": "SF"} + assert json.loads(result.tool_calls[1].function.arguments) == {"city": "NYC"} + + def test_content_before_tool_call(self, mock_tokenizer, mock_request): + text = "Let me check. " + _func_calls( + _invoke("search", _param("q", "true", "vllm")), + ) + parser = DeepSeekV32Parser(mock_tokenizer) + result = parser.extract_tool_calls(text, mock_request) + assert result.tools_called + assert result.content is not None + assert "Let me check" in result.content + + def test_non_string_params_json_parsed(self, mock_tokenizer, mock_request): + text = _func_calls( + _invoke( + "toggle", + _param("enabled", "false", "true"), + _param("count", "false", "42"), + ), + ) + parser = DeepSeekV32Parser(mock_tokenizer) + result = parser.extract_tool_calls(text, mock_request) + args = json.loads(result.tool_calls[0].function.arguments) + assert args["enabled"] is True + assert args["count"] == 42 + + def test_wrapper_unwrapping(self, mock_tokenizer, mock_request): + tool = _make_tool("get_weather", {"location": {"type": "string"}}) + mock_request.tools = [tool] + text = _func_calls( + _invoke( + "get_weather", + _param("arguments", "false", '{"location":"Beijing"}'), + ), + ) + parser = DeepSeekV32Parser(mock_tokenizer, tools=[tool]) + result = parser.extract_tool_calls(text, mock_request) + args = json.loads(result.tool_calls[0].function.arguments) + assert args == {"location": "Beijing"} + + +# ── Initial state ──────────────────────────────────────────────────── + + +class TestInitialState: + def test_always_content(self, mock_tokenizer): + parser = DeepSeekV32Parser(mock_tokenizer) + cfg = parser.parser_engine_config + assert cfg.initial_state == ParserState.CONTENT + + def test_ignores_thinking_kwargs(self, mock_tokenizer): + parser = DeepSeekV32Parser( + mock_tokenizer, + chat_template_kwargs={"thinking": True, "enable_thinking": True}, + ) + cfg = parser.parser_engine_config + assert cfg.initial_state == ParserState.CONTENT + + +# ── Streaming ──────────────────────────────────────────────────────── + + +class TestStreaming: + def test_single_tool_streaming(self, mock_tokenizer, mock_request): + text = _func_calls( + _invoke("get_weather", _param("city", "true", "SF")), + ) + parser = DeepSeekV32Parser(mock_tokenizer) + results = simulate_tool_streaming(parser, mock_request, list(text)) + assert collect_function_name(results) == "get_weather" + args_json = collect_tool_arguments(results) + assert json.loads(args_json) == {"city": "SF"} + + def test_content_before_tool_streaming(self, mock_tokenizer, mock_request): + text = "Checking... " + _func_calls( + _invoke("fn", _param("k", "true", "v")), + ) + parser = DeepSeekV32Parser(mock_tokenizer) + results = simulate_tool_streaming(parser, mock_request, list(text)) + content = collect_content(results) + assert "Checking" in content + + def test_parallel_tools_streaming(self, mock_tokenizer, mock_request): + text = _func_calls( + _invoke("fn_a", _param("x", "true", "1")), + _invoke("fn_b", _param("y", "true", "2")), + ) + parser = DeepSeekV32Parser(mock_tokenizer) + results = simulate_tool_streaming(parser, mock_request, list(text)) + + names = [] + for delta, _ in results: + if delta and delta.tool_calls: + for tc in delta.tool_calls: + if tc.function and tc.function.name: + names.append(tc.function.name) + assert "fn_a" in names + assert "fn_b" in names + + def test_no_tool_content_only(self, mock_tokenizer, mock_request): + text = "Just some text, no tools." + parser = DeepSeekV32Parser(mock_tokenizer) + results = simulate_tool_streaming(parser, mock_request, list(text)) + content = collect_content(results) + assert "Just some text" in content + args = collect_tool_arguments(results) + assert args == "" + + def test_streaming_wrapper_unwrap_consistency(self, mock_tokenizer, mock_request): + tool = _make_tool("get_weather", {"location": {"type": "string"}}) + mock_request.tools = [tool] + parser = DeepSeekV32Parser(mock_tokenizer, tools=[tool]) + + chunks = [ + DSML_FUNC_START, + _invoke( + "get_weather", + _param("arguments", "false", '{"location": "NYC"}'), + ), + DSML_FUNC_END, + ] + + results = simulate_tool_streaming(parser, mock_request, chunks) + streamed_args = collect_tool_arguments(results) + + final_delta, _ = results[-1] + finish_delta = parser.finish_streaming() + extracted = parser._build_extracted_result(final_delta, finish_delta) + + assert extracted.tools_called is True + assert len(extracted.tool_calls) == 1 + final_args = extracted.tool_calls[0].function.arguments + assert json.loads(final_args) == {"location": "NYC"} + assert '"arguments"' not in streamed_args + assert final_args.startswith(streamed_args) + + def test_missing_invoke_end(self, mock_tokenizer, mock_request): + text = ( + f"{DSML_FUNC_START}\n" + f"{DSML_INVOKE_PREFIX}fn{DSML_INVOKE_NAME_END}\n" + f"{_param('k', 'true', 'v')}\n" + f"{DSML_FUNC_END}" + ) + parser = DeepSeekV32Parser(mock_tokenizer) + results = simulate_tool_streaming(parser, mock_request, list(text)) + assert collect_function_name(results) == "fn" + args = json.loads(collect_tool_arguments(results)) + assert args == {"k": "v"} diff --git a/tests/parser/engine/test_deepseek_v4.py b/tests/parser/engine/test_deepseek_v4.py new file mode 100644 index 0000000000..3aa7b3b9d2 --- /dev/null +++ b/tests/parser/engine/test_deepseek_v4.py @@ -0,0 +1,922 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for DeepSeek V4-specific parser engine semantics.""" + +import json + +import pytest + +from tests.parser.engine.conftest import make_mock_tokenizer +from tests.parser.engine.replay_harness import ( + DUMMY_TOOLS, + MockTokenizer, + _test_request, + collect_output, + replay_streaming, +) +from tests.parser.engine.streaming_helpers import ( + collect_content, + collect_function_name, + collect_tool_arguments, + simulate_reasoning_streaming, + simulate_tool_streaming, +) +from vllm.parser.abstract_parser import DelegatingParser +from vllm.parser.deepseek_v4 import ( + DSML_INVOKE_END, + DSML_INVOKE_NAME_END, + DSML_INVOKE_PREFIX, + DSML_THINK_END, + DSML_THINK_START, + DSML_TOOL_END, + DSML_TOOL_START, + DeepSeekV4Parser, + _dsml_arg_converter, + _unwrap_wrapper_args, + deepseek_v4_config, +) +from vllm.parser.engine.registered_adapters import ( + DeepSeekV4ParserReasoningAdapter, + DeepSeekV4ParserToolAdapter, +) + +_THINK_START_ID = 50 +_THINK_END_ID = 51 + +_PARAM_OPEN = '|DSML|parameter name="{name}" string="{is_str}">' +_PARAM_CLOSE = "" + + +def _param(name: str, is_str: str, value: str) -> str: + return f"<{_PARAM_OPEN.format(name=name, is_str=is_str)}{value}{_PARAM_CLOSE}" + + +@pytest.fixture +def mock_tokenizer(): + return make_mock_tokenizer( + { + DSML_THINK_START: _THINK_START_ID, + DSML_THINK_END: _THINK_END_ID, + } + ) + + +# ── Arg converter unit tests ───────────────────────────────────────── + + +class TestArgConverter: + def _raw(self, *params: tuple[str, str, str]) -> str: + lines = [_param(n, s, v) for n, s, v in params] + return "\n" + "\n".join(lines) + "\n" + + def test_string_param(self): + raw = self._raw(("city", "true", "杭州")) + result = json.loads(_dsml_arg_converter(raw, partial=False)) + assert result == {"city": "杭州"} + + def test_string_with_spaces_and_quotes(self): + raw = self._raw(("msg", "true", 'He said "hello world"')) + result = json.loads(_dsml_arg_converter(raw, partial=False)) + assert result["msg"] == 'He said "hello world"' + + def test_integer_param(self): + raw = self._raw(("count", "false", "42")) + result = json.loads(_dsml_arg_converter(raw, partial=False)) + assert result["count"] == 42 + assert isinstance(result["count"], int) + + def test_float_param(self): + raw = self._raw(("ratio", "false", "3.14")) + result = json.loads(_dsml_arg_converter(raw, partial=False)) + assert abs(result["ratio"] - 3.14) < 1e-9 + + def test_bool_param(self): + raw = self._raw(("flag", "false", "true")) + result = json.loads(_dsml_arg_converter(raw, partial=False)) + assert result["flag"] is True + + def test_array_param(self): + raw = self._raw(("items", "false", '["a", "b", "c"]')) + result = json.loads(_dsml_arg_converter(raw, partial=False)) + assert result["items"] == ["a", "b", "c"] + + def test_object_param(self): + raw = self._raw(("opts", "false", '{"key": "val"}')) + result = json.loads(_dsml_arg_converter(raw, partial=False)) + assert result["opts"] == {"key": "val"} + + def test_mixed_types(self): + raw = self._raw( + ("location", "true", "Tokyo"), + ("limit", "false", "10"), + ("active", "false", "false"), + ) + result = json.loads(_dsml_arg_converter(raw, partial=False)) + assert result == {"location": "Tokyo", "limit": 10, "active": False} + + def test_empty_args(self): + result = json.loads(_dsml_arg_converter("", partial=False)) + assert result == {} + + def test_invalid_json_fallback(self): + raw = self._raw(("data", "false", "[broken")) + result = json.loads(_dsml_arg_converter(raw, partial=False)) + assert result["data"] == "[broken" + + def test_chinese_chars_preserved_in_json(self): + raw = self._raw(("query", "true", "你好世界")) + raw_json = _dsml_arg_converter(raw, partial=False) + assert "你好世界" in raw_json + result = json.loads(raw_json) + assert result["query"] == "你好世界" + + def test_partial_complete_plus_in_progress(self): + raw = self._raw(("city", "true", "Tokyo")) + raw += f"<{_PARAM_OPEN.format(name='unit', is_str='true')}celsi" + result = json.loads(_dsml_arg_converter(raw, partial=True)) + assert result["city"] == "Tokyo" + assert result["unit"] == "celsi" + + def test_partial_no_in_progress(self): + raw = self._raw(("city", "true", "Tokyo")) + result = json.loads(_dsml_arg_converter(raw, partial=True)) + assert result == {"city": "Tokyo"} + + def test_partial_value_with_angle_bracket(self): + raw = f"<{_PARAM_OPEN.format(name='code', is_str='true')}a absorption and duplicate absorption ───────── + + +class TestThinkTagAbsorption: + def test_bare_think_end_not_leaked(self, mock_tokenizer): + parser = DeepSeekV4Parser(mock_tokenizer) + chunks = ["", "Here is the direct answer."] + reasoning, content = simulate_reasoning_streaming(parser, chunks) + assert reasoning == "" + assert "" not in content + assert "Here is the direct answer" in content + + def test_duplicate_think_start_absorbed(self, mock_tokenizer): + parser = DeepSeekV4Parser( + mock_tokenizer, chat_template_kwargs={"thinking": True} + ) + chunks = [ + "\n", + "Some reasoning.\n", + "\n", + "Answer.", + ] + reasoning, content = simulate_reasoning_streaming(parser, chunks) + assert "Some reasoning" in reasoning + assert "Answer" in content + + +# ── Missing before ──────────── + + +class TestMissingInvokeEnd: + def test_non_streaming(self, mock_tokenizer, mock_request): + parser = DeepSeekV4Parser(mock_tokenizer) + text = ( + f"{DSML_TOOL_START}" + f"{DSML_INVOKE_PREFIX}get_weather{DSML_INVOKE_NAME_END}\n" + f"{_param('location', 'true', 'NYC')}\n" + f"{DSML_TOOL_END}" + ) + 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 == "get_weather" + args = json.loads(result.tool_calls[0].function.arguments) + assert args == {"location": "NYC"} + + def test_streaming_with_trailing_content(self, mock_tokenizer, mock_request): + parser = DeepSeekV4Parser(mock_tokenizer) + chunks = [ + DSML_TOOL_START, + f"{DSML_INVOKE_PREFIX}get_weather{DSML_INVOKE_NAME_END}\n" + f"{_param('location', 'true', 'NYC')}\n", + DSML_TOOL_END, + "Done.", + ] + + results = simulate_tool_streaming(parser, mock_request, chunks) + + assert collect_function_name(results) == "get_weather" + args = json.loads(collect_tool_arguments(results)) + assert args == {"location": "NYC"} + assert "Done." in collect_content(results) + + +# ── Thinking mode initial state ────────────────────────────────────── + + +class TestThinkingModeConfig: + def test_thinking_true_starts_in_reasoning(self): + cfg = deepseek_v4_config(thinking=True) + assert cfg.initial_state.name == "REASONING" + + def test_thinking_false_starts_in_content(self): + cfg = deepseek_v4_config(thinking=False) + assert cfg.initial_state.name == "CONTENT" + + def test_enable_thinking_kwarg(self, mock_tokenizer): + p = DeepSeekV4Parser( + mock_tokenizer, chat_template_kwargs={"enable_thinking": True} + ) + assert p.parser_engine_config.initial_state.name == "REASONING" + + def test_no_thinking_kwarg_defaults_to_content(self, mock_tokenizer): + p = DeepSeekV4Parser(mock_tokenizer) + assert p.parser_engine_config.initial_state.name == "CONTENT" + + def test_thinking_mode_reasoning_without_tags(self, mock_tokenizer): + parser = DeepSeekV4Parser( + mock_tokenizer, chat_template_kwargs={"thinking": True} + ) + chunks = [ + "\n\nLet me consider ", + "this carefully.\n", + "\n", + "Here is the result.", + ] + reasoning, content = simulate_reasoning_streaming(parser, chunks) + assert "Let me consider" in reasoning + assert "Here is the result" in content + + def test_thinking_mode_all_reasoning_no_end_tag(self, mock_tokenizer): + parser = DeepSeekV4Parser( + mock_tokenizer, chat_template_kwargs={"thinking": True} + ) + chunks = ["I'll review ", "the PR."] + reasoning, content = simulate_reasoning_streaming(parser, chunks) + assert "review" in reasoning + assert "the PR" in reasoning + assert content == "" + + def test_reasoning_effort_none_overrides_enable_thinking(self, mock_tokenizer): + p = DeepSeekV4Parser( + mock_tokenizer, + chat_template_kwargs={ + "enable_thinking": True, + "reasoning_effort": "none", + }, + ) + assert p.parser_engine_config.initial_state.name == "CONTENT" + + +# ── Implicit reasoning end (missing before tool calls) ───── + + +class TestImplicitReasoningEnd: + """Tool call markers end reasoning implicitly when is missing. + + DeepSeek V4 models occasionally omit before emitting tool calls. + The (REASONING, TOOL_START) transition handles this gracefully. + """ + + @pytest.fixture + def thinking_parser(self, mock_tokenizer): + return DeepSeekV4Parser(mock_tokenizer, chat_template_kwargs={"thinking": True}) + + def _reasoning_then_tool(self, reasoning_text: str) -> str: + return reasoning_text + _tool_calls( + _invoke("get_weather", ("location", "true", "NYC")), + ) + + def test_non_streaming_extract_reasoning_implicit_end(self, thinking_parser): + text = self._reasoning_then_tool("Let me look up the weather.\n\n") + reasoning, content = thinking_parser.extract_reasoning(text, None) + assert reasoning == "Let me look up the weather." + assert DSML_TOOL_START not in reasoning + assert DSML_INVOKE_PREFIX not in reasoning + assert content is None + + def test_non_streaming_extract_tool_calls_implicit_end( + self, thinking_parser, mock_request + ): + text = self._reasoning_then_tool("Let me look up the weather.\n\n") + result = thinking_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 == "get_weather" + args = json.loads(result.tool_calls[0].function.arguments) + assert args == {"location": "NYC"} + + def test_non_streaming_parse_implicit_end(self, thinking_parser, mock_request): + text = self._reasoning_then_tool("Let me look up the weather.\n\n") + reasoning, content, tool_calls = thinking_parser.parse(text, mock_request) + assert reasoning == "Let me look up the weather." + assert content is None + assert tool_calls is not None + assert len(tool_calls) == 1 + assert tool_calls[0].name == "get_weather" + args = json.loads(tool_calls[0].arguments) + assert args == {"location": "NYC"} + + def test_streaming_reasoning_implicit_end(self, thinking_parser): + chunks = [ + "Let me look up the weather.\n\n", + DSML_TOOL_START, + DSML_INVOKE_PREFIX + "get_weather" + DSML_INVOKE_NAME_END, + ] + reasoning, content = simulate_reasoning_streaming(thinking_parser, chunks) + assert reasoning == "Let me look up the weather." + assert DSML_TOOL_START not in reasoning + assert DSML_INVOKE_PREFIX not in reasoning + + def test_streaming_tool_extraction_implicit_end( + self, thinking_parser, mock_request + ): + chunks = [ + "Let me check.\n\n", + DSML_TOOL_START, + DSML_INVOKE_PREFIX + + "get_weather" + + DSML_INVOKE_NAME_END + + "\n" + + _param("location", "true", "NYC") + + "\n" + + DSML_INVOKE_END, + DSML_TOOL_END, + ] + results = simulate_tool_streaming(thinking_parser, mock_request, chunks) + assert collect_function_name(results) == "get_weather" + args = json.loads(collect_tool_arguments(results)) + assert args == {"location": "NYC"} + + def test_thinking_false_explicit_think_then_tool_call(self, mock_tokenizer): + parser = DeepSeekV4Parser(mock_tokenizer) + chunks = [ + DSML_THINK_START, + "Let me check the weather.", + DSML_TOOL_START, + DSML_INVOKE_PREFIX + "get_weather" + DSML_INVOKE_NAME_END, + ] + reasoning, content = simulate_reasoning_streaming(parser, chunks) + assert "Let me check the weather" in reasoning + assert DSML_TOOL_START not in reasoning + assert DSML_THINK_START not in reasoning + + def test_non_streaming_parallel_tools_after_implicit_end( + self, thinking_parser, mock_request + ): + text = "I need both.\n\n" + _tool_calls( + _invoke("get_weather", ("location", "true", "NYC")), + _invoke("get_time", ("timezone", "true", "EST")), + ) + result = thinking_parser.extract_tool_calls(text, mock_request) + assert result.tools_called is True + assert len(result.tool_calls) == 2 + assert result.tool_calls[0].function.name == "get_weather" + assert result.tool_calls[1].function.name == "get_time" + + def test_streaming_implicit_end_trailing_whitespace_stripped(self, thinking_parser): + chunks = [ + "Reasoning.\n\n\n", + DSML_TOOL_START, + DSML_INVOKE_PREFIX + "func" + DSML_INVOKE_NAME_END, + ] + reasoning, content = simulate_reasoning_streaming(thinking_parser, chunks) + assert reasoning == "Reasoning." + + +# ── Wrapper argument unwrapping ────────────────────────────────────── + + +class TestWrapperUnwrapping: + def test_unwrap_arguments_wrapper(self): + from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionToolsParam, + ) + + tool = ChatCompletionToolsParam( + type="function", + function={ + "name": "get_weather", + "parameters": { + "type": "object", + "properties": {"location": {"type": "string"}}, + }, + }, + ) + + result = _unwrap_wrapper_args( + '{"arguments": {"location": "Beijing"}}', + [tool], + "get_weather", + ) + assert json.loads(result) == {"location": "Beijing"} + + def test_unwrap_input_wrapper(self): + from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionToolsParam, + ) + + tool = ChatCompletionToolsParam( + type="function", + function={ + "name": "get_weather", + "parameters": { + "type": "object", + "properties": {"location": {"type": "string"}}, + }, + }, + ) + + result = _unwrap_wrapper_args( + '{"input": {"location": "Beijing"}}', + [tool], + "get_weather", + ) + assert json.loads(result) == {"location": "Beijing"} + + def test_no_unwrap_when_key_in_schema(self): + from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionToolsParam, + ) + + tool = ChatCompletionToolsParam( + type="function", + function={ + "name": "func", + "parameters": { + "type": "object", + "properties": {"arguments": {"type": "string"}}, + }, + }, + ) + + result = _unwrap_wrapper_args( + '{"arguments": "some value"}', + [tool], + "func", + ) + assert json.loads(result) == {"arguments": "some value"} + + def test_no_unwrap_when_no_tools(self): + result = _unwrap_wrapper_args( + '{"arguments": {"location": "Beijing"}}', + None, + "get_weather", + ) + assert json.loads(result) == {"arguments": {"location": "Beijing"}} + + def test_unwrap_json_string_inner(self): + from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionToolsParam, + ) + + tool = ChatCompletionToolsParam( + type="function", + function={ + "name": "get_weather", + "parameters": { + "type": "object", + "properties": {"location": {"type": "string"}}, + }, + }, + ) + + result = _unwrap_wrapper_args( + '{"arguments": "{\\"location\\": \\"Beijing\\"}"}', + [tool], + "get_weather", + ) + assert json.loads(result) == {"location": "Beijing"} + + +# ── Parallel tool call wrapper unwrapping ─────────────────────────── + + +def _make_tool(name, properties): + from vllm.entrypoints.openai.chat_completion.protocol import ( # noqa: E501 + ChatCompletionToolsParam, + ) + + return ChatCompletionToolsParam( + type="function", + function={ + "name": name, + "parameters": { + "type": "object", + "properties": properties, + }, + }, + ) + + +def _invoke(name, *params): + body = "\n".join(_param(n, s, v) for n, s, v in params) + return ( + f"{DSML_INVOKE_PREFIX}{name}{DSML_INVOKE_NAME_END}\n{body}\n{DSML_INVOKE_END}" + ) + + +def _tool_calls(*invokes): + return DSML_TOOL_START + "\n".join(invokes) + DSML_TOOL_END + + +class TestParallelUnwrapping: + @pytest.fixture + def weather_tool(self): + return _make_tool( + "get_weather", + { + "location": {"type": "string"}, + "unit": {"type": "string"}, + }, + ) + + @pytest.fixture + def time_tool(self): + return _make_tool( + "get_time", + {"timezone": {"type": "string"}}, + ) + + @pytest.mark.parametrize( + "weather_args, expected", + [ + ( + '{"location": "NYC", "unit": "celsius"}', + {"location": "NYC", "unit": "celsius"}, + ), + ('{"location": "NYC"}', {"location": "NYC"}), + ], + ids=["all_props", "subset_props"], + ) + def test_unwrap_parallel_uses_correct_schema( + self, + mock_tokenizer, + mock_request, + weather_tool, + time_tool, + weather_args, + expected, + ): + tools = [weather_tool, time_tool] + parser = DeepSeekV4Parser(mock_tokenizer, tools=tools) + mock_request.tools = tools + + text = _tool_calls( + _invoke("get_weather", ("arguments", "false", weather_args)), + _invoke("get_time", ("timezone", "true", "EST")), + ) + + result = parser.extract_tool_calls(text, mock_request) + + assert result.tools_called is True + assert len(result.tool_calls) == 2 + assert result.tool_calls[0].function.name == "get_weather" + args0 = json.loads(result.tool_calls[0].function.arguments) + assert args0 == expected + assert result.tool_calls[1].function.name == "get_time" + args1 = json.loads(result.tool_calls[1].function.arguments) + assert args1 == {"timezone": "EST"} + + def test_unwrap_parallel_streaming( + self, mock_tokenizer, mock_request, weather_tool, time_tool + ): + tools = [weather_tool, time_tool] + parser = DeepSeekV4Parser(mock_tokenizer, tools=tools) + mock_request.tools = tools + + chunks = [ + DSML_TOOL_START, + _invoke( + "get_weather", + ("arguments", "false", '{"location": "NYC"}'), + ), + _invoke("get_time", ("timezone", "true", "EST")), + DSML_TOOL_END, + ] + + results = simulate_tool_streaming(parser, mock_request, chunks) + final_delta, _ = results[-1] + finish_delta = parser.finish_streaming() + extracted = parser._build_extracted_result(final_delta, finish_delta) + + assert extracted.tools_called is True + assert len(extracted.tool_calls) == 2 + args0 = json.loads(extracted.tool_calls[0].function.arguments) + assert args0 == {"location": "NYC"} + args1 = json.loads(extracted.tool_calls[1].function.arguments) + assert args1 == {"timezone": "EST"} + + def test_no_unwrap_parallel_when_no_match( + self, mock_tokenizer, mock_request, weather_tool, time_tool + ): + tools = [weather_tool, time_tool] + parser = DeepSeekV4Parser(mock_tokenizer, tools=tools) + mock_request.tools = tools + + text = _tool_calls( + _invoke( + "get_weather", + ("arguments", "false", '{"unknown_key": "val"}'), + ), + _invoke("get_time", ("timezone", "true", "EST")), + ) + + result = parser.extract_tool_calls(text, mock_request) + + assert len(result.tool_calls) == 2 + args0 = json.loads(result.tool_calls[0].function.arguments) + assert args0 == {"arguments": {"unknown_key": "val"}} + args1 = json.loads(result.tool_calls[1].function.arguments) + assert args1 == {"timezone": "EST"} + + def test_unwrap_single_tool_still_works(self, mock_tokenizer, mock_request): + tool = _make_tool("get_weather", {"location": {"type": "string"}}) + tools = [tool] + parser = DeepSeekV4Parser(mock_tokenizer, tools=tools) + mock_request.tools = tools + + text = _tool_calls( + _invoke( + "get_weather", + ("arguments", "false", '{"location": "Beijing"}'), + ), + ) + + result = parser.extract_tool_calls(text, mock_request) + + assert result.tools_called is True + assert len(result.tool_calls) == 1 + args = json.loads(result.tool_calls[0].function.arguments) + assert args == {"location": "Beijing"} + + +# ── Streaming wrapper consistency ───────────────────────────────────── + + +class TestStreamingWrapperConsistency: + """Streamed arg deltas must stay consistent with final extraction + when wrapper params like 'arguments' are unwrapped.""" + + def test_streaming_wrapper_unwrap_consistency(self, mock_tokenizer, mock_request): + tool = _make_tool("get_weather", {"location": {"type": "string"}}) + tools = [tool] + parser = DeepSeekV4Parser(mock_tokenizer, tools=tools) + mock_request.tools = tools + + chunks = [ + DSML_TOOL_START, + _invoke( + "get_weather", + ("arguments", "false", '{"location": "NYC"}'), + ), + DSML_TOOL_END, + ] + + results = simulate_tool_streaming(parser, mock_request, chunks) + streamed_args = collect_tool_arguments(results) + + final_delta, _ = results[-1] + finish_delta = parser.finish_streaming() + extracted = parser._build_extracted_result(final_delta, finish_delta) + + assert extracted.tools_called is True + assert len(extracted.tool_calls) == 1 + + final_args = extracted.tool_calls[0].function.arguments + assert json.loads(final_args) == {"location": "NYC"} + + assert '"arguments"' not in streamed_args, ( + f"Streamed args should not contain wrapper key, got: {streamed_args!r}" + ) + + assert final_args.startswith(streamed_args), ( + f"Extracted args {final_args!r} " + f"should start with streamed args {streamed_args!r}" + ) + + +# ── DelegatingParser: large delta with + tool calls ───────── + +_DSV4_FULL_VOCAB = { + DSML_THINK_START: 128821, + DSML_THINK_END: 128822, + DSML_TOOL_START: 128823, + DSML_TOOL_END: 128824, +} + + +class _DeepSeekV4Delegating(DelegatingParser): + reasoning_parser_cls = DeepSeekV4ParserReasoningAdapter + tool_parser_cls = DeepSeekV4ParserToolAdapter + + +def _dsv4_tokens( + reasoning: str, + tool_name: str, + params: list[tuple[str, str, str]], +) -> list[tuple[int, str]]: + """Build a token sequence: reasoning + + DSML tool block.""" + tokens: list[tuple[int, str]] = [] + tid = 100 + + for word in reasoning.split(" "): + prefix = " " if tokens else "" + tokens.append((tid, prefix + word)) + tid += 1 + + tokens.append((_DSV4_FULL_VOCAB[DSML_THINK_END], DSML_THINK_END)) + + tokens.append((tid, "\n\n")) + tid += 1 + + tokens.append((_DSV4_FULL_VOCAB[DSML_TOOL_START], DSML_TOOL_START)) + + tokens.append((tid, "\n")) + tid += 1 + + invoke_prefix_text = f"{DSML_INVOKE_PREFIX}{tool_name}{DSML_INVOKE_NAME_END}" + tokens.append((tid, invoke_prefix_text)) + tid += 1 + + tokens.append((tid, "\n")) + tid += 1 + + for name, is_str, value in params: + param_text = _param(name, is_str, value) + tokens.append((tid, param_text)) + tid += 1 + tokens.append((tid, "\n")) + tid += 1 + + tokens.append((tid, DSML_INVOKE_END)) + tid += 1 + + tokens.append((tid, "\n")) + tid += 1 + + tokens.append((_DSV4_FULL_VOCAB[DSML_TOOL_END], DSML_TOOL_END)) + + return tokens + + +class TestDelegatingParserLargeDelta: + """Regression: tool calls lost when + DSML arrive in same delta. + + The DelegatingParser used by the serving layer splits reasoning and + tool parsing across two separate engine instances. When and + the entire DSML tool block arrive in a single large streaming delta, + the content transfer from reasoning adapter to tool adapter must + preserve the tool call text. + """ + + @pytest.fixture + def dsv4_tokens(self): + return _dsv4_tokens( + reasoning="The user wants the current weather in Berlin.", + tool_name="get_weather", + params=[ + ("location", "true", "Berlin"), + ("units", "true", "celsius"), + ], + ) + + @pytest.fixture + def dsv4_tokenizer(self, dsv4_tokens): + return MockTokenizer( + vocab=dict(_DSV4_FULL_VOCAB), + tokens=dsv4_tokens, + ) + + @pytest.mark.parametrize( + "chunk_size", + [1, 2, 3, 5, None], + ids=lambda c: f"chunk={c}", + ) + def test_tool_calls_extracted_at_all_chunk_sizes( + self, dsv4_tokenizer, dsv4_tokens, chunk_size + ): + parser = _DeepSeekV4Delegating( + dsv4_tokenizer, + chat_template_kwargs={"thinking": True}, + ) + deltas = replay_streaming( + parser, + dsv4_tokens, + chunk_size=chunk_size, + finished_on_last=True, + tools=DUMMY_TOOLS, + ) + output = collect_output(deltas) + + assert "The user wants" in output.reasoning + assert len(output.tool_calls) == 1, ( + f"Expected 1 tool call but got {len(output.tool_calls)}; " + f"reasoning={output.reasoning!r}, content={output.content!r}" + ) + assert output.tool_calls[0]["name"] == "get_weather" + args = json.loads(output.tool_calls[0]["arguments"]) + assert args == {"location": "Berlin", "units": "celsius"} + + def test_eos_drop_token_does_not_swallow_tool_calls(self): + """Tool calls must survive when an EOS DROP token's ID is in + delta_token_ids but its text is absent from delta_text. + + At large stream_interval the EOS token ID arrives in the same + delta as + tool calls but the detokenizer strips the + EOS text. The scanner's _rebuild_from_anchors defers all text + after when it can't find the EOS anchor text. The + reasoning adapter's finish_streaming must flush deferred text + as content (with skip_tool_parsing), not as tool calls. + """ + eos_text = "<|end▁of▁sentence|>" + eos_id = 128801 + vocab = { + DSML_THINK_START: 128821, + DSML_THINK_END: 128822, + eos_text: eos_id, + } + + reasoning = "The user wants weather." + tool_block = ( + "\n\n" + + DSML_TOOL_START + + "\n" + + DSML_INVOKE_PREFIX + + "get_weather" + + DSML_INVOKE_NAME_END + + "\n" + + _param("location", "true", "Berlin") + + "\n" + + DSML_INVOKE_END + + "\n" + + DSML_TOOL_END + ) + # delta_text does NOT include EOS text (detokenizer strips it) + full_text = reasoning + DSML_THINK_END + tool_block + # Build token list: word-split reasoning, then special tokens, + # then word-split tool block content, then EOS. + # EOS ID is present but its text is NOT in delta_text. + tokens: list[tuple[int, str]] = [] + tid = 100 + for word in reasoning.split(" "): + pfx = " " if tokens else "" + tokens.append((tid, pfx + word)) + tid += 1 + tokens.append((128822, DSML_THINK_END)) + for ch in tool_block: + tokens.append((tid, ch)) + tid += 1 + tokens.append((eos_id, eos_text)) + + all_ids = [t[0] for t in tokens] + tokenizer = MockTokenizer(vocab=vocab, tokens=tokens) + request = _test_request(tools=DUMMY_TOOLS) + + # All-in-one delta: EOS ID in token_ids but text NOT in + # delta_text (detokenizer strips EOS). This is the scenario + # at large stream_interval. + parser = _DeepSeekV4Delegating( + tokenizer, + chat_template_kwargs={"thinking": True}, + ) + deltas = [ + parser.parse_delta( + full_text, + all_ids, + request, + prompt_token_ids=[], + finished=True, + ) + ] + + output = collect_output(deltas) + + assert "The user wants" in output.reasoning + assert len(output.tool_calls) == 1, ( + f"Expected 1 tool call but got {len(output.tool_calls)}; " + f"reasoning={output.reasoning!r}, content={output.content!r}" + ) + assert output.tool_calls[0]["name"] == "get_weather" + args = json.loads(output.tool_calls[0]["arguments"]) + assert args == {"location": "Berlin"} diff --git a/tests/parser/engine/test_replay.py b/tests/parser/engine/test_replay.py index b8a1ba71a9..1626135f78 100644 --- a/tests/parser/engine/test_replay.py +++ b/tests/parser/engine/test_replay.py @@ -20,6 +20,7 @@ import pytest from tests.parser.engine.replay_harness import ( DUMMY_TOOLS, MockTokenizer, + Sample, _test_request, assert_no_terminal_leakage, assert_parse_output, @@ -100,6 +101,14 @@ def _discover_parsers() -> list[_ParserInfo]: _PARSERS = _discover_parsers() + +def _make_parser(parser_cls: type[ParserEngine], tokenizer, sample: Sample, **extra): + kwargs = dict(extra) + if sample.chat_template_kwargs: + kwargs["chat_template_kwargs"] = sample.chat_template_kwargs + return parser_cls(tokenizer, sample.tools, **kwargs) + + _ENGINE_PARSERS: dict[str, type[ParserEngine]] = { f"{p.name}_engine": p.parser_cls for p in _PARSERS } @@ -123,7 +132,7 @@ class TestReplayWithHoldback: def test_replay(self, parser_cls, sample, terminals, chunk_size, holdback): tokenizer = make_mock_tokenizer(sample) - parser = parser_cls(tokenizer, sample.tools) + parser = _make_parser(parser_cls, tokenizer, sample) deltas = replay_streaming( parser, sample.tokens, @@ -160,7 +169,7 @@ class TestTextHoldback: def test_replay(self, parser_cls, sample, terminals, delay): tokenizer = make_mock_tokenizer(sample) - parser = parser_cls(tokenizer, sample.tools) + parser = _make_parser(parser_cls, tokenizer, sample) deltas = replay_with_text_holdback( parser, sample.tokens, @@ -190,7 +199,7 @@ class TestReplay: def test_replay(self, parser_cls, sample, terminals, chunk_size): tokenizer = make_mock_tokenizer(sample) - parser = parser_cls(tokenizer, sample.tools) + parser = _make_parser(parser_cls, tokenizer, sample) deltas = replay_streaming( parser, sample.tokens, @@ -227,7 +236,7 @@ class TestDeferralFinish: def test_misaligned_last_delta_with_finish(self, parser_cls, sample, tool_end_text): tokenizer = make_mock_tokenizer(sample) - parser = parser_cls(tokenizer, sample.tools) + parser = _make_parser(parser_cls, tokenizer, sample) request = _test_request() @@ -528,10 +537,7 @@ class TestDropTokenReplay: for sample in parser_info.samples: injected = _inject_drop_tokens(sample) tokenizer = make_mock_tokenizer(injected) - parser = parser_info.parser_cls( - tokenizer, - tools=sample.tools, - ) + parser = _make_parser(parser_info.parser_cls, tokenizer, sample) results = replay_streaming( parser, @@ -562,10 +568,7 @@ class TestDropTokenNonStreaming: for sample in parser_info.samples: injected = _inject_drop_tokens(sample) tokenizer = make_mock_tokenizer(injected) - parser = parser_info.parser_cls( - tokenizer, - tools=sample.tools, - ) + parser = _make_parser(parser_info.parser_cls, tokenizer, sample) request = _test_request(tools=sample.tools) output = parse_non_streaming(parser, injected, request) diff --git a/tests/parser/engine/trace_builder.py b/tests/parser/engine/trace_builder.py index 93159a0ae2..533c64408f 100644 --- a/tests/parser/engine/trace_builder.py +++ b/tests/parser/engine/trace_builder.py @@ -29,6 +29,8 @@ from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionToolsParam, ) from vllm.parser.engine.registered_adapters import ( + DeepSeekV4Parser, + DeepSeekV32Parser, Gemma4Parser, Glm47MoeParser, KimiK2Parser, @@ -644,6 +646,144 @@ def _build_seed_oss(scenario: Scenario, validate: bool = True) -> Sample: return sample +# ── DeepSeek V4 (DSML tool format) ────────────────────────────────── + +_DSML = "|DSML|" +_DSV4_VOCAB: dict[str, int] = { + "": 128821, + "": 128822, + f"<{_DSML}tool_calls>": 128823, + f"": 128824, +} + + +def _dsv4_param_text(key: str, value: Any) -> str: + is_string = isinstance(value, str) + if is_string: + val_str = value + elif isinstance(value, bool): + val_str = "true" if value else "false" + elif isinstance(value, (int, float)): + val_str = str(value) + else: + val_str = json.dumps(value, ensure_ascii=False) + string_attr = "true" if is_string else "false" + return ( + f'<{_DSML}parameter name="{key}" string="{string_attr}">' + f"{val_str}\n" + ) + + +def _dsv4_tool_text(tc: ToolCallSpec) -> str: + parts = [f'<{_DSML}invoke name="{tc.name}">\n'] + for key, value in tc.arguments.items(): + parts.append(_dsv4_param_text(key, value)) + parts.append(f"\n") + return "".join(parts) + + +def _dsml_tool_segs( + scenario: Scenario, + tag: str, +) -> list[tuple[str, bool]]: + if not scenario.tool_calls: + return [] + parts = ["\n"] + for tc in scenario.tool_calls: + parts.append(_dsv4_tool_text(tc)) + return [ + (f"<{_DSML}{tag}>", True), + ("".join(parts), False), + (f"", True), + ] + + +def _dsv4_segments(scenario: Scenario, thinking: bool) -> list[tuple[str, bool]]: + segs: list[tuple[str, bool]] = [] + + if thinking: + if scenario.reasoning is not None: + segs.append((scenario.reasoning, False)) + if scenario.content is not None or scenario.tool_calls: + segs.append(("", True)) + else: + if scenario.reasoning is not None: + segs.append(("", True)) + segs.append((scenario.reasoning, False)) + segs.append(("", True)) + + if scenario.content is not None: + segs.append((scenario.content, False)) + + segs.extend(_dsml_tool_segs(scenario, "tool_calls")) + return segs + + +def _build_deepseek_v4(scenario: Scenario, validate: bool = True) -> Sample: + thinking = scenario.reasoning is not None + chat_kwargs = {"thinking": True} if thinking else None + + if thinking: + expected_reasoning: str | None = scenario.reasoning or "" + else: + expected_reasoning = None + + sample = _make_sample( + sample_id=f"deepseek_v4-{scenario.id}", + description=scenario.description, + vocab=_DSV4_VOCAB, + segments=_dsv4_segments(scenario, thinking), + expected_reasoning=expected_reasoning, + expected_content=_qwen3_expected_content(scenario), + expected_tool_calls=_expected_tc(scenario), + tools=_expected_tools(scenario), + chat_template_kwargs=chat_kwargs, + ) + if validate: + kwargs = {} + if chat_kwargs: + kwargs["chat_template_kwargs"] = chat_kwargs + _validate_sample(sample, DeepSeekV4Parser, **kwargs) + return sample + + +# ── DeepSeek V3.2 (DSML tool format, no reasoning) ────────────────── + +_DSV32_VOCAB: dict[str, int] = { + f"<{_DSML}function_calls>": 128830, + f"": 128831, +} + + +def _dsv32_segments(scenario: Scenario) -> list[tuple[str, bool]]: + segs: list[tuple[str, bool]] = [] + + if scenario.content is not None: + segs.append((scenario.content, False)) + + segs.extend(_dsml_tool_segs(scenario, "function_calls")) + return segs + + +def _build_deepseek_v32(scenario: Scenario, validate: bool = True) -> Sample | None: + if scenario.reasoning is not None: + return None + + sample = _make_sample( + sample_id=f"deepseek_v32-{scenario.id}", + description=scenario.description, + vocab=_DSV32_VOCAB, + segments=_dsv32_segments(scenario), + expected_reasoning=None, + expected_content=_qwen3_expected_content(scenario), + expected_tool_calls=_expected_tc(scenario), + tools=_expected_tools(scenario), + ) + if validate: + _validate_sample(sample, DeepSeekV32Parser) + return sample + + # ── GLM-4.7 MoE (XML tool format, starts in REASONING) ────────────── _GLM47_MOE_VOCAB: dict[str, int] = { @@ -811,13 +951,15 @@ _KIMI_K2_SCENARIOS = [ # ── Registry and public API ────────────────────────────────────────── _BUILDERS: dict[str, Any] = { - "qwen3": _build_qwen3, + "deepseek_v32": _build_deepseek_v32, + "deepseek_v4": _build_deepseek_v4, "gemma4": _build_gemma4, "minimax_m2": _build_minimax_m2, "nemotron_v3": _build_nemotron_v3, "seed_oss": _build_seed_oss, "glm47_moe": _build_glm47_moe, "kimi_k2": _build_kimi_k2, + "qwen3": _build_qwen3, } @@ -826,10 +968,10 @@ def build_samples(model: str) -> tuple[Sample, ...]: """Build all scenario samples for a model, self-validated.""" builder = _BUILDERS[model] scenarios = _KIMI_K2_SCENARIOS if model == "kimi_k2" else SCENARIOS - return tuple(builder(s) for s in scenarios) + return tuple(s for s in (builder(sc) for sc in scenarios) if s is not None) -def build_sample(model: str, scenario: Scenario) -> Sample: +def build_sample(model: str, scenario: Scenario) -> Sample | None: """Build a single sample for one model + scenario.""" return _BUILDERS[model](scenario) diff --git a/tests/reasoning/test_deepseekv3_reasoning_parser.py b/tests/reasoning/test_deepseekv3_reasoning_parser.py index f5b37194f9..81af6c2db4 100644 --- a/tests/reasoning/test_deepseekv3_reasoning_parser.py +++ b/tests/reasoning/test_deepseekv3_reasoning_parser.py @@ -11,6 +11,8 @@ from vllm.reasoning.deepseek_r1_reasoning_parser import DeepSeekR1ReasoningParse from vllm.reasoning.deepseek_v3_reasoning_parser import DeepSeekV3ReasoningParser from vllm.reasoning.identity_reasoning_parser import IdentityReasoningParser +pytestmark = pytest.mark.skip_global_cleanup + REASONING_MODEL_NAME = "deepseek-ai/DeepSeek-V3.1" @@ -35,9 +37,13 @@ def test_parser_selection(tokenizer, thinking, expected_parser_type): def test_deepseek_v4_reasoning_parser_alias(): + from vllm.reasoning.deepseek_v4_engine_reasoning_parser import ( + DeepSeekV4ParserReasoningAdapter, + ) + parser_cls = ReasoningParserManager.get_reasoning_parser("deepseek_v4") - assert parser_cls is DeepSeekV3ReasoningParser + assert parser_cls is DeepSeekV4ParserReasoningAdapter def test_identity_reasoning_parser_basic(tokenizer): diff --git a/tests/tool_parsers/test_deepseekv32_tool_parser.py b/tests/tool_parsers/test_deepseekv32_tool_parser.py index a35976b8bb..40ad6033d3 100644 --- a/tests/tool_parsers/test_deepseekv32_tool_parser.py +++ b/tests/tool_parsers/test_deepseekv32_tool_parser.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Unit tests for DeepSeekV32ToolParser. +"""Unit tests for DeepSeekV32EngineToolParser. These tests use a minimal mock tokenizer so no real model weights are required. """ @@ -17,7 +17,11 @@ from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionToolsParam, FunctionDefinition, ) -from vllm.tool_parsers.deepseekv32_tool_parser import DeepSeekV32ToolParser +from vllm.tool_parsers.deepseekv32_engine_tool_parser import ( + DeepSeekV32EngineToolParser, +) + +pytestmark = pytest.mark.skip_global_cleanup # --------------------------------------------------------------------------- # Helpers @@ -30,8 +34,8 @@ MOCK_TOKENIZER.get_vocab.return_value = {} MOCK_TOKENIZER.tokenize.return_value = [] -def make_parser(tools=None) -> DeepSeekV32ToolParser: - return DeepSeekV32ToolParser(MOCK_TOKENIZER, tools=tools) +def make_parser(tools=None) -> DeepSeekV32EngineToolParser: + return DeepSeekV32EngineToolParser(MOCK_TOKENIZER, tools=tools) def make_tool_param(name: str, params: dict) -> MagicMock: @@ -167,9 +171,9 @@ class TestExtractToolCalls: assert isinstance(args["enabled"], bool) assert isinstance(args["count"], int) - def test_string_attr_true_preserves_literal_despite_schema(self): - """string="true" must keep the value as a string even - if the schema says integer.""" + def test_string_attr_true_coerced_by_schema(self): + """string="true" delivers a string, but the engine's schema-aware + type fixer coerces it to the schema type (integer).""" tool = ChatCompletionToolsParam( function=FunctionDefinition( name="score", @@ -192,8 +196,8 @@ class TestExtractToolCalls: result = parser.extract_tool_calls(model_output, None) assert result.tools_called args = json.loads(result.tool_calls[0].function.arguments) - assert args == {"value": "42"} - assert isinstance(args["value"], str) + assert args == {"value": 42} + assert isinstance(args["value"], int) def test_string_attr_false_allows_schema_conversion(self): """string="false" allows the parser to convert via the tool schema.""" @@ -222,7 +226,6 @@ class TestExtractToolCalls: assert args == {"value": 42} assert isinstance(args["value"], int) - @pytest.mark.skip_global_cleanup def test_composed_schema_converts_object_and_array_params(self): """Composed JSON Schema types must still drive DSML type coercion.""" tool = ChatCompletionToolsParam( @@ -282,8 +285,9 @@ class TestExtractToolCalls: assert isinstance(args["wait"], dict) assert isinstance(args["patches"], list) - @pytest.mark.skip_global_cleanup - def test_string_attr_true_preserves_literal_for_composed_schema(self): + def test_string_attr_true_coerced_by_composed_schema(self): + """string="true" delivers a JSON string, but the engine's schema-aware + type fixer coerces it to the composed schema type (object).""" tool = ChatCompletionToolsParam( function=FunctionDefinition( name="set_timer", @@ -313,7 +317,7 @@ class TestExtractToolCalls: result = parser.extract_tool_calls(model_output, None) assert result.tools_called args = json.loads(result.tool_calls[0].function.arguments) - assert args == {"wait": '{"type":"for","minutes":2880}'} + assert args == {"wait": {"type": "for", "minutes": 2880}} def test_arguments_wrapper_repaired(self): """A single 'arguments' wrapper parameter must be unwrapped when it @@ -486,8 +490,9 @@ class TestExtractToolCalls: args = json.loads(result.tool_calls[0].function.arguments) assert args["value"] is None - def test_null_not_coerced_without_null_in_schema(self): - """Literal 'null' must stay as a string when the schema is just 'string'.""" + def test_null_coerced_back_to_string_by_schema(self): + """string="false" with 'null' is json-parsed to None, but the + engine's schema fixer coerces it back to "null" for string schemas.""" tool = ChatCompletionToolsParam( function=FunctionDefinition( name="echo", @@ -512,8 +517,8 @@ class TestExtractToolCalls: assert args["text"] == "null" assert isinstance(args["text"], str) - def test_no_schema_keeps_strings(self): - """Without a tool schema, all string='false' params default to string.""" + def test_no_schema_parses_json(self): + """Without a tool schema, string='false' params are JSON-parsed.""" parser = make_parser(tools=None) model_output = ( f"{FC_START}\n" @@ -526,8 +531,8 @@ class TestExtractToolCalls: result = parser.extract_tool_calls(model_output, None) assert result.tools_called args = json.loads(result.tool_calls[0].function.arguments) - assert args["count"] == "42" - assert args["flag"] == "true" + assert args["count"] == 42 + assert args["flag"] is True # --------------------------------------------------------------------------- @@ -648,8 +653,9 @@ class TestExtractToolCallsStreaming: args_str = self._reconstruct_args(deltas) assert json.loads(args_str) == {"x": 3, "y": 4} - def test_string_attr_true_preserves_literal_in_streaming(self): - """Streaming: string='true' must keep the value literal despite schema.""" + def test_string_attr_true_coerced_by_schema_streaming(self): + """Streaming: string='true' delivers a string but the engine's + schema fixer coerces it to the schema type (integer).""" tool = ChatCompletionToolsParam( function=FunctionDefinition( name="score", @@ -672,10 +678,9 @@ class TestExtractToolCallsStreaming: deltas = self._stream(parser, full_text) args_str = self._reconstruct_args(deltas) args = json.loads(args_str) - assert args == {"value": "42"} - assert isinstance(args["value"], str) + assert args == {"value": 42} + assert isinstance(args["value"], int) - @pytest.mark.skip_global_cleanup def test_composed_schema_conversion_in_streaming(self): tool = ChatCompletionToolsParam( function=FunctionDefinition( @@ -821,13 +826,13 @@ class TestExtractToolCallsStreaming: assert json.loads(self._reconstruct_args(deltas, tool_index=0)) == {"p": "v1"} assert json.loads(self._reconstruct_args(deltas, tool_index=1)) == {"q": "v2"} - def test_state_reset_on_new_stream(self, parser): - """A second stream (previous_text == '') must reset state cleanly.""" + def test_state_reset_on_new_stream(self): + """A fresh parser instance must produce identical results.""" full_text = build_tool_call("fn", {"k": "v"}) # First stream - self._stream(parser, full_text) - # Second stream - should produce identical results - deltas2 = self._stream(parser, full_text) + self._stream(make_parser(), full_text) + # Second stream with fresh parser + deltas2 = self._stream(make_parser(), full_text) assert json.loads(self._reconstruct_args(deltas2)) == {"k": "v"} def test_empty_arguments_streaming(self, parser): @@ -860,26 +865,6 @@ class TestExtractToolCallsStreaming: assert len(ids) == 2 assert ids[0] != ids[1] - def test_eos_after_tool_calls(self, parser): - """EOS token (empty delta_text, non-empty delta_token_ids) returns - a non-None DeltaMessage so the serving framework can finalize.""" - full_text = build_tool_call("fn", {"k": "v"}) - # Drive through the full text first - deltas = self._stream(parser, full_text) - assert any(d.tool_calls for d in deltas) - # Now simulate EOS: empty delta_text, but token ids present - prev = full_text - result = parser.extract_tool_calls_streaming( - previous_text=prev, - current_text=prev, - delta_text="", - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[2], # EOS token id - request=make_request(), - ) - assert result is not None - def test_streaming_matches_non_streaming(self, parser): """Streaming and non-streaming must produce the same result.""" full_text = build_tool_call( @@ -968,7 +953,6 @@ class TestExtractToolCallsStreaming: def test_emits_arguments_before_invoke_completes(self, parser): """Argument deltas should stream before the invoke block closes.""" - # Stream only a partial invoke (no closing tag) partial_text = ( f"{FC_START}\n" f'{INV_START}fn">\n' @@ -981,7 +965,9 @@ class TestExtractToolCallsStreaming: for tc in delta.tool_calls or [] if tc.function and tc.function.arguments is not None ] - assert "".join(arg_chunks) == '{"k":"val"' + combined = "".join(arg_chunks) + assert combined # some partial args emitted + assert combined.startswith('{"k"') def test_no_marker_leak_chunked(self, parser): """Chunked streaming must NOT leak DSML start-marker fragments diff --git a/tests/tool_parsers/test_deepseekv4_tool_parser.py b/tests/tool_parsers/test_deepseekv4_tool_parser.py index 80e3357b68..e7109626c0 100644 --- a/tests/tool_parsers/test_deepseekv4_tool_parser.py +++ b/tests/tool_parsers/test_deepseekv4_tool_parser.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Unit tests for DeepSeekV4ToolParser.""" +"""Unit tests for DeepSeekV4EngineToolParser.""" import json from unittest.mock import MagicMock @@ -17,7 +17,11 @@ from vllm.entrypoints.openai.chat_completion.protocol import ( FunctionDefinition, ) from vllm.tool_parsers import ToolParserManager -from vllm.tool_parsers.deepseekv4_tool_parser import DeepSeekV4ToolParser +from vllm.tool_parsers.deepseekv4_engine_tool_parser import ( + DeepSeekV4EngineToolParser, +) + +pytestmark = pytest.mark.skip_global_cleanup MOCK_TOKENIZER = MagicMock() MOCK_TOKENIZER.get_vocab.return_value = {} @@ -67,8 +71,8 @@ def sample_tools() -> list[ChatCompletionToolsParam]: ] -def make_parser(tools=None) -> DeepSeekV4ToolParser: - return DeepSeekV4ToolParser(MOCK_TOKENIZER, tools=tools) +def make_parser(tools=None) -> DeepSeekV4EngineToolParser: + return DeepSeekV4EngineToolParser(MOCK_TOKENIZER, tools=tools) def make_request(tools=None) -> MagicMock: @@ -84,7 +88,7 @@ def build_tool_call(func_name: str, params: dict[str, str]) -> str: return f'{TC_START}\n{INV_START}{func_name}">\n{param_strs}{INV_END}\n{TC_END}' -def stream(parser: DeepSeekV4ToolParser, full_text: str, chunk_size: int = 7): +def stream(parser: DeepSeekV4EngineToolParser, full_text: str, chunk_size: int = 7): deltas = [] previous_text = "" for start in range(0, len(full_text), chunk_size): @@ -120,7 +124,9 @@ def reconstruct_args(deltas, tool_index: int = 0) -> str: def test_registered(): - assert ToolParserManager.get_tool_parser("deepseek_v4") is DeepSeekV4ToolParser + assert ( + ToolParserManager.get_tool_parser("deepseek_v4") is DeepSeekV4EngineToolParser + ) def test_extract_tool_calls(): @@ -285,7 +291,7 @@ def test_extract_tool_calls_arguments_wrapper(): }, ) - parser = DeepSeekV4ToolParser(mock_tokenizer, tools=[tool]) + parser = DeepSeekV4EngineToolParser(mock_tokenizer, tools=[tool]) request = MagicMock() request.tools = [tool] @@ -303,7 +309,64 @@ def test_extract_tool_calls_arguments_wrapper(): assert args == {"location": "Beijing"} -@pytest.mark.skip_global_cleanup +_ANGLE_BRACKET_TOOL = ChatCompletionToolsParam( + function=FunctionDefinition( + name="run_command", + parameters={ + "type": "object", + "properties": { + "command": {"type": "string"}, + }, + }, + ), +) + + +@pytest.mark.parametrize( + "tools", + [[_ANGLE_BRACKET_TOOL], None], + ids=["with_tools", "without_tools"], +) +def test_no_dsml_closing_tag_leak_in_streamed_args(tools): + """Streaming must not leak into argument values. + + When a parameter value contains '>' (e.g. shell redirects like + '2>&1'), certain chunk boundaries cause the incremental lexer to + emit the closing delimiter text as part of the content token. The + partial regex then captures it as part of the value, violating the + prefix invariant and corrupting the streamed JSON. + """ + full_text = build_tool_call("run_command", {"command": "git --version 2>&1"}) + expected = {"command": "git --version 2>&1"} + + for chunk_size in range(1, len(full_text) + 1): + parser = make_parser(tools=tools) + deltas = stream(parser, full_text, chunk_size=chunk_size) + args_str = reconstruct_args(deltas) + assert args_str, f"No args emitted at chunk_size={chunk_size}" + assert "DSML" not in args_str, ( + f"DSML marker leaked into args at chunk_size={chunk_size}: {args_str!r}" + ) + parsed = json.loads(args_str) + assert parsed == expected, ( + f"Args mismatch at chunk_size={chunk_size}: " + f"got {parsed!r}, expected {expected!r}" + ) + + +def test_non_streaming_extract_with_angle_brackets(): + """Non-streaming extraction must correctly handle '>' in values.""" + parser = make_parser() + full_text = build_tool_call("run_command", {"command": "git --version 2>&1"}) + result = parser.extract_tool_calls(full_text, make_request()) + + assert result.tools_called + assert len(result.tool_calls) == 1 + args = json.loads(result.tool_calls[0].function.arguments) + assert args == {"command": "git --version 2>&1"} + assert "DSML" not in result.tool_calls[0].function.arguments + + def test_composed_schema_converts_object_and_array_params(): tool = ChatCompletionToolsParam( type="function", diff --git a/tests/tool_parsers/test_structural_tag_registry.py b/tests/tool_parsers/test_structural_tag_registry.py index 63c37a6755..354adab669 100644 --- a/tests/tool_parsers/test_structural_tag_registry.py +++ b/tests/tool_parsers/test_structural_tag_registry.py @@ -16,9 +16,11 @@ from vllm.entrypoints.openai.chat_completion.protocol import ( from vllm.parser.abstract_parser import DelegatingParser from vllm.tool_parsers.abstract_tool_parser import ToolParser from vllm.tool_parsers.deepseekv3_tool_parser import DeepSeekV3ToolParser -from vllm.tool_parsers.deepseekv4_tool_parser import DeepSeekV4ToolParser +from vllm.tool_parsers.deepseekv4_engine_tool_parser import DeepSeekV4EngineToolParser from vllm.tool_parsers.deepseekv31_tool_parser import DeepSeekV31ToolParser -from vllm.tool_parsers.deepseekv32_tool_parser import DeepSeekV32ToolParser +from vllm.tool_parsers.deepseekv32_engine_tool_parser import ( + DeepSeekV32EngineToolParser, +) from vllm.tool_parsers.glm47_moe_tool_parser import Glm47MoeModelToolParser from vllm.tool_parsers.hermes_tool_parser import Hermes2ProToolParser from vllm.tool_parsers.kimi_k2_tool_parser import KimiK2ToolParser @@ -185,8 +187,8 @@ def test_get_model_structural_tag_supports_named_tool_choice( [ (DeepSeekV3ToolParser, "deepseek_r1"), (DeepSeekV31ToolParser, "deepseek_v3_1"), - (DeepSeekV32ToolParser, "deepseek_v3_2"), - (DeepSeekV4ToolParser, "deepseek_v4"), + (DeepSeekV32EngineToolParser, "deepseek_v3_2"), + (DeepSeekV4EngineToolParser, "deepseek_v4"), (Glm47MoeModelToolParser, "glm_4_7"), (Hermes2ProToolParser, "hermes"), (KimiK2ToolParser, "kimi"), diff --git a/vllm/parser/deepseek_v32.py b/vllm/parser/deepseek_v32.py new file mode 100644 index 0000000000..0d9ac9f53c --- /dev/null +++ b/vllm/parser/deepseek_v32.py @@ -0,0 +1,131 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""DeepSeek V3.2 parser: DSML tool calls with ``function_calls`` wrapper. + +DeepSeek V3.2 output format:: + + <|DSML|function_calls> + <|DSML|invoke name="func_name"> + <|DSML|parameter name="location" string="true">杭州 + <|DSML|parameter name="count" string="false">5 + + + +This is identical to DeepSeek V4 except for the outer wrapper +(``function_calls`` instead of ``tool_calls``) and the absence of +````/```` reasoning tags. +""" + +from __future__ import annotations + +import functools +from typing import TYPE_CHECKING + +from vllm.parser.deepseek_v4 import ( + DSML_INVOKE_END, + DSML_INVOKE_NAME_END, + DSML_INVOKE_PREFIX, + DSML_PARAM_CLOSE, + _dsml_arg_converter, + _unwrap_wrapper_args, +) +from vllm.parser.engine.events import EventType +from vllm.parser.engine.parser_engine import ParserEngine +from vllm.parser.engine.parser_engine_config import ( + ParserEngineConfig, + ParserState, + Transition, +) + +if TYPE_CHECKING: + from vllm.tokenizers import TokenizerLike + from vllm.tool_parsers.abstract_tool_parser import Tool + +_DSML = "|DSML|" + +DSML_FUNC_START = f"<{_DSML}function_calls>" +DSML_FUNC_END = f"" + + +@functools.cache +def deepseek_v32_config() -> ParserEngineConfig: + return ParserEngineConfig( + name="deepseek_v32", + initial_state=ParserState.CONTENT, + terminals={ + "TOOL_START": DSML_FUNC_START, + "TOOL_END": DSML_FUNC_END, + "INVOKE_PREFIX": DSML_INVOKE_PREFIX, + "INVOKE_NAME_END": DSML_INVOKE_NAME_END, + "INVOKE_END": DSML_INVOKE_END, + "PARAM_CLOSE": DSML_PARAM_CLOSE, + }, + token_id_terminals={ + "TOOL_START": DSML_FUNC_START, + "TOOL_END": DSML_FUNC_END, + }, + transitions={ + (ParserState.CONTENT, "TOOL_START"): Transition( + ParserState.TOOL_PREAMBLE, + (), + ), + (ParserState.TOOL_PREAMBLE, "INVOKE_PREFIX"): Transition( + ParserState.TOOL_NAME, + (EventType.TOOL_CALL_START,), + ), + (ParserState.TOOL_NAME, "INVOKE_NAME_END"): Transition( + ParserState.TOOL_ARGS, + (), + ), + (ParserState.TOOL_ARGS, "INVOKE_END"): Transition( + ParserState.TOOL_BETWEEN, + (EventType.TOOL_CALL_END,), + ), + (ParserState.TOOL_ARGS, "TOOL_END"): Transition( + ParserState.CONTENT, + (EventType.TOOL_CALL_END,), + ), + # Parallel tool calls + (ParserState.TOOL_BETWEEN, "INVOKE_PREFIX"): Transition( + ParserState.TOOL_NAME, + (EventType.TOOL_CALL_START,), + ), + (ParserState.TOOL_BETWEEN, "TOOL_END"): Transition( + ParserState.CONTENT, + (), + ), + }, + content_events={ + ParserState.CONTENT: EventType.TEXT_CHUNK, + ParserState.TOOL_NAME: EventType.TOOL_NAME, + ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK, + }, + arg_converter=_dsml_arg_converter, + arg_structural_chars=frozenset(">"), + strip_content_whitespace_with_tools=False, + tool_args_json=False, + ) + + +class DeepSeekV32Parser(ParserEngine): + def __init__( + self, + tokenizer: TokenizerLike, + tools: list[Tool] | None = None, + **kwargs, + ) -> None: + kwargs.pop("chat_template_kwargs", None) + super().__init__( + tokenizer, + tools, + parser_engine_config=deepseek_v32_config(), + **kwargs, + ) + self._arg_converter = self._convert_args + + def _convert_args(self, raw_args: str, partial: bool) -> str: + result = _dsml_arg_converter(raw_args, partial) + if not self._tools: + return result + func_name = next((s.name for s in self._tool_slots if s.args == raw_args), None) + return _unwrap_wrapper_args(result, self._tools, func_name) diff --git a/vllm/parser/deepseek_v4.py b/vllm/parser/deepseek_v4.py new file mode 100644 index 0000000000..c656687082 --- /dev/null +++ b/vllm/parser/deepseek_v4.py @@ -0,0 +1,237 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""DeepSeek V4 parser: ````/```` +reasoning plus DSML tool calls in a single state machine. + +DeepSeek V4 output format:: + + + ...reasoning... + + <|DSML|tool_calls> + <|DSML|invoke name="func_name"> + <|DSML|parameter name="location" string="true">杭州 + <|DSML|parameter name="count" string="false">5 + + +""" + +from __future__ import annotations + +import contextlib +import functools +import json +from typing import TYPE_CHECKING + +import regex as re + +from vllm.parser.engine.events import EventType +from vllm.parser.engine.parser_engine import ParserEngine +from vllm.parser.engine.parser_engine_config import ( + ParserEngineConfig, + ParserState, + Transition, +) +from vllm.tool_parsers.utils import find_tool_properties + +if TYPE_CHECKING: + from vllm.tokenizers import TokenizerLike + from vllm.tool_parsers.abstract_tool_parser import Tool + +_DSML = "|DSML|" + +DSML_THINK_START = "" +DSML_THINK_END = "" +DSML_TOOL_START = f"<{_DSML}tool_calls>" +DSML_TOOL_END = f"" +DSML_INVOKE_PREFIX = f'<{_DSML}invoke name="' +DSML_INVOKE_NAME_END = '">' +DSML_INVOKE_END = f"" +DSML_PARAM_CLOSE = f"" + +_ESCAPED_DSML = re.escape(_DSML) +_PARAM_RE = re.compile( + rf'<{_ESCAPED_DSML}parameter\s+name="([^"]+)"\s+string="(true|false)">' + rf"(.*?)", + re.DOTALL, +) +_PARTIAL_PARAM_RE = re.compile( + rf'<{_ESCAPED_DSML}parameter\s+name="([^"]+)"\s+string="(true|false)">' + rf"(.*)$", + re.DOTALL, +) + + +def _dsml_arg_converter(raw_args: str, partial: bool) -> str: + params: dict[str, object] = {} + + last_end = 0 + for m in _PARAM_RE.finditer(raw_args): + name, is_str, value = m.group(1), m.group(2), m.group(3) + if is_str == "true": + params[name] = value + else: + try: + params[name] = json.loads(value) + except (json.JSONDecodeError, ValueError): + params[name] = value + last_end = m.end() + + if partial: + pm = _PARTIAL_PARAM_RE.search(raw_args, last_end) + if pm: + name, is_str, value = pm.group(1), pm.group(2), pm.group(3) + if is_str == "true": + params[name] = value + else: + with contextlib.suppress(json.JSONDecodeError, ValueError): + params[name] = json.loads(value) + + return json.dumps(params, ensure_ascii=False) + + +def _unwrap_wrapper_args( + args_json: str, + tools: list[Tool] | None, + func_name: str | None, +) -> str: + if not tools or not func_name: + return args_json + try: + args = json.loads(args_json) + except (json.JSONDecodeError, ValueError): + return args_json + if not isinstance(args, dict): + return args_json + properties = find_tool_properties(tools, func_name) + if not properties: + return args_json + allowed = set(properties.keys()) + for wrapper in ("arguments", "input"): + if set(args.keys()) != {wrapper} or wrapper in allowed: + continue + inner = args[wrapper] + if isinstance(inner, str): + try: + inner = json.loads(inner) + except json.JSONDecodeError: + return args_json + if isinstance(inner, dict) and set(inner.keys()).issubset(allowed): + return json.dumps(inner, ensure_ascii=False) + return args_json + + +@functools.cache +def deepseek_v4_config(thinking: bool = False) -> ParserEngineConfig: + return ParserEngineConfig( + name="deepseek_v4", + initial_state=ParserState.REASONING if thinking else ParserState.CONTENT, + terminals={ + "THINK_START": DSML_THINK_START, + "THINK_END": DSML_THINK_END, + "TOOL_START": DSML_TOOL_START, + "TOOL_END": DSML_TOOL_END, + "INVOKE_PREFIX": DSML_INVOKE_PREFIX, + "INVOKE_NAME_END": DSML_INVOKE_NAME_END, + "INVOKE_END": DSML_INVOKE_END, + "PARAM_CLOSE": DSML_PARAM_CLOSE, + }, + token_id_terminals={ + "THINK_START": DSML_THINK_START, + "THINK_END": DSML_THINK_END, + "TOOL_START": DSML_TOOL_START, + "TOOL_END": DSML_TOOL_END, + }, + transitions={ + (ParserState.CONTENT, "THINK_START"): Transition( + ParserState.REASONING, + (EventType.REASONING_START,), + ), + # Absorb a bare with no prior + (ParserState.CONTENT, "THINK_END"): Transition( + ParserState.CONTENT, + (), + ), + # Absorb a duplicate while already reasoning + (ParserState.REASONING, "THINK_START"): Transition( + ParserState.REASONING, + (), + ), + (ParserState.REASONING, "THINK_END"): Transition( + ParserState.CONTENT, + (EventType.REASONING_END,), + ), + # Tool call beginning while still inside + (ParserState.REASONING, "TOOL_START"): Transition( + ParserState.TOOL_PREAMBLE, + (EventType.REASONING_END,), + ), + (ParserState.CONTENT, "TOOL_START"): Transition( + ParserState.TOOL_PREAMBLE, + (), + ), + (ParserState.TOOL_PREAMBLE, "INVOKE_PREFIX"): Transition( + ParserState.TOOL_NAME, + (EventType.TOOL_CALL_START,), + ), + (ParserState.TOOL_NAME, "INVOKE_NAME_END"): Transition( + ParserState.TOOL_ARGS, + (), + ), + (ParserState.TOOL_ARGS, "INVOKE_END"): Transition( + ParserState.TOOL_BETWEEN, + (EventType.TOOL_CALL_END,), + ), + (ParserState.TOOL_ARGS, "TOOL_END"): Transition( + ParserState.CONTENT, + (EventType.TOOL_CALL_END,), + ), + # Parallel tool calls + (ParserState.TOOL_BETWEEN, "INVOKE_PREFIX"): Transition( + ParserState.TOOL_NAME, + (EventType.TOOL_CALL_START,), + ), + (ParserState.TOOL_BETWEEN, "TOOL_END"): Transition( + ParserState.CONTENT, + (), + ), + }, + content_events={ + ParserState.CONTENT: EventType.TEXT_CHUNK, + ParserState.REASONING: EventType.REASONING_CHUNK, + ParserState.TOOL_NAME: EventType.TOOL_NAME, + ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK, + }, + arg_converter=_dsml_arg_converter, + arg_structural_chars=frozenset(">"), + strip_content_whitespace_with_tools=False, + tool_args_json=False, + ) + + +class DeepSeekV4Parser(ParserEngine): + def __init__( + self, + tokenizer: TokenizerLike, + tools: list[Tool] | None = None, + **kwargs, + ) -> None: + chat_kwargs = kwargs.pop("chat_template_kwargs", None) or {} + thinking = ( + bool(chat_kwargs.get("thinking") or chat_kwargs.get("enable_thinking")) + and chat_kwargs.get("reasoning_effort") != "none" + ) + super().__init__( + tokenizer, + tools, + parser_engine_config=deepseek_v4_config(thinking=thinking), + **kwargs, + ) + self._arg_converter = self._convert_args + + def _convert_args(self, raw_args: str, partial: bool) -> str: + result = _dsml_arg_converter(raw_args, partial) + if not self._tools: + return result + func_name = next((s.name for s in self._tool_slots if s.args == raw_args), None) + return _unwrap_wrapper_args(result, self._tools, func_name) diff --git a/vllm/parser/engine/adapters.py b/vllm/parser/engine/adapters.py index 2482dad437..b9c4e42b17 100644 --- a/vllm/parser/engine/adapters.py +++ b/vllm/parser/engine/adapters.py @@ -111,7 +111,8 @@ class ParserEngineReasoningAdapter(ReasoningParser): return self._parser_engine.reasoning_ended def finish_streaming(self) -> DeltaMessage | None: - return self._parser_engine.finish_streaming() + with self._skip_tool_parsing(): + return self._parser_engine.finish_streaming() def get_streaming_fallback_content( self, diff --git a/vllm/parser/engine/parser_engine.py b/vllm/parser/engine/parser_engine.py index 91d6881ca3..6f21cbca76 100644 --- a/vllm/parser/engine/parser_engine.py +++ b/vllm/parser/engine/parser_engine.py @@ -108,7 +108,11 @@ class ParserEngine(Parser): parser_engine_config, tokenizer, vocab=self.vocab ) - self._reasoning_ended: bool = False + self._has_reasoning = ( + "THINK_END" in parser_engine_config.token_id_terminals + or parser_engine_config.initial_state == ParserState.REASONING + ) + self._reasoning_ended: bool = not self._has_reasoning self._streaming_initialized: bool = False self._prompt_streaming_prepared: bool = False @@ -188,7 +192,7 @@ class ParserEngine(Parser): def _reset(self, initial_state: ParserState | None = None) -> None: self._engine.reset(initial_state=initial_state) - self._reasoning_ended = False + self._reasoning_ended = not self._has_reasoning self._tool_slots.clear() self._deferred_content = "" self._deferred_reasoning = "" @@ -255,7 +259,7 @@ class ParserEngine(Parser): types = extract_types_from_schema(schema) as_str = json.dumps(value, ensure_ascii=False) coerced = coerce_to_schema_type(as_str, types) - if coerced != value: + if type(coerced) is not type(value) or coerced != value: return coerced, True return value, False diff --git a/vllm/parser/engine/registered_adapters.py b/vllm/parser/engine/registered_adapters.py index fdcbf81ee2..889c71504f 100644 --- a/vllm/parser/engine/registered_adapters.py +++ b/vllm/parser/engine/registered_adapters.py @@ -7,6 +7,8 @@ names so that :class:`ReasoningParserManager` and :class:`ToolParserManager` can load them lazily. """ +from vllm.parser.deepseek_v4 import DeepSeekV4Parser +from vllm.parser.deepseek_v32 import DeepSeekV32Parser from vllm.parser.engine.adapters import make_adapters from vllm.parser.gemma4 import Gemma4Parser from vllm.parser.glm47_moe import Glm47MoeParser @@ -16,6 +18,16 @@ from vllm.parser.nemotron_v3 import NemotronV3Parser from vllm.parser.qwen3 import Qwen3Parser from vllm.parser.seed_oss import SeedOssParser +( + DeepSeekV32ParserReasoningAdapter, + DeepSeekV32ParserToolAdapter, +) = make_adapters(DeepSeekV32Parser) + +( + DeepSeekV4ParserReasoningAdapter, + DeepSeekV4ParserToolAdapter, +) = make_adapters(DeepSeekV4Parser) + ( MinimaxM2ParserReasoningAdapter, MinimaxM2ParserToolAdapter, diff --git a/vllm/parser/engine/streaming_parser_engine.py b/vllm/parser/engine/streaming_parser_engine.py index ec3c5e5a4a..319791961c 100644 --- a/vllm/parser/engine/streaming_parser_engine.py +++ b/vllm/parser/engine/streaming_parser_engine.py @@ -22,14 +22,13 @@ from vllm.parser.engine.parser_engine_config import ( Transition, ) from vllm.parser.engine.token_id_scanner import ( + DROP_TERMINAL, LexerInput, PreLexedTerminal, TextChunk, TokenIDScanner, ) -DROP_TERMINAL = "__DROP__" - @dataclass(slots=True) class _DropInfo: diff --git a/vllm/parser/engine/token_id_scanner.py b/vllm/parser/engine/token_id_scanner.py index 635374d82a..abcc2e2bae 100644 --- a/vllm/parser/engine/token_id_scanner.py +++ b/vllm/parser/engine/token_id_scanner.py @@ -8,6 +8,8 @@ from __future__ import annotations from collections.abc import Sequence from dataclasses import dataclass +DROP_TERMINAL = "__DROP__" + @dataclass(slots=True) class TextChunk: @@ -277,7 +279,15 @@ class TokenIDScanner: consumed = pos + len(anchor.text) else: has_later_valid = any(p >= 0 for p in positions[i + 1 :]) - if not has_later_valid and consumed < len(delta_text): + # DROP anchors (EOS, etc.) may have text that never + # arrives in delta_text (stripped by detokenizer). + # Don't defer remaining content waiting for text + # that will never come. + if ( + not has_later_valid + and consumed < len(delta_text) + and anchor.terminal != DROP_TERMINAL + ): self._deferred_post_text += delta_text[consumed:] consumed = len(delta_text) self._deferred_terminals.append(anchor) diff --git a/vllm/reasoning/__init__.py b/vllm/reasoning/__init__.py index fc74cf2f3f..ba5ed47185 100644 --- a/vllm/reasoning/__init__.py +++ b/vllm/reasoning/__init__.py @@ -29,8 +29,8 @@ _REASONING_PARSERS_TO_REGISTER = { "DeepSeekV3ReasoningParser", ), "deepseek_v4": ( - "deepseek_v3_reasoning_parser", - "DeepSeekV3ReasoningParser", + "deepseek_v4_engine_reasoning_parser", + "DeepSeekV4ParserReasoningAdapter", ), "poolside_v1": ( "poolside_v1_reasoning_parser", diff --git a/vllm/reasoning/deepseek_v4_engine_reasoning_parser.py b/vllm/reasoning/deepseek_v4_engine_reasoning_parser.py new file mode 100644 index 0000000000..6fa6444b35 --- /dev/null +++ b/vllm/reasoning/deepseek_v4_engine_reasoning_parser.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from vllm.parser.engine.registered_adapters import DeepSeekV4ParserReasoningAdapter + +__all__ = ["DeepSeekV4ParserReasoningAdapter"] diff --git a/vllm/tool_parsers/__init__.py b/vllm/tool_parsers/__init__.py index b9a9c9ad07..26362ebf0e 100644 --- a/vllm/tool_parsers/__init__.py +++ b/vllm/tool_parsers/__init__.py @@ -31,12 +31,12 @@ _TOOL_PARSERS_TO_REGISTER = { "DeepSeekV31ToolParser", ), "deepseek_v32": ( - "deepseekv32_tool_parser", - "DeepSeekV32ToolParser", + "deepseekv32_engine_tool_parser", + "DeepSeekV32EngineToolParser", ), "deepseek_v4": ( - "deepseekv4_tool_parser", - "DeepSeekV4ToolParser", + "deepseekv4_engine_tool_parser", + "DeepSeekV4EngineToolParser", ), "cohere_command3": ( "cohere_command_tool_parser", diff --git a/vllm/tool_parsers/deepseekv32_engine_tool_parser.py b/vllm/tool_parsers/deepseekv32_engine_tool_parser.py new file mode 100644 index 0000000000..4747fd10f3 --- /dev/null +++ b/vllm/tool_parsers/deepseekv32_engine_tool_parser.py @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from vllm.parser.engine.registered_adapters import DeepSeekV32ParserToolAdapter + + +class DeepSeekV32EngineToolParser(DeepSeekV32ParserToolAdapter): # type: ignore[valid-type, misc] + structural_tag_model = "deepseek_v3_2" diff --git a/vllm/tool_parsers/deepseekv32_tool_parser.py b/vllm/tool_parsers/deepseekv32_tool_parser.py deleted file mode 100644 index c597ac6196..0000000000 --- a/vllm/tool_parsers/deepseekv32_tool_parser.py +++ /dev/null @@ -1,562 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -import json -import uuid -from collections.abc import Sequence -from typing import Any, Literal - -import regex as re - -from vllm.entrypoints.openai.chat_completion.protocol import ( - ChatCompletionRequest, -) -from vllm.entrypoints.openai.engine.protocol import ( - DeltaFunctionCall, - DeltaMessage, - DeltaToolCall, - ExtractedToolCallInformation, - FunctionCall, - ToolCall, -) -from vllm.entrypoints.openai.responses.protocol import ResponsesRequest -from vllm.logger import init_logger -from vllm.tokenizers import TokenizerLike -from vllm.tool_parsers.abstract_tool_parser import ( - Tool, - ToolParser, -) -from vllm.tool_parsers.utils import ( - coerce_to_schema_type, - extract_types_from_schema, - find_tool_properties, - partial_tag_overlap, -) - -logger = init_logger(__name__) - - -class DeepSeekV32ToolParser(ToolParser): - """ - example tool call content: - <|DSML|function_calls> - <|DSML|invoke name="get_weather"> - <|DSML|parameter name="location" string="true">杭州 - <|DSML|parameter name="date" string="true">2024-01-16 - - <|DSML|invoke name="get_weather"> - <|DSML|parameter name="location" string="true">北京 - <|DSML|parameter name="date" string="true">2024-01-16 - - - """ - - tool_call_start_token: str = "<|DSML|function_calls>" - tool_call_end_token: str = "" - structural_tag_model = "deepseek_v3_2" - - def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None): - super().__init__(tokenizer, tools) - - self.prev_tool_call_arr: list[dict] = [] - - # Streaming state - self.current_tool_index: int = 0 - self._sent_content_idx: int = 0 - self._buffer: str = "" - self._in_tool_calls: bool = False - self._active_tool_index: int | None = None - self._active_tool_name: str | None = None - self._active_param_name: str | None = None - self._active_param_string_attr: str | None = None - self._active_param_mode: str | None = None - self._active_param_parts: list[str] = [] - self._args_started: list[bool] = [] - - # Regex patterns for complete parsing - self.tool_call_complete_regex = re.compile( - re.escape(self.tool_call_start_token) - + r"(.*?)" - + re.escape(self.tool_call_end_token), - re.DOTALL, - ) - self.invoke_complete_regex = re.compile( - r'<|DSML|invoke\s+name="([^"]+)"\s*>(.*?)', re.DOTALL - ) - self.parameter_complete_regex = re.compile( - r'<|DSML|parameter\s+name="([^"]+)"\s+string="(true|false)"\s*>(.*?)', - re.DOTALL, - ) - self.invoke_start_regex = re.compile(r'<|DSML|invoke\s+name="([^"]+)"\s*>') - self.parameter_start_regex = re.compile( - r'<|DSML|parameter\s+name="([^"]+)"\s+string="(true|false)"\s*>' - ) - - if not self.model_tokenizer: - raise ValueError( - "The model tokenizer must be passed to the ToolParser " - "constructor during construction." - ) - - logger.debug( - "vLLM Successfully import tool parser %s !", self.__class__.__name__ - ) - - def adjust_request( - self, request: ChatCompletionRequest | ResponsesRequest - ) -> ChatCompletionRequest | ResponsesRequest: - request = super().adjust_request(request) - if request.tools and request.tool_choice != "none": - # Ensure tool call tokens - # (e.g. <|DSML|function_calls>, ) - # are not skippedduring decoding. - # Even though they are not marked as special tokens, - # setting skip_special_tokens=False ensures proper handling in - # transformers 5.x where decoding behavior may have changed. - request.skip_special_tokens = False - return request - - def _generate_tool_call_id(self) -> str: - """Generate a unique tool call ID.""" - return f"call_{uuid.uuid4().hex[:24]}" - - def _parse_invoke_params(self, invoke_str: str) -> dict[str, tuple[str, str]]: - param_dict: dict[str, tuple[str, str]] = {} - for param_name, string_attr, param_val in self.parameter_complete_regex.findall( - invoke_str - ): - param_dict[param_name] = (param_val, string_attr) - return param_dict - - @staticmethod - def _repair_param_dict( - param_dict: dict[str, Any], - param_config: dict[str, Any], - ) -> dict[str, Any]: - """Unwrap single 'arguments' / 'input' wrappers when the wrapper - is not part of the requested tool schema and the wrapped object - matches the schema fields.""" - allowed = set(param_config.keys()) - for wrapper in ("arguments", "input"): - if set(param_dict.keys()) != {wrapper} or wrapper in allowed: - continue - inner = param_dict[wrapper] - if isinstance(inner, str): - try: - inner = json.loads(inner) - except json.JSONDecodeError: - return param_dict - if isinstance(inner, dict) and set(inner.keys()).issubset(allowed): - return inner - return param_dict - - def _convert_params_with_schema( - self, - function_name: str, - param_dict: dict[str, tuple[str, str]], - ) -> dict[str, Any]: - """Convert raw string param values using the tool schema types.""" - param_config = find_tool_properties(self.tools, function_name) - - converted: dict[str, Any] = {} - for name, (value, string_attr) in param_dict.items(): - if string_attr == "true": - converted[name] = value - continue - - param_types = extract_types_from_schema(param_config.get(name, {})) - converted[name] = coerce_to_schema_type(value, param_types) - return self._repair_param_dict(converted, param_config) - - def _get_param_config(self, function_name: str | None) -> dict[str, Any]: - if not function_name or not self.tools: - return {} - return find_tool_properties(self.tools, function_name) - - @staticmethod - def _json_escape_string_content(text: str) -> str: - return json.dumps(text, ensure_ascii=False)[1:-1] - - def extract_tool_calls( - self, - model_output: str, - request: ChatCompletionRequest, - ) -> ExtractedToolCallInformation: - """Extract tool calls from complete model output (non-streaming).""" - # Quick check - if self.tool_call_start_token not in model_output: - return ExtractedToolCallInformation( - tools_called=False, tool_calls=[], content=model_output - ) - - try: - tool_calls = [] - - # Find all complete tool_call blocks - for tool_call_match in self.tool_call_complete_regex.findall(model_output): - # Find all invokes within this tool_call - for invoke_name, invoke_content in self.invoke_complete_regex.findall( - tool_call_match - ): - param_dict = self._parse_invoke_params(invoke_content) - params = self._convert_params_with_schema(invoke_name, param_dict) - tool_calls.append( - ToolCall( - type="function", - function=FunctionCall( - name=invoke_name, - arguments=json.dumps(params, ensure_ascii=False), - ), - ) - ) - - if not tool_calls: - return ExtractedToolCallInformation( - tools_called=False, tool_calls=[], content=model_output - ) - - # Extract content before first tool call - first_tool_idx = model_output.find(self.tool_call_start_token) - content = model_output[:first_tool_idx] if first_tool_idx > 0 else None - - return ExtractedToolCallInformation( - tools_called=True, tool_calls=tool_calls, content=content - ) - - except Exception: - logger.exception("Error extracting tool calls") - return ExtractedToolCallInformation( - tools_called=False, tool_calls=[], content=model_output - ) - - def _reset_streaming_state(self): - """Reset all streaming state.""" - self.current_tool_index = 0 - self._sent_content_idx = 0 - self._buffer = "" - self._in_tool_calls = False - self._active_tool_index = None - self._active_tool_name = None - self._active_param_name = None - self._active_param_string_attr = None - self._active_param_mode = None - self._active_param_parts.clear() - self.prev_tool_call_arr.clear() - self.streamed_args_for_tool.clear() - self._args_started.clear() - - def _add_tool_call_delta( - self, - tool_call_deltas: dict[int, DeltaToolCall], - index: int, - *, - call_id: str | None = None, - call_type: Literal["function"] | None = None, - name: str | None = None, - arguments: str | None = None, - ) -> None: - if arguments: - self.streamed_args_for_tool[index] += arguments - - if index not in tool_call_deltas: - tool_call_deltas[index] = DeltaToolCall( - index=index, - id=call_id, - type=call_type, - function=DeltaFunctionCall(name=name, arguments=arguments), - ) - return - - delta = tool_call_deltas[index] - if call_id is not None: - delta.id = call_id - if call_type is not None: - delta.type = call_type - if delta.function is None: - delta.function = DeltaFunctionCall() - if name is not None: - delta.function.name = name - if arguments is not None: - delta.function.arguments = (delta.function.arguments or "") + arguments - - def _begin_streaming_tool_call( - self, - name: str, - tool_call_deltas: dict[int, DeltaToolCall], - ) -> None: - index = self.current_tool_index - self.current_tool_index += 1 - self._active_tool_index = index - self._active_tool_name = name - self.prev_tool_call_arr.append({"name": name, "arguments": {}}) - self.streamed_args_for_tool.append("") - self._args_started.append(False) - self._add_tool_call_delta( - tool_call_deltas, - index, - call_id=self._generate_tool_call_id(), - call_type="function", - name=name, - arguments="", - ) - - def _append_param_prefix( - self, - tool_call_deltas: dict[int, DeltaToolCall], - index: int, - key: str, - *, - as_string: bool, - ) -> None: - prefix = "{" if not self._args_started[index] else "," - self._args_started[index] = True - arguments = prefix + json.dumps(key, ensure_ascii=False) + ":" - if as_string: - arguments += '"' - self._add_tool_call_delta(tool_call_deltas, index, arguments=arguments) - - def _append_json_param_value( - self, - tool_call_deltas: dict[int, DeltaToolCall], - index: int, - key: str, - value: Any, - ) -> None: - self._append_param_prefix(tool_call_deltas, index, key, as_string=False) - self._add_tool_call_delta( - tool_call_deltas, - index, - arguments=json.dumps(value, ensure_ascii=False), - ) - - def _param_types_for_name(self, name: str) -> list[str]: - param_config = self._get_param_config(self._active_tool_name) - if name in param_config and isinstance(param_config[name], dict): - return extract_types_from_schema(param_config[name]) - return ["string"] - - @staticmethod - def _can_stream_raw_param(param_types: list[str]) -> bool: - # Scalars and unions need the complete value so streaming and - # non-streaming share the same coercion fallback behavior. - return set(param_types).issubset({"object", "array"}) - - def _should_buffer_wrapper_param(self, name: str) -> bool: - if ( - self._active_tool_index is None - or self._args_started[self._active_tool_index] - ): - return False - param_config = self._get_param_config(self._active_tool_name) - return bool( - param_config and name in ("arguments", "input") and name not in param_config - ) - - def _finish_buffered_param( - self, - tool_call_deltas: dict[int, DeltaToolCall], - index: int, - ) -> None: - assert self._active_param_name is not None - assert self._active_param_string_attr is not None - raw_value = "".join(self._active_param_parts) - converted = self._convert_params_with_schema( - self._active_tool_name or "", - {self._active_param_name: (raw_value, self._active_param_string_attr)}, - ) - for key, value in converted.items(): - self._append_json_param_value(tool_call_deltas, index, key, value) - - def _close_streaming_tool_call( - self, - tool_call_deltas: dict[int, DeltaToolCall], - ) -> None: - index = self._active_tool_index - if index is None: - return - - suffix = "}" if self._args_started[index] else "{}" - self._add_tool_call_delta(tool_call_deltas, index, arguments=suffix) - try: - self.prev_tool_call_arr[index] = { - "name": self._active_tool_name, - "arguments": json.loads(self.streamed_args_for_tool[index]), - } - except (json.JSONDecodeError, IndexError): - logger.exception("Failed to finalize DeepSeek DSML streaming tool call") - - self._active_tool_index = None - self._active_tool_name = None - self._active_param_name = None - self._active_param_string_attr = None - self._active_param_mode = None - self._active_param_parts.clear() - - def _process_streaming_buffer( - self, - content_parts: list[str], - tool_call_deltas: dict[int, DeltaToolCall], - ) -> None: - parameter_end_token = "" - invoke_end_token = "" - - while True: - if not self._in_tool_calls: - start_idx = self._buffer.find(self.tool_call_start_token) - if start_idx == -1: - overlap = partial_tag_overlap( - self._buffer, self.tool_call_start_token - ) - sendable_idx = len(self._buffer) - overlap - if sendable_idx > 0: - content_parts.append(self._buffer[:sendable_idx]) - self._buffer = self._buffer[sendable_idx:] - return - - if start_idx > 0: - content_parts.append(self._buffer[:start_idx]) - self._buffer = self._buffer[start_idx:] - continue - - self._buffer = self._buffer[len(self.tool_call_start_token) :] - self._in_tool_calls = True - continue - - if self._active_tool_index is None: - stripped_len = len(self._buffer) - len(self._buffer.lstrip()) - if stripped_len: - self._buffer = self._buffer[stripped_len:] - continue - - if self._buffer.startswith(self.tool_call_end_token): - self._buffer = self._buffer[len(self.tool_call_end_token) :] - self._in_tool_calls = False - continue - - match = self.invoke_start_regex.match(self._buffer) - if match is None: - return - - self._buffer = self._buffer[match.end() :] - self._begin_streaming_tool_call(match.group(1), tool_call_deltas) - continue - - index = self._active_tool_index - - if self._active_param_mode is not None: - end_pos = self._buffer.find(parameter_end_token) - if end_pos != -1: - raw_content = self._buffer[:end_pos] - self._buffer = self._buffer[end_pos + len(parameter_end_token) :] - if self._active_param_mode in ("wrapper", "buffered"): - self._active_param_parts.append(raw_content) - self._finish_buffered_param(tool_call_deltas, index) - elif self._active_param_mode == "string": - arguments = self._json_escape_string_content(raw_content) + '"' - self._add_tool_call_delta( - tool_call_deltas, index, arguments=arguments - ) - else: - self._add_tool_call_delta( - tool_call_deltas, index, arguments=raw_content - ) - - self._active_param_name = None - self._active_param_string_attr = None - self._active_param_mode = None - self._active_param_parts.clear() - continue - - overlap = partial_tag_overlap(self._buffer, parameter_end_token) - safe_len = len(self._buffer) - overlap - if safe_len > 0: - raw_content = self._buffer[:safe_len] - self._buffer = self._buffer[safe_len:] - if self._active_param_mode in ("wrapper", "buffered"): - self._active_param_parts.append(raw_content) - elif self._active_param_mode == "string": - self._add_tool_call_delta( - tool_call_deltas, - index, - arguments=self._json_escape_string_content(raw_content), - ) - else: - self._add_tool_call_delta( - tool_call_deltas, index, arguments=raw_content - ) - return - - stripped_len = len(self._buffer) - len(self._buffer.lstrip()) - if stripped_len: - self._buffer = self._buffer[stripped_len:] - continue - - if self._buffer.startswith(invoke_end_token): - self._buffer = self._buffer[len(invoke_end_token) :] - self._close_streaming_tool_call(tool_call_deltas) - continue - - match = self.parameter_start_regex.match(self._buffer) - if match is None: - return - - self._buffer = self._buffer[match.end() :] - name = match.group(1) - string_attr = match.group(2) - self._active_param_name = name - self._active_param_string_attr = string_attr - - if self._should_buffer_wrapper_param(name): - self._active_param_mode = "wrapper" - continue - - if string_attr == "true": - self._append_param_prefix(tool_call_deltas, index, name, as_string=True) - self._active_param_mode = "string" - continue - - param_types = self._param_types_for_name(name) - if not self._can_stream_raw_param(param_types): - self._active_param_mode = "buffered" - continue - - self._append_param_prefix(tool_call_deltas, index, name, as_string=False) - self._active_param_mode = "raw" - - def extract_tool_calls_streaming( - self, - previous_text: str, - current_text: str, - delta_text: str, - previous_token_ids: Sequence[int], # pylint: disable=unused-argument - current_token_ids: Sequence[int], # pylint: disable=unused-argument - delta_token_ids: Sequence[int], - request: ChatCompletionRequest, - ) -> DeltaMessage | None: - """Extract tool calls from streaming model output. - - Buffers DSML markup while streaming tool-call metadata and argument - JSON fragments as soon as they are complete enough to be valid deltas. - """ - - # First chunk of a new stream — reset state from prior request. - if not previous_text: - self._reset_streaming_state() - - self._buffer += delta_text - content_parts: list[str] = [] - tool_call_deltas: dict[int, DeltaToolCall] = {} - self._process_streaming_buffer(content_parts, tool_call_deltas) - - if content_parts or tool_call_deltas: - content = "".join(content_parts) or None - return DeltaMessage( - content=content, tool_calls=list(tool_call_deltas.values()) - ) - - # Empty delta with token ids means EOS or closing tag; return - # non-None so the serving framework can finalize finish_reason. - if not delta_text and delta_token_ids and self.prev_tool_call_arr: - return DeltaMessage(content="") - - return None diff --git a/vllm/tool_parsers/deepseekv4_engine_tool_parser.py b/vllm/tool_parsers/deepseekv4_engine_tool_parser.py new file mode 100644 index 0000000000..7e3ebf2691 --- /dev/null +++ b/vllm/tool_parsers/deepseekv4_engine_tool_parser.py @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from vllm.parser.engine.registered_adapters import DeepSeekV4ParserToolAdapter + + +class DeepSeekV4EngineToolParser(DeepSeekV4ParserToolAdapter): # type: ignore[valid-type, misc] + structural_tag_model = "deepseek_v4" diff --git a/vllm/tool_parsers/deepseekv4_tool_parser.py b/vllm/tool_parsers/deepseekv4_tool_parser.py deleted file mode 100644 index 2558f585f8..0000000000 --- a/vllm/tool_parsers/deepseekv4_tool_parser.py +++ /dev/null @@ -1,17 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -from vllm.tool_parsers.deepseekv32_tool_parser import DeepSeekV32ToolParser - - -class DeepSeekV4ToolParser(DeepSeekV32ToolParser): - """ - DeepSeek V4 DSML tool parser. - - V4 keeps the V3.2 DSML invoke/parameter grammar, but wraps tool calls in - ``<|DSML|tool_calls>`` instead of ``<|DSML|function_calls>``. - """ - - tool_call_start_token: str = "<|DSML|tool_calls>" - tool_call_end_token: str = "" - structural_tag_model = "deepseek_v4"