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