[Frontend] [Parser] Port DeepSeek V4 to streaming parser engine framework (#45877)
Signed-off-by: Ben Browning <bbrownin@redhat.com>
This commit is contained in:
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user