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

hubunt committed Sep 30, 2026 at 03:37 UTC 19a4d66a0bdd01405ea14ab9a5ea12fb0ff87cf2
2 files changed +104
tests/tool_parsers/test_structural_tag_registry.py
+97
@@ -6,15 +6,18 @@ from types import SimpleNamespace
6 from unittest.mock import MagicMock
7
8 import pytest
9 +from openai.types.responses import ToolChoiceFunction
10 from xgrammar import Grammar, StructuralTag
11 from xgrammar.testing import _is_grammar_accept_string
12
13 +import vllm.envs as envs
14 from vllm.entrypoints.openai.chat_completion.protocol import (
15 ChatCompletionNamedFunction,
16 ChatCompletionNamedToolChoiceParam,
17 ChatCompletionRequest,
18 ChatCompletionToolsParam,
19 )
20 +from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
21 from vllm.parser.abstract_parser import DelegatingParser
22 from vllm.parser.plamo3 import (
23 BEGIN_TOOL_ARGUMENTS,
@@ -700,6 +703,100 @@ def test_non_structural_tag_parser_uses_schema_constraints(
703 assert out.structured_outputs.structural_tag is None
704
705
706 +_NAMED_CHAT_TOOL_CHOICE = {"type": "function", "function": {"name": "get_weather"}}
707 +
708 +
709 +def _responses_request(tool_choice) -> ResponsesRequest:
710 + return ResponsesRequest.model_validate(
711 + {
712 + "input": "hi",
713 + "tools": [
714 + {
715 + "type": "function",
716 + "name": "get_weather",
717 + "parameters": {
718 + "type": "object",
719 + "properties": {"city": {"type": "string"}},
720 + },
721 + }
722 + ],
723 + "tool_choice": tool_choice,
724 + }
725 + )
726 +
727 +
728 +@pytest.mark.parametrize("tool_choice", ["required", _NAMED_CHAT_TOOL_CHOICE])
729 +def test_native_format_parser_skips_json_schema_constraint(
730 + sample_tools: list[ChatCompletionToolsParam],
731 + tool_choice,
732 +):
733 + # Parsers that extract required/named calls from their native format must
734 + # not be forced into JSON, which their extractor cannot parse.
735 + class NativeFormatToolParser(ToolParser):
736 + supports_required_and_named = False
737 +
738 + parser = NativeFormatToolParser(MagicMock())
739 + request = ChatCompletionRequest(
740 + messages=[],
741 + model="m",
742 + # Named tool choice validation reads tools in their JSON form.
743 + tools=[tool.model_dump(exclude_none=True) for tool in sample_tools],
744 + tool_choice=tool_choice,
745 + )
746 +
747 + out = parser.adjust_request(request)
748 +
749 + assert out.structured_outputs is None
750 +
751 +
752 +@pytest.mark.parametrize(
753 + "tool_choice",
754 + ["required", ToolChoiceFunction(type="function", name="get_weather")],
755 +)
756 +def test_native_format_parser_skips_json_schema_constraint_responses(
757 + tool_choice,
758 +):
759 + class NativeFormatToolParser(ToolParser):
760 + supports_required_and_named = False
761 +
762 + parser = NativeFormatToolParser(MagicMock())
763 + request = _responses_request(tool_choice)
764 +
765 + out = parser.adjust_request(request)
766 +
767 + assert out.structured_outputs is None
768 + assert out.text is None
769 +
770 +
771 +@pytest.mark.parametrize("tool_choice", ["required", _NAMED_CHAT_TOOL_CHOICE])
772 +def test_glm47_without_strict_tool_calling_skips_json_schema_constraint(
773 + monkeypatch: pytest.MonkeyPatch,
774 + sample_tools: list[ChatCompletionToolsParam],
775 + tool_choice,
776 +):
777 + # With strict tool calling off, no structural tag is attached, so GLM-4.7
778 + # must decode its native XML instead of a JSON tool-call list.
779 + monkeypatch.setattr(envs, "VLLM_ENFORCE_STRICT_TOOL_CALLING", False)
780 +
781 + class TestParser(DelegatingParser):
782 + tool_parser_cls = Glm47MoeModelToolParser
783 +
784 + request = ChatCompletionRequest(
785 + messages=[],
786 + model="m",
787 + # Named tool choice validation reads tools in their JSON form.
788 + tools=[tool.model_dump(exclude_none=True) for tool in sample_tools],
789 + tool_choice=tool_choice,
790 + )
791 + parser = TestParser(MagicMock(), tools=sample_tools)
792 + parser._reasoning_parser = MagicMock(adjust_request=lambda request: request)
793 +
794 + out = parser.adjust_request(request)
795 +
796 + assert out.structured_outputs is None
797 + assert out.skip_special_tokens is False
798 +
799 +
800 def test_get_structural_tag_disables_reasoning(
801 monkeypatch: pytest.MonkeyPatch,
802 sample_tools_strict: list[ChatCompletionToolsParam],
vllm/tool_parsers/abstract_tool_parser.py
+7
@@ -133,6 +133,13 @@ class ToolParser:
133 ):
134 return request
135
136 + # Parsers that extract required/named tool calls from their native
137 + # format (supports_required_and_named=False) must not be forced into
138 + # the JSON tool-call format below: they would never find a call in
139 + # the JSON output and would return it as content instead.
140 + if not self.supports_required_and_named:
141 + return request
142 +
143 json_schema_from_tool = get_json_schema_from_tools(
144 tool_choice=request.tool_choice, tools=request.tools
145 )