Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions tests/tool_parsers/test_deepseekv31_tool_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import pytest

from tests.tool_parsers.utils import run_tool_extraction_streaming
from vllm.tokenizers import get_tokenizer
from vllm.tool_parsers.deepseekv31_tool_parser import (
DeepSeekV31ToolParser,
Expand Down Expand Up @@ -59,3 +60,50 @@ def test_extract_tool_calls_with_multiple_tools(parser):

# prefix is content
assert result.content == "some prefix text"


def test_streaming_whole_tool_call_in_one_delta(parser):
# A complete tool call (begin tag, name, arguments, end tag) arriving in a
# single streaming delta must be emitted, matching the non-streaming result.
# Before the fix, start_count == end_count on the first pass sent the parser
# into the "closing" branch with nothing opened, dropping the call.
model_output = (
"<|tool▁calls▁begin|>"
'<|tool▁call▁begin|>foo<|tool▁sep|>{"x":1}<|tool▁call▁end|>'
"<|tool▁calls▁end|>"
)

nonstream = parser.extract_tool_calls(model_output, None)
assert len(nonstream.tool_calls) == 1

reconstructor = run_tool_extraction_streaming(parser, [model_output])

assert len(reconstructor.tool_calls) == 1
assert reconstructor.tool_calls[0].function.name == "foo"
assert reconstructor.tool_calls[0].function.name == (
nonstream.tool_calls[0].function.name
)
assert reconstructor.tool_calls[0].function.arguments == '{"x":1}'
assert reconstructor.tool_calls[0].function.arguments == (
nonstream.tool_calls[0].function.arguments
)


def test_streaming_parallel_tool_calls_in_one_delta(parser):
# Multiple complete tool calls in a single delta must all be emitted.
model_output = (
"<|tool▁calls▁begin|>"
'<|tool▁call▁begin|>foo<|tool▁sep|>{"x":1}<|tool▁call▁end|>'
'<|tool▁call▁begin|>bar<|tool▁sep|>{"y":2}<|tool▁call▁end|>'
"<|tool▁calls▁end|>"
)

reconstructor = run_tool_extraction_streaming(
parser, [model_output], assert_one_tool_per_delta=False
)

assert len(reconstructor.tool_calls) == 2
assert reconstructor.tool_calls[0].function.name == "foo"
assert reconstructor.tool_calls[0].function.arguments == '{"x":1}'
assert reconstructor.tool_calls[1].function.name == "bar"
assert reconstructor.tool_calls[1].function.arguments == '{"y":2}'
59 changes: 59 additions & 0 deletions tests/tool_parsers/test_deepseekv3_tool_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
ToolParserTestConfig,
ToolParserTests,
)
from tests.tool_parsers.utils import (
run_tool_extraction,
run_tool_extraction_streaming,
)
from vllm.tokenizers import TokenizerLike, get_tokenizer


Expand Down Expand Up @@ -90,3 +94,58 @@ def test_config(self) -> ToolParserTestConfig:
),
},
)

def test_streaming_whole_tool_call_in_one_delta(
self,
tool_parser,
test_config: ToolParserTestConfig,
):
"""A complete tool call delivered in a single streaming delta must be
emitted, matching the non-streaming result.

Chunk boundaries can batch a whole tool call (begin tag, name,
arguments, and end tag) into one delta -- e.g. under speculative
decoding or scheduler batching. Feeding the full output as a single
delta reproduces that case; before the fix the call was silently
dropped (streamed result was empty).
"""
_, tools_non = run_tool_extraction(
tool_parser, test_config.single_tool_call_output, streaming=False
)

reconstructor = run_tool_extraction_streaming(
tool_parser, [test_config.single_tool_call_output]
)

assert len(reconstructor.tool_calls) == 1
assert reconstructor.tool_calls[0].function.name == (
test_config.single_tool_call_expected_name
)
assert (
reconstructor.tool_calls[0].function.arguments
== tools_non[0].function.arguments
)

def test_streaming_parallel_tool_calls_in_one_delta(
self,
tool_parser,
test_config: ToolParserTestConfig,
):
"""Multiple complete tool calls in a single delta must all be emitted."""
_, tools_non = run_tool_extraction(
tool_parser, test_config.parallel_tool_calls_output, streaming=False
)

reconstructor = run_tool_extraction_streaming(
tool_parser,
[test_config.parallel_tool_calls_output],
assert_one_tool_per_delta=False,
)

assert len(reconstructor.tool_calls) == test_config.parallel_tool_calls_count
for streamed, expected_name in zip(
reconstructor.tool_calls, test_config.parallel_tool_calls_names
):
assert streamed.function.name == expected_name
for streamed, non_streamed in zip(reconstructor.tool_calls, tools_non):
assert streamed.function.arguments == non_streamed.function.arguments
39 changes: 39 additions & 0 deletions vllm/tool_parsers/deepseekv31_tool_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,45 @@ def extract_tool_calls_streaming(
delta_text = delta_text.split(self.tool_call_end_token)[0].rstrip()
text_portion = delta_text.split(self.tool_call_end_token)[-1].lstrip()

# case -- one or more complete tool calls (a begin tag and its
# matching end tag) arrived within this single delta. The
# count-based branches below assume every tool call spends at
# least one delta in a "started but not yet closed" state
# (start_count > end_count) before its end tag is seen. When a
# whole call arrives at once, start_count == end_count on the
# first pass, so it would fall into the "closing" branch with an
# empty prev_tool_call_arr and be silently dropped. Re-use the
# non-streaming regex to parse every complete block and emit the
# ones we have not streamed yet.
if (
cur_tool_start_count == cur_tool_end_count
and cur_tool_start_count > prev_tool_start_count
and prev_tool_start_count == prev_tool_end_count
):
complete_calls = self.tool_call_regex.findall(current_text)
new_calls = complete_calls[self.current_tool_id + 1 :]
if not new_calls:
return None
delta_tool_calls = []
for function_name, function_args in new_calls:
self.current_tool_id += 1
self.current_tool_name_sent = True
self.streamed_args_for_tool.append(function_args)
self.prev_tool_call_arr.append(
{"name": function_name, "arguments": function_args}
)
delta_tool_calls.append(
DeltaToolCall(
index=self.current_tool_id,
type="function",
id=make_tool_call_id(),
function=DeltaFunctionCall(
name=function_name, arguments=function_args
).model_dump(exclude_none=True),
)
)
return DeltaMessage(tool_calls=delta_tool_calls)

# case -- we're starting a new tool call
if (
cur_tool_start_count > cur_tool_end_count
Expand Down
39 changes: 39 additions & 0 deletions vllm/tool_parsers/deepseekv3_tool_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,45 @@ def extract_tool_calls_streaming(
delta_text = delta_text.split(self.tool_call_end_token)[0].rstrip()
text_portion = delta_text.split(self.tool_call_end_token)[-1].lstrip()

# case -- one or more complete tool calls (a begin tag and its
# matching end tag) arrived within this single delta. The
# count-based branches below assume every tool call spends at
# least one delta in a "started but not yet closed" state
# (start_count > end_count) before its end tag is seen. When a
# whole call arrives at once, start_count == end_count on the
# first pass, so it would fall into the "closing" branch with an
# empty prev_tool_call_arr and be silently dropped. Re-use the
# non-streaming regex to parse every complete block and emit the
# ones we have not streamed yet.
if (
cur_tool_start_count == cur_tool_end_count
and cur_tool_start_count > prev_tool_start_count
and prev_tool_start_count == prev_tool_end_count
):
complete_calls = self.tool_call_regex.findall(current_text)
new_calls = complete_calls[self.current_tool_id + 1 :]
if not new_calls:
return None
delta_tool_calls = []
for tool_type, function_name, function_args in new_calls:
self.current_tool_id += 1
self.current_tool_name_sent = True
self.streamed_args_for_tool.append(function_args)
self.prev_tool_call_arr.append(
{"name": function_name, "arguments": function_args}
)
delta_tool_calls.append(
DeltaToolCall(
index=self.current_tool_id,
type="function",
id=make_tool_call_id(),
function=DeltaFunctionCall(
name=function_name, arguments=function_args
).model_dump(exclude_none=True),
)
)
return DeltaMessage(tool_calls=delta_tool_calls)

# case -- we're starting a new tool call
if (
cur_tool_start_count > cur_tool_end_count
Expand Down
Loading