[Bugfix][Frontend] Enforce parallel_tool_calls=false in the required-tool grammar (#50502)
Signed-off-by: ostring <ostring@qq.com> Signed-off-by: sfeng33 <4florafeng@gmail.com> Co-authored-by: Flora Feng <4florafeng@gmail.com>
This commit is contained in:
@@ -27,6 +27,7 @@ def mock_request():
|
||||
request = MagicMock(spec=ChatCompletionRequest)
|
||||
request.tools = []
|
||||
request.tool_choice = "auto"
|
||||
request.parallel_tool_calls = True
|
||||
return request
|
||||
|
||||
|
||||
|
||||
@@ -1317,27 +1317,153 @@ def test_mimo_strict_compact_xml(policy):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("policy", ["auto", "required"])
|
||||
def test_mimo_request_limits_parallel_calls(sample_tools_strict, policy):
|
||||
_DS_CALLS_BEGIN = "<\uff5ctool\u2581calls\u2581begin\uff5c>"
|
||||
_DS_CALL_BEGIN = "<\uff5ctool\u2581call\u2581begin\uff5c>"
|
||||
_DS_SEP = "<\uff5ctool\u2581sep\uff5c>"
|
||||
_DS_CALL_END = "<\uff5ctool\u2581call\u2581end\uff5c>"
|
||||
_DS_CALLS_END = "<\uff5ctool\u2581calls\u2581end\uff5c>"
|
||||
|
||||
# One entry per tool-call list shape: top-level triggered tags (mimo, qwen3),
|
||||
# tags with a separator (hermes required), and a calls block wrapping the list
|
||||
# (deepseek). Each is (tool_parser, list prefix, one call, list suffix).
|
||||
_SINGLE_CALL_CASES = [
|
||||
(
|
||||
"mimo",
|
||||
"",
|
||||
"<tool_call><function=get_weather><parameter=city>Paris</parameter>"
|
||||
"</function></tool_call>",
|
||||
"",
|
||||
),
|
||||
(
|
||||
"hermes",
|
||||
"",
|
||||
'<tool_call>\n{"name": "get_weather", "arguments": {"city": "Paris"}}'
|
||||
"\n</tool_call>",
|
||||
"",
|
||||
),
|
||||
(
|
||||
"qwen3_xml",
|
||||
"",
|
||||
"<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n"
|
||||
"</parameter>\n</function>\n</tool_call>",
|
||||
"",
|
||||
),
|
||||
(
|
||||
"deepseek_v31",
|
||||
_DS_CALLS_BEGIN,
|
||||
f'{_DS_CALL_BEGIN}get_weather{_DS_SEP}{{"city": "Paris"}}{_DS_CALL_END}',
|
||||
_DS_CALLS_END,
|
||||
),
|
||||
]
|
||||
|
||||
_SINGLE_CALL_IDS = [case[0] for case in _SINGLE_CALL_CASES]
|
||||
|
||||
|
||||
def _tool_calling_grammar(
|
||||
tool_parser: str,
|
||||
tools: list[ChatCompletionToolsParam],
|
||||
request: ChatCompletionRequest | ResponsesRequest,
|
||||
) -> Grammar | None:
|
||||
from vllm.parser.parser_manager import ParserManager
|
||||
|
||||
parser_cls = ParserManager.get_parser(
|
||||
tool_parser_name=tool_parser, enable_auto_tools=True
|
||||
)
|
||||
adjusted = parser_cls(MagicMock(), tools=tools).adjust_request(request)
|
||||
structured_outputs = adjusted.structured_outputs
|
||||
if structured_outputs is None or structured_outputs.structural_tag is None:
|
||||
return None
|
||||
return Grammar.from_structural_tag(structured_outputs.structural_tag)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("policy", ["auto", "required"])
|
||||
@pytest.mark.parametrize(
|
||||
"tool_parser,prefix,call,suffix", _SINGLE_CALL_CASES, ids=_SINGLE_CALL_IDS
|
||||
)
|
||||
def test_parallel_tool_calls_false_limits_grammar_to_one_call(
|
||||
sample_tools, tool_parser, prefix, call, suffix, policy
|
||||
):
|
||||
# Tools are not strict, so auto gets a grammar only because of the limit.
|
||||
request = ChatCompletionRequest(
|
||||
model="mimo",
|
||||
model="m",
|
||||
messages=[],
|
||||
tools=[t.model_dump() for t in sample_tools_strict],
|
||||
tools=[t.model_dump() for t in sample_tools],
|
||||
tool_choice=policy,
|
||||
parallel_tool_calls=False,
|
||||
)
|
||||
parser_cls = ParserManager.get_parser(
|
||||
tool_parser_name="mimo", enable_auto_tools=True
|
||||
|
||||
grammar = _tool_calling_grammar(tool_parser, sample_tools, request)
|
||||
|
||||
assert grammar is not None
|
||||
assert _is_grammar_accept_string(grammar, prefix + call + suffix)
|
||||
assert not _is_grammar_accept_string(grammar, prefix + call * 2 + suffix)
|
||||
assert not _is_grammar_accept_string(grammar, prefix + call + suffix + "text")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool_parser,prefix,call,suffix", _SINGLE_CALL_CASES, ids=_SINGLE_CALL_IDS
|
||||
)
|
||||
def test_parallel_tool_calls_default_keeps_grammar_unlimited(
|
||||
sample_tools, tool_parser, prefix, call, suffix
|
||||
):
|
||||
def request(policy: str) -> ChatCompletionRequest:
|
||||
return ChatCompletionRequest(
|
||||
model="m",
|
||||
messages=[],
|
||||
tools=[t.model_dump() for t in sample_tools],
|
||||
tool_choice=policy,
|
||||
)
|
||||
|
||||
assert _tool_calling_grammar(tool_parser, sample_tools, request("auto")) is None
|
||||
grammar = _tool_calling_grammar(tool_parser, sample_tools, request("required"))
|
||||
assert grammar is not None
|
||||
assert _is_grammar_accept_string(grammar, prefix + call * 2 + suffix)
|
||||
|
||||
|
||||
def test_parallel_tool_calls_false_does_not_enable_calls_with_response_format(
|
||||
sample_tools,
|
||||
):
|
||||
# With the flag unset, auto + response_format + non-strict tools is
|
||||
# format-only. Setting the flag to false must not make tool calls possible.
|
||||
request = ChatCompletionRequest(
|
||||
model="m",
|
||||
messages=[],
|
||||
tools=[t.model_dump() for t in sample_tools],
|
||||
tool_choice="auto",
|
||||
parallel_tool_calls=False,
|
||||
response_format={
|
||||
"type": "json_schema",
|
||||
"json_schema": {"name": "answer", "schema": {"type": "object"}},
|
||||
},
|
||||
)
|
||||
parser = parser_cls(MagicMock(), tools=sample_tools_strict)
|
||||
adjusted = parser.adjust_request(request)
|
||||
grammar = Grammar.from_structural_tag(adjusted.structured_outputs.structural_tag)
|
||||
call = (
|
||||
"<tool_call><function=get_weather><parameter=city>北京</parameter>"
|
||||
"</function></tool_call>"
|
||||
|
||||
assert _tool_calling_grammar("hermes", sample_tools, request) is None
|
||||
|
||||
|
||||
def test_responses_parallel_tool_calls_false_limits_grammar_to_one_call():
|
||||
# The Responses API has no post-hoc filter, so the grammar is the only limit.
|
||||
request = ResponsesRequest.model_validate(
|
||||
{
|
||||
"input": "hi",
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "get_weather",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"],
|
||||
},
|
||||
}
|
||||
],
|
||||
"tool_choice": "required",
|
||||
"parallel_tool_calls": False,
|
||||
}
|
||||
)
|
||||
_, _, call, _ = _SINGLE_CALL_CASES[1]
|
||||
|
||||
grammar = _tool_calling_grammar("hermes", request.tools, request)
|
||||
|
||||
assert grammar is not None
|
||||
assert _is_grammar_accept_string(grammar, call)
|
||||
assert not _is_grammar_accept_string(grammar, call * 2)
|
||||
assert not _is_grammar_accept_string(grammar, call + "extra text")
|
||||
|
||||
@@ -9,6 +9,8 @@ from openai.types.responses import FunctionTool, WebSearchTool
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from vllm.entrypoints.openai.chat_completion.protocol import (
|
||||
ChatCompletionNamedFunction,
|
||||
ChatCompletionNamedToolChoiceParam,
|
||||
ChatCompletionToolsParam,
|
||||
)
|
||||
from vllm.tool_parsers.streaming import extract_required_tool_call_streaming
|
||||
@@ -68,11 +70,18 @@ EXAMPLE_TOOLS = [
|
||||
|
||||
|
||||
def _compile_and_check(
|
||||
tools: list[ChatCompletionToolsParam], sample_output, should_match: bool
|
||||
tools: list[ChatCompletionToolsParam],
|
||||
sample_output,
|
||||
should_match: bool,
|
||||
parallel_tool_calls: bool | None = None,
|
||||
):
|
||||
# self = MagicMock(tool_choice="required", tools=tools)
|
||||
# schema = ChatCompletionRequest._get_json_schema_from_tool(self)
|
||||
schema = get_json_schema_from_tools(tools=tools, tool_choice="required")
|
||||
schema = get_json_schema_from_tools(
|
||||
tools=tools,
|
||||
tool_choice="required",
|
||||
parallel_tool_calls=parallel_tool_calls,
|
||||
)
|
||||
assert isinstance(schema, dict)
|
||||
|
||||
# use build_regex_from_schema used in JSONLogitsProcessor to create Guide
|
||||
@@ -393,3 +402,57 @@ class TestNonFunctionToolsSkipped:
|
||||
any_of = schema["items"]["anyOf"]
|
||||
assert len(any_of) == 1
|
||||
assert any_of[0]["properties"]["name"]["enum"] == ["get_weather"]
|
||||
|
||||
|
||||
class TestParallelToolCallsConstraint:
|
||||
"""`parallel_tool_calls=false` must be enforced by the decoding grammar.
|
||||
|
||||
Without a `maxItems` bound the model is free to emit an unbounded run of
|
||||
tool calls that are only discarded afterwards, wasting the token budget and
|
||||
risking truncation of the one call the client actually receives."""
|
||||
|
||||
TOOLS = TypeAdapter(list[ChatCompletionToolsParam]).validate_python(EXAMPLE_TOOLS)
|
||||
ONE_CALL = [{"name": "get_current_weather", "parameters": {"city": "Vienna"}}]
|
||||
TWO_CALLS = [
|
||||
{"name": "get_current_weather", "parameters": {"city": "Vienna"}},
|
||||
{"name": "get_current_weather", "parameters": {"city": "Berlin"}},
|
||||
]
|
||||
|
||||
def test_disabled_rejects_a_second_tool_call(self):
|
||||
_compile_and_check(self.TOOLS, self.ONE_CALL, True, parallel_tool_calls=False)
|
||||
_compile_and_check(self.TOOLS, self.TWO_CALLS, False, parallel_tool_calls=False)
|
||||
|
||||
@pytest.mark.parametrize("parallel_tool_calls", [True, None])
|
||||
def test_enabled_or_unset_still_allows_multiple(self, parallel_tool_calls):
|
||||
_compile_and_check(
|
||||
self.TOOLS,
|
||||
self.TWO_CALLS,
|
||||
True,
|
||||
parallel_tool_calls=parallel_tool_calls,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"parallel_tool_calls,expected",
|
||||
[(False, 1), (True, None), (None, None)],
|
||||
)
|
||||
def test_max_items_bound(self, parallel_tool_calls, expected):
|
||||
schema = get_json_schema_from_tools(
|
||||
tools=self.TOOLS,
|
||||
tool_choice="required",
|
||||
parallel_tool_calls=parallel_tool_calls,
|
||||
)
|
||||
assert isinstance(schema, dict)
|
||||
assert schema["minItems"] == 1
|
||||
assert schema.get("maxItems") == expected
|
||||
|
||||
def test_forced_named_tool_is_unaffected(self):
|
||||
# Named tool choice yields a bare parameters object, never an array.
|
||||
schema = get_json_schema_from_tools(
|
||||
tools=self.TOOLS,
|
||||
tool_choice=ChatCompletionNamedToolChoiceParam(
|
||||
function=ChatCompletionNamedFunction(name="get_current_weather")
|
||||
),
|
||||
parallel_tool_calls=False,
|
||||
)
|
||||
assert isinstance(schema, dict)
|
||||
assert "maxItems" not in schema
|
||||
|
||||
@@ -49,7 +49,10 @@ from vllm.tool_parsers.streaming import (
|
||||
extract_named_tool_call_streaming,
|
||||
extract_required_tool_call_streaming,
|
||||
)
|
||||
from vllm.tool_parsers.structural_tag_registry import resolve_tool_strictness
|
||||
from vllm.tool_parsers.structural_tag_registry import (
|
||||
limit_to_single_tool_call,
|
||||
resolve_tool_strictness,
|
||||
)
|
||||
from vllm.tool_parsers.tool_strict_level import ToolStrictLevel
|
||||
|
||||
logger = init_logger(__name__)
|
||||
@@ -496,13 +499,20 @@ class DelegatingParser(Parser):
|
||||
|
||||
structured_outputs = request.extract_structured_outputs()
|
||||
is_auto = request.tool_choice == "auto"
|
||||
single_call = request.parallel_tool_calls is False
|
||||
strict_level = self.tool_strict_level
|
||||
if single_call and not (is_auto and structured_outputs is not None):
|
||||
# Limiting the call count needs the call envelope in the grammar.
|
||||
# Auto with structured outputs keeps its format-only grammar, so the
|
||||
# flag never makes calls possible that are impossible without it.
|
||||
strict_level = max(strict_level, ToolStrictLevel.FUNCTION)
|
||||
|
||||
resolved_tools = None
|
||||
if tool_parser.structural_tag_model is not None:
|
||||
resolved_tools = resolve_tool_strictness(
|
||||
request.tools,
|
||||
request.tool_choice,
|
||||
self.tool_strict_level,
|
||||
strict_level,
|
||||
)
|
||||
|
||||
output_format = None
|
||||
@@ -529,7 +539,7 @@ class DelegatingParser(Parser):
|
||||
tools_structural_tag = tool_parser.get_structural_tag(
|
||||
tag_request,
|
||||
reasoning=False,
|
||||
strict_level=self.tool_strict_level,
|
||||
strict_level=strict_level,
|
||||
)
|
||||
else:
|
||||
tools_structural_tag = None
|
||||
@@ -544,6 +554,8 @@ class DelegatingParser(Parser):
|
||||
)
|
||||
return request
|
||||
|
||||
if single_call:
|
||||
limit_to_single_tool_call(tools_structural_tag)
|
||||
structural_tag = tools_structural_tag
|
||||
if output_format is not None:
|
||||
structural_tag = StructuralTag(
|
||||
|
||||
@@ -141,7 +141,9 @@ class ToolParser:
|
||||
return request
|
||||
|
||||
json_schema_from_tool = get_json_schema_from_tools(
|
||||
tool_choice=request.tool_choice, tools=request.tools
|
||||
tool_choice=request.tool_choice,
|
||||
tools=request.tools,
|
||||
parallel_tool_calls=request.parallel_tool_calls,
|
||||
)
|
||||
# Set structured output params for tool calling
|
||||
if json_schema_from_tool is not None:
|
||||
|
||||
@@ -1,25 +1,8 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from xgrammar.structural_tag import TriggeredTagsFormat
|
||||
|
||||
from vllm.parser.engine.registered_adapters import MiMoParserToolAdapter
|
||||
from vllm.tool_parsers.tool_strict_level import ToolStrictLevel
|
||||
|
||||
|
||||
class MiMoToolParser(MiMoParserToolAdapter): # type: ignore[valid-type, misc]
|
||||
structural_tag_model = "mimo"
|
||||
|
||||
def get_structural_tag(
|
||||
self, request, *, reasoning=False, strict_level=ToolStrictLevel.AUTO
|
||||
):
|
||||
tag = super().get_structural_tag(
|
||||
request, reasoning=reasoning, strict_level=strict_level
|
||||
)
|
||||
if (
|
||||
tag is not None
|
||||
and request.parallel_tool_calls is False
|
||||
and isinstance(tag.format, TriggeredTagsFormat)
|
||||
):
|
||||
tag.format.stop_after_first = True
|
||||
return tag
|
||||
|
||||
@@ -9,6 +9,7 @@ from openai.types.responses.response import ToolChoice as ResponsesToolChoice
|
||||
from openai.types.responses.tool import Tool as ResponsesTool
|
||||
from openai.types.responses.tool_choice_allowed import ToolChoiceAllowed
|
||||
from openai.types.responses.tool_choice_function import ToolChoiceFunction
|
||||
from pydantic import BaseModel
|
||||
from xgrammar import StructuralTag, normalize_tool_choice
|
||||
from xgrammar import get_model_structural_tag as get_xgrammar_model_structural_tag
|
||||
from xgrammar.openai_tool_call_schema import (
|
||||
@@ -27,6 +28,7 @@ from xgrammar.structural_tag import (
|
||||
StarFormat,
|
||||
TagFormat,
|
||||
TagsWithSeparatorFormat,
|
||||
TokenTriggeredTagsFormat,
|
||||
TriggeredTagsFormat,
|
||||
)
|
||||
|
||||
@@ -197,6 +199,34 @@ def get_model_structural_tag(
|
||||
)
|
||||
|
||||
|
||||
_TOOL_CALL_LIST_FORMATS = (
|
||||
TriggeredTagsFormat,
|
||||
TokenTriggeredTagsFormat,
|
||||
TagsWithSeparatorFormat,
|
||||
)
|
||||
|
||||
|
||||
def limit_to_single_tool_call(tag: StructuralTag) -> StructuralTag:
|
||||
"""Stop every tool-call list in ``tag`` after its first call, in place.
|
||||
|
||||
Enforces ``parallel_tool_calls=false`` in the grammar. In every registered
|
||||
tag, ``_TOOL_CALL_LIST_FORMATS`` repeat tool calls, never arguments.
|
||||
"""
|
||||
|
||||
def visit(node: Any) -> None:
|
||||
if isinstance(node, BaseModel):
|
||||
if isinstance(node, _TOOL_CALL_LIST_FORMATS):
|
||||
node.stop_after_first = True
|
||||
for name in type(node).model_fields:
|
||||
visit(getattr(node, name))
|
||||
elif isinstance(node, (list, tuple)):
|
||||
for item in node:
|
||||
visit(item)
|
||||
|
||||
visit(tag.format)
|
||||
return tag
|
||||
|
||||
|
||||
def _dump_tool_for_xgrammar(
|
||||
tool: ChatCompletionToolsParam | ResponsesTool,
|
||||
) -> dict[str, Any]:
|
||||
|
||||
@@ -347,6 +347,7 @@ def _get_tool_schema_defs(
|
||||
|
||||
def _get_json_schema_from_tools(
|
||||
tools: list[Tool],
|
||||
parallel_tool_calls: bool | None = None,
|
||||
) -> dict:
|
||||
fn_tool_schemas: list[dict[str, Any]] = []
|
||||
fn_tools: list[Tool] = []
|
||||
@@ -361,7 +362,7 @@ def _get_json_schema_from_tools(
|
||||
elif _is_function_tool(tool):
|
||||
fn_tool_schemas.append(_get_tool_schema_from_tool(tool))
|
||||
fn_tools.append(tool)
|
||||
json_schema = {
|
||||
json_schema: dict[str, Any] = {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
@@ -369,6 +370,10 @@ def _get_json_schema_from_tools(
|
||||
"anyOf": fn_tool_schemas,
|
||||
},
|
||||
}
|
||||
# Only an explicit `false` opts out of parallel tool calls; `None` means the
|
||||
# field was never set and the default (true) applies.
|
||||
if parallel_tool_calls is False:
|
||||
json_schema["maxItems"] = 1
|
||||
json_schema_defs = _get_tool_schema_defs(fn_tools)
|
||||
if json_schema_defs:
|
||||
json_schema["$defs"] = json_schema_defs
|
||||
@@ -378,6 +383,7 @@ def _get_json_schema_from_tools(
|
||||
def get_json_schema_from_tools(
|
||||
tool_choice: str | ToolChoiceFunction | ChatCompletionNamedToolChoiceParam,
|
||||
tools: list[Tool] | None,
|
||||
parallel_tool_calls: bool | None = None,
|
||||
) -> str | dict | None:
|
||||
# tool_choice: "none"
|
||||
if tool_choice in ("none", None) or tools is None:
|
||||
@@ -413,7 +419,7 @@ def get_json_schema_from_tools(
|
||||
return chat_tool_map[tool_name].function.parameters
|
||||
# tool_choice: "required"
|
||||
if tool_choice == "required":
|
||||
return _get_json_schema_from_tools(tools)
|
||||
return _get_json_schema_from_tools(tools, parallel_tool_calls)
|
||||
# tool_choice: "auto"
|
||||
return None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user