[Frontend] [Parser] Port DeepSeek V4 to streaming parser engine framework (#45877)

Signed-off-by: Ben Browning <bbrownin@redhat.com>
This commit is contained in:
Ben Browning
2026-07-03 20:55:23 -04:00
committed by GitHub
parent d6d39c111e
commit fb5291b35b
22 changed files with 1900 additions and 671 deletions
+268
View File
@@ -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 (``<think>``/``</think>``) 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 = "</|DSML|parameter>"
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"}
+922
View File
@@ -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 = "</|DSML|parameter>"
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<b"
result = json.loads(_dsml_arg_converter(raw, partial=True))
assert result == {"code": "a<b"}
def test_partial_value_with_angle_bracket_and_complete_param(self):
raw = self._raw(("city", "true", "Tokyo"))
raw += f"<{_PARAM_OPEN.format(name='expr', is_str='true')}x<5"
result = json.loads(_dsml_arg_converter(raw, partial=True))
assert result["city"] == "Tokyo"
assert result["expr"] == "x<5"
def test_null_string_false(self):
raw = self._raw(("val", "false", "null"))
result = json.loads(_dsml_arg_converter(raw, partial=False))
assert result["val"] is None
def test_string_true_not_json_parsed(self):
raw = self._raw(("n", "true", "42"))
result = json.loads(_dsml_arg_converter(raw, partial=False))
assert result["n"] == "42"
assert isinstance(result["n"], str)
# ── Bare </think> absorption and duplicate <think> absorption ─────────
class TestThinkTagAbsorption:
def test_bare_think_end_not_leaked(self, mock_tokenizer):
parser = DeepSeekV4Parser(mock_tokenizer)
chunks = ["</think>", "Here is the direct answer."]
reasoning, content = simulate_reasoning_streaming(parser, chunks)
assert reasoning == ""
assert "</think>" 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 = [
"<think>\n",
"Some reasoning.\n",
"</think>\n",
"Answer.",
]
reasoning, content = simulate_reasoning_streaming(parser, chunks)
assert "Some reasoning" in reasoning
assert "Answer" in content
# ── Missing </|DSML|invoke> before </|DSML|tool_calls> ────────────
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",
"</think>\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 </think> before tool calls) ─────
class TestImplicitReasoningEnd:
"""Tool call markers end reasoning implicitly when </think> is missing.
DeepSeek V4 models occasionally omit </think> 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 </think> + 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 + </think> + 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 </think> + DSML arrive in same delta.
The DelegatingParser used by the serving layer splits reasoning and
tool parsing across two separate engine instances. When </think> 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 </think> + tool calls but the detokenizer strips the
EOS text. The scanner's _rebuild_from_anchors defers all text
after </think> 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"}
+15 -12
View File
@@ -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)
+145 -3
View File
@@ -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] = {
"<think>": 128821,
"</think>": 128822,
f"<{_DSML}tool_calls>": 128823,
f"</{_DSML}tool_calls>": 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}</{_DSML}parameter>\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"</{_DSML}invoke>\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"</{_DSML}{tag}>", 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(("</think>", True))
else:
if scenario.reasoning is not None:
segs.append(("<think>", True))
segs.append((scenario.reasoning, False))
segs.append(("</think>", 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"</{_DSML}function_calls>": 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)
@@ -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):
@@ -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
@@ -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 </|DSML|parameter> 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",
@@ -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"),
+131
View File
@@ -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>
<|DSML|parameter name="count" string="false">5</|DSML|parameter>
</|DSML|invoke>
</|DSML|function_calls>
This is identical to DeepSeek V4 except for the outer wrapper
(``function_calls`` instead of ``tool_calls``) and the absence of
``<think>``/``</think>`` 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"</{_DSML}function_calls>"
@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)
+237
View File
@@ -0,0 +1,237 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""DeepSeek V4 parser: ``<think>``/``</think>``
reasoning plus DSML tool calls in a single state machine.
DeepSeek V4 output format::
<think>
...reasoning...
</think>
<|DSML|tool_calls>
<|DSML|invoke name="func_name">
<|DSML|parameter name="location" string="true">杭州</|DSML|parameter>
<|DSML|parameter name="count" string="false">5</|DSML|parameter>
</|DSML|invoke>
</|DSML|tool_calls>
"""
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 = "<think>"
DSML_THINK_END = "</think>"
DSML_TOOL_START = f"<{_DSML}tool_calls>"
DSML_TOOL_END = f"</{_DSML}tool_calls>"
DSML_INVOKE_PREFIX = f'<{_DSML}invoke name="'
DSML_INVOKE_NAME_END = '">'
DSML_INVOKE_END = f"</{_DSML}invoke>"
DSML_PARAM_CLOSE = f"</{_DSML}parameter>"
_ESCAPED_DSML = re.escape(_DSML)
_PARAM_RE = re.compile(
rf'<{_ESCAPED_DSML}parameter\s+name="([^"]+)"\s+string="(true|false)">'
rf"(.*?)</{_ESCAPED_DSML}parameter>",
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 </think> with no prior <think>
(ParserState.CONTENT, "THINK_END"): Transition(
ParserState.CONTENT,
(),
),
# Absorb a duplicate <think> 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 <think>
(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)
+2 -1
View File
@@ -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,
+7 -3
View File
@@ -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
+12
View File
@@ -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,
@@ -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:
+11 -1
View File
@@ -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)
+2 -2
View File
@@ -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",
@@ -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"]
+4 -4
View File
@@ -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",
@@ -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"
@@ -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>
<|DSML|parameter name="date" string="true">2024-01-16</|DSML|parameter>
</|DSML|invoke>
<|DSML|invoke name="get_weather">
<|DSML|parameter name="location" string="true">北京</|DSML|parameter>
<|DSML|parameter name="date" string="true">2024-01-16</|DSML|parameter>
</|DSML|invoke>
</|DSML|function_calls>
"""
tool_call_start_token: str = "<|DSML|function_calls>"
tool_call_end_token: str = "</|DSML|function_calls>"
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*>(.*?)</|DSML|invoke>', re.DOTALL
)
self.parameter_complete_regex = re.compile(
r'<|DSML|parameter\s+name="([^"]+)"\s+string="(true|false)"\s*>(.*?)</|DSML|parameter>',
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>, </|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 = "</|DSML|parameter>"
invoke_end_token = "</|DSML|invoke>"
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
@@ -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"
@@ -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 = "</|DSML|tool_calls>"
structural_tag_model = "deepseek_v4"