[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()