[Bugfix] Route Step3p5 forced tool choices through XML parser (#51810)

Signed-off-by: taking-lying-flat <1615405@qq.com> Signed-off-by: Flora Feng <4florafeng@gmail.com> Co-authored-by: Flora Feng <4florafeng@gmail.com>

cherry77-cloud committed Sep 30, 2026 at 05:58 UTC 5e887a078b764e69b6579926619e25eb4f0066dc
3 files changed +83 -82
tests/tool_parsers/test_step3p5_tool_parser.py
+25 -21
@@ -5,6 +5,7 @@ import json
5 from collections.abc import Generator
6
7 import pytest
8 +from openai.types.responses import FunctionTool
9
10 from vllm.entrypoints.generate.base.protocol import (
11 DeltaMessage,
@@ -386,26 +387,29 @@ TX
387 assert extracted_tool_calls.tool_calls[0].function.name == "get_current_weather"
388
389
389 -def test_extract_tool_calls_type_conversion(step3p5_tokenizer):
390 - """Test parameter type conversion based on tool schema."""
391 - tools = [
392 - ChatCompletionToolsParam(
393 - type="function",
394 - function={
395 - "name": "test_types",
396 - "parameters": {
397 - "type": "object",
398 - "properties": {
399 - "int_param": {"type": "integer"},
400 - "float_param": {"type": "float"},
401 - "bool_param": {"type": "boolean"},
402 - "str_param": {"type": "string"},
403 - "obj_param": {"type": "object"},
404 - },
405 - },
406 - },
407 - )
408 - ]
390 +@pytest.mark.parametrize("api", ["chat", "responses"])
391 +def test_extract_tool_calls_type_conversion(step3p5_tokenizer, api):
392 + """Test parameter type conversion based on Chat and Responses tool schemas."""
393 + parameters = {
394 + "type": "object",
395 + "properties": {
396 + "int_param": {"type": "integer"},
397 + "float_param": {"type": "float"},
398 + "bool_param": {"type": "boolean"},
399 + "str_param": {"type": "string"},
400 + "obj_param": {"type": "object"},
401 + },
402 + }
403 + tools: list[ChatCompletionToolsParam | FunctionTool] = (
404 + [
405 + ChatCompletionToolsParam(
406 + type="function",
407 + function={"name": "test_types", "parameters": parameters},
408 + )
409 + ]
410 + if api == "chat"
411 + else [FunctionTool(type="function", name="test_types", parameters=parameters)]
412 + )
413
414 model_output = """<tool_call>
415 <function=test_types>
@@ -428,7 +432,7 @@ hello world
432 </tool_call>"""
433
434 parser = Step3p5ToolParser(step3p5_tokenizer, tools=tools)
431 - request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools)
435 + request = ChatCompletionRequest(model=MODEL, messages=[])
436 extracted_tool_calls = parser.extract_tool_calls(model_output, request=request)
437
438 args = json.loads(extracted_tool_calls.tool_calls[0].function.arguments)
tests/tool_parsers/test_structural_tag_registry.py
+43
@@ -47,6 +47,7 @@ from vllm.tool_parsers.mimo_tool_parser import MiMoToolParser
47 from vllm.tool_parsers.minimax_m2_tool_parser import MinimaxM2ToolParser
48 from vllm.tool_parsers.plamo3_engine_tool_parser import Plamo3EngineToolParser
49 from vllm.tool_parsers.qwen3_engine_tool_parser import Qwen3EngineToolParser
50 +from vllm.tool_parsers.step3p5_tool_parser import Step3p5ToolParser
51 from vllm.tool_parsers.structural_tag_registry import (
52 SUPPORTED_STRUCTURAL_TAG_MODELS,
53 VLLM_BUILTIN_STRUCTURAL_TAG_MODELS,
@@ -669,6 +670,7 @@ def test_get_model_structural_tag_supports_named_tool_choice(
670 (Llama3JsonToolParser, "llama"),
671 (MinimaxM2ToolParser, "minimax"),
672 (Qwen3EngineToolParser, "qwen_3_coder"),
673 + (Step3p5ToolParser, "qwen_3_coder"),
674 (MiMoToolParser, "mimo"),
675 ],
676 )
@@ -677,6 +679,47 @@ def test_tool_parsers_declare_matching_xgrammar_builtin_model(parser_cls, model)
679 assert not parser_cls.supports_required_and_named
680
681
682 +@pytest.mark.parametrize(
683 + "tool_choice",
684 + ["required", {"type": "function", "function": {"name": "get_weather"}}],
685 +)
686 +def test_step3p5_forced_tool_choice_round_trips_native_xml(
687 + sample_tools: list[ChatCompletionToolsParam], tool_choice
688 +):
689 + """Forced Step calls must be constrained to, and parsed from, Step XML."""
690 +
691 + class StepParser(DelegatingParser):
692 + tool_parser_cls = Step3p5ToolParser
693 +
694 + request = ChatCompletionRequest(
695 + messages=[],
696 + model="m",
697 + # Named tool choice validation reads tools in their JSON form.
698 + tools=[tool.model_dump(exclude_none=True) for tool in sample_tools],
699 + tool_choice=tool_choice,
700 + )
701 + parser = StepParser(MagicMock(), tools=sample_tools)
702 + request = parser.adjust_request(request)
703 +
704 + assert request.structured_outputs.json is None
705 + grammar = Grammar.from_structural_tag(request.structured_outputs.structural_tag)
706 + # Rendered exactly as the Step-3.5/3.7 chat templates render tool calls.
707 + output = (
708 + "<tool_call>\n<function=get_weather>\n"
709 + "<parameter=city>\nDallas\n</parameter>\n"
710 + "</function>\n</tool_call>"
711 + )
712 + assert _is_grammar_accept_string(grammar, output)
713 + assert not _is_grammar_accept_string(grammar, "It is sunny in Dallas.")
714 +
715 + _, content, tool_calls = parser.parse(output, request, enable_auto_tools=True)
716 +
717 + assert not content
718 + assert [(call.name, json.loads(call.arguments)) for call in tool_calls] == [
719 + ("get_weather", {"city": "Dallas"})
720 + ]
721 +
722 +
723 def test_tool_parsers_without_structural_tag_support_required_and_named():
724 class NonStructuralTagToolParser(ToolParser):
725 pass
vllm/tool_parsers/step3p5_tool_parser.py
+15 -61
@@ -22,7 +22,11 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
22 from vllm.logger import init_logger
23 from vllm.tokenizers import TokenizerLike
24 from vllm.tool_parsers.abstract_tool_parser import Tool, ToolParser
25 -from vllm.tool_parsers.utils import safe_literal_eval
25 +from vllm.tool_parsers.utils import (
26 + find_tool_name,
27 + find_tool_properties,
28 + safe_literal_eval,
29 +)
30
31 logger = init_logger(__name__)
32
@@ -421,43 +425,14 @@ class StreamingXMLToolCallParser:
425
426 def _validate_function_name(self, func_name: str) -> bool:
427 """Check if function name exists in tool definitions."""
424 - if not self.tools:
425 - return False
426 -
427 - for tool in self.tools:
428 - if (
429 - hasattr(tool, "type")
430 - and tool.type == "function"
431 - and hasattr(tool, "function")
432 - and hasattr(tool.function, "name")
433 - and tool.function.name == func_name
434 - ):
435 - return True
436 -
437 - return False
428 + return find_tool_name(self.tools, func_name)
429
430 def _validate_parameter_name(self, param_name: str) -> bool:
431 """Check if parameter exists in current function's tool definition."""
432 if not self.tools or not self.current_function_name:
433 return True
443 -
444 - for tool in self.tools:
445 - if (
446 - hasattr(tool, "type")
447 - and tool.type == "function"
448 - and hasattr(tool, "function")
449 - and hasattr(tool.function, "name")
450 - and tool.function.name == self.current_function_name
451 - ):
452 - if not hasattr(tool.function, "parameters"):
453 - return True
454 - params = tool.function.parameters
455 - if isinstance(params, dict):
456 - properties = params.get("properties", params)
457 - return param_name in properties
458 - break
459 -
460 - return True
434 + properties = find_tool_properties(self.tools, self.current_function_name)
435 + return not properties or param_name in properties
436
437 def _should_skip_element(self, element: str) -> bool:
438 """Determine whether an element should be skipped.
@@ -1190,34 +1165,10 @@ class StreamingXMLToolCallParser:
1165 """
1166 if not self.tools or not self.current_function_name:
1167 return "string"
1193 -
1194 - for tool in self.tools:
1195 - if not hasattr(tool, "type") or not (
1196 - hasattr(tool, "function") and hasattr(tool.function, "name")
1197 - ):
1198 - continue
1199 - if (
1200 - tool.type == "function"
1201 - and tool.function.name == self.current_function_name
1202 - ):
1203 - if not hasattr(tool.function, "parameters"):
1204 - return "string"
1205 - params = tool.function.parameters
1206 - if isinstance(params, dict) and "properties" in params:
1207 - properties = params["properties"]
1208 - if param_name in properties and isinstance(
1209 - properties[param_name], dict
1210 - ):
1211 - return self.repair_param_type(
1212 - str(properties[param_name].get("type", "string"))
1213 - )
1214 - elif isinstance(params, dict) and param_name in params:
1215 - param_config = params[param_name]
1216 - if isinstance(param_config, dict):
1217 - return self.repair_param_type(
1218 - str(param_config.get("type", "string"))
1219 - )
1220 - break
1168 + properties = find_tool_properties(self.tools, self.current_function_name)
1169 + param_config = properties.get(param_name)
1170 + if isinstance(param_config, dict):
1171 + return self.repair_param_type(str(param_config.get("type", "string")))
1172 return "string"
1173
1174 def repair_param_type(self, param_type: str) -> str:
@@ -1357,6 +1308,9 @@ class StreamingXMLToolCallParser:
1308
1309
1310 class Step3p5ToolParser(ToolParser):
1311 + # Step-3.5/3.7 chat templates use the Qwen3-Coder XML tool-call format.
1312 + structural_tag_model = "qwen_3_coder"
1313 +
1314 def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None):
1315 super().__init__(tokenizer, tools)
1316 self.parser = StreamingXMLToolCallParser()