From 19a4d66a0bdd01405ea14ab9a5ea12fb0ff87cf2 Mon Sep 17 00:00:00 2001 From: hubunt <150658615+hubunt@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:37:12 +0800 Subject: [PATCH] [Bugfix] Avoid JSON constraints for native tool parsers (#47512) Signed-off-by: Flora Feng <4florafeng@gmail.com> Co-authored-by: Flora Feng <4florafeng@gmail.com> --- .../test_structural_tag_registry.py | 97 +++++++++++++++++++ vllm/tool_parsers/abstract_tool_parser.py | 7 ++ 2 files changed, 104 insertions(+) diff --git a/tests/tool_parsers/test_structural_tag_registry.py b/tests/tool_parsers/test_structural_tag_registry.py index 01bc53968e..3f22da17cd 100644 --- a/tests/tool_parsers/test_structural_tag_registry.py +++ b/tests/tool_parsers/test_structural_tag_registry.py @@ -6,15 +6,18 @@ from types import SimpleNamespace from unittest.mock import MagicMock import pytest +from openai.types.responses import ToolChoiceFunction from xgrammar import Grammar, StructuralTag from xgrammar.testing import _is_grammar_accept_string +import vllm.envs as envs from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionNamedFunction, ChatCompletionNamedToolChoiceParam, ChatCompletionRequest, ChatCompletionToolsParam, ) +from vllm.entrypoints.openai.responses.protocol import ResponsesRequest from vllm.parser.abstract_parser import DelegatingParser from vllm.parser.plamo3 import ( BEGIN_TOOL_ARGUMENTS, @@ -700,6 +703,100 @@ def test_non_structural_tag_parser_uses_schema_constraints( assert out.structured_outputs.structural_tag is None +_NAMED_CHAT_TOOL_CHOICE = {"type": "function", "function": {"name": "get_weather"}} + + +def _responses_request(tool_choice) -> ResponsesRequest: + return ResponsesRequest.model_validate( + { + "input": "hi", + "tools": [ + { + "type": "function", + "name": "get_weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + }, + } + ], + "tool_choice": tool_choice, + } + ) + + +@pytest.mark.parametrize("tool_choice", ["required", _NAMED_CHAT_TOOL_CHOICE]) +def test_native_format_parser_skips_json_schema_constraint( + sample_tools: list[ChatCompletionToolsParam], + tool_choice, +): + # Parsers that extract required/named calls from their native format must + # not be forced into JSON, which their extractor cannot parse. + class NativeFormatToolParser(ToolParser): + supports_required_and_named = False + + parser = NativeFormatToolParser(MagicMock()) + request = ChatCompletionRequest( + messages=[], + model="m", + # Named tool choice validation reads tools in their JSON form. + tools=[tool.model_dump(exclude_none=True) for tool in sample_tools], + tool_choice=tool_choice, + ) + + out = parser.adjust_request(request) + + assert out.structured_outputs is None + + +@pytest.mark.parametrize( + "tool_choice", + ["required", ToolChoiceFunction(type="function", name="get_weather")], +) +def test_native_format_parser_skips_json_schema_constraint_responses( + tool_choice, +): + class NativeFormatToolParser(ToolParser): + supports_required_and_named = False + + parser = NativeFormatToolParser(MagicMock()) + request = _responses_request(tool_choice) + + out = parser.adjust_request(request) + + assert out.structured_outputs is None + assert out.text is None + + +@pytest.mark.parametrize("tool_choice", ["required", _NAMED_CHAT_TOOL_CHOICE]) +def test_glm47_without_strict_tool_calling_skips_json_schema_constraint( + monkeypatch: pytest.MonkeyPatch, + sample_tools: list[ChatCompletionToolsParam], + tool_choice, +): + # With strict tool calling off, no structural tag is attached, so GLM-4.7 + # must decode its native XML instead of a JSON tool-call list. + monkeypatch.setattr(envs, "VLLM_ENFORCE_STRICT_TOOL_CALLING", False) + + class TestParser(DelegatingParser): + tool_parser_cls = Glm47MoeModelToolParser + + request = ChatCompletionRequest( + messages=[], + model="m", + # Named tool choice validation reads tools in their JSON form. + tools=[tool.model_dump(exclude_none=True) for tool in sample_tools], + tool_choice=tool_choice, + ) + parser = TestParser(MagicMock(), tools=sample_tools) + parser._reasoning_parser = MagicMock(adjust_request=lambda request: request) + + out = parser.adjust_request(request) + + assert out.structured_outputs is None + assert out.skip_special_tokens is False + + def test_get_structural_tag_disables_reasoning( monkeypatch: pytest.MonkeyPatch, sample_tools_strict: list[ChatCompletionToolsParam], diff --git a/vllm/tool_parsers/abstract_tool_parser.py b/vllm/tool_parsers/abstract_tool_parser.py index 953782daa9..bcbb5757c1 100644 --- a/vllm/tool_parsers/abstract_tool_parser.py +++ b/vllm/tool_parsers/abstract_tool_parser.py @@ -133,6 +133,13 @@ class ToolParser: ): return request + # Parsers that extract required/named tool calls from their native + # format (supports_required_and_named=False) must not be forced into + # the JSON tool-call format below: they would never find a call in + # the JSON output and would return it as content instead. + if not self.supports_required_and_named: + return request + json_schema_from_tool = get_json_schema_from_tools( tool_choice=request.tool_choice, tools=request.tools )