[Bugfix] Handle bare and malformed tool call openers in Gemma4 parser (#53444)

Signed-off-by: wuhangxian <1391938827@qq.com> Co-authored-by: Chauncey <chaunceyjiang@gmail.com>

wuhangxian committed Sep 15, 2026 at 10:52 UTC a529c1a748d3e3e21b3f97883b578e126892abf1
2 files changed +116
tests/tool_parsers/test_gemma4_tool_parser.py
+108
@@ -897,3 +897,111 @@ class TestStreamingExtraction:
897 }
898
899 assert args_text.count("replace_all") == 1
900 +
901 +
902 +class TestBareAndMalformedOpeners:
903 + """Tests for bare and malformed tool call openers.
904 +
905 + See https://github.com/vllm-project/vllm/issues/53431.
906 + """
907 +
908 + def test_bare_opener_non_streaming(self, parser, mock_request):
909 + model_output = '<|tool_call>:get_weather{location:<|"|>London<|"|>}<tool_call|>'
910 + result = parser.extract_tool_calls(model_output, mock_request)
911 +
912 + assert result.tools_called is True
913 + assert len(result.tool_calls) == 1
914 + assert result.tool_calls[0].function.name == "get_weather"
915 + args = json.loads(result.tool_calls[0].function.arguments)
916 + assert args == {"location": "London"}
917 +
918 + def test_documented_form_still_works(self, parser, mock_request):
919 + model_output = (
920 + '<|tool_call>call:get_weather{location:<|"|>London<|"|>}<tool_call|>'
921 + )
922 + result = parser.extract_tool_calls(model_output, mock_request)
923 +
924 + assert result.tools_called is True
925 + assert len(result.tool_calls) == 1
926 + assert result.tool_calls[0].function.name == "get_weather"
927 +
928 + def test_bare_opener_multiple_args(self, parser, mock_request):
929 + model_output = (
930 + "<|tool_call>:get_weather{"
931 + 'location:<|"|>Tokyo<|"|>,unit:<|"|>celsius<|"|>}'
932 + "<tool_call|>"
933 + )
934 + result = parser.extract_tool_calls(model_output, mock_request)
935 +
936 + assert result.tools_called is True
937 + assert result.tool_calls[0].function.name == "get_weather"
938 + args = json.loads(result.tool_calls[0].function.arguments)
939 + assert args == {"location": "Tokyo", "unit": "celsius"}
940 +
941 + def test_malformed_no_brace_bounded(self, parser, mock_request):
942 + model_output = "<|tool_call>call:bad_func no brace<tool_call|>"
943 + result = parser.extract_tool_calls(model_output, mock_request)
944 +
945 + assert result.tools_called is True
946 + assert len(result.tool_calls) == 1
947 + name = result.tool_calls[0].function.name
948 + assert "<tool_call|>" not in name
949 +
950 + def test_malformed_then_well_formed(self, parser, mock_request):
951 + model_output = (
952 + "<|tool_call>call:bad_func no brace<tool_call|>"
953 + '<|tool_call>call:get_weather{location:<|"|>London<|"|>}'
954 + "<tool_call|>"
955 + )
956 + result = parser.extract_tool_calls(model_output, mock_request)
957 +
958 + assert result.tools_called is True
959 + assert len(result.tool_calls) == 2
960 + assert result.tool_calls[0].function.name == "bad_func no brace"
961 + assert result.tool_calls[1].function.name == "get_weather"
962 + assert "London" in result.tool_calls[1].function.arguments
963 +
964 + def test_bare_malformed_then_well_formed(self, parser, mock_request):
965 + model_output = (
966 + "<|tool_call>:bad_func no brace<tool_call|>"
967 + '<|tool_call>call:get_weather{location:<|"|>London<|"|>}'
968 + "<tool_call|>"
969 + )
970 + result = parser.extract_tool_calls(model_output, mock_request)
971 +
972 + assert result.tools_called is True
973 + assert len(result.tool_calls) == 2
974 + assert result.tool_calls[0].function.name == "bad_func no brace"
975 + assert result.tool_calls[1].function.name == "get_weather"
976 +
977 + def test_three_mixed_calls(self, parser, mock_request):
978 + model_output = (
979 + "<|tool_call>:bad no brace<tool_call|>"
980 + '<|tool_call>call:search{input:<|"|>hello<|"|>}<tool_call|>'
981 + "<|tool_call>:set{flag:true,count:5}<tool_call|>"
982 + )
983 + result = parser.extract_tool_calls(model_output, mock_request)
984 +
985 + assert result.tools_called is True
986 + assert len(result.tool_calls) == 3
987 + assert result.tool_calls[0].function.name == "bad no brace"
988 + assert result.tool_calls[1].function.name == "search"
989 + assert result.tool_calls[2].function.name == "set"
990 +
991 + def test_colon_in_content_not_broken(self, parser, mock_request):
992 + model_output = (
993 + "Hello: world"
994 + '<|tool_call>:get_weather{location:<|"|>London<|"|>}<tool_call|>'
995 + )
996 + result = parser.extract_tool_calls(model_output, mock_request)
997 +
998 + assert result.tools_called is True
999 + assert result.content is not None
1000 + assert "Hello" in result.content
1001 +
1002 + def test_bare_opener_empty_args(self, parser, mock_request):
1003 + model_output = "<|tool_call>:set_status{}<tool_call|>"
1004 + result = parser.extract_tool_calls(model_output, mock_request)
1005 +
1006 + assert result.tools_called is True
1007 + assert result.tool_calls[0].function.name == "set_status"
vllm/parser/gemma4.py
+8
@@ -303,6 +303,7 @@ def gemma4_config() -> ParserEngineConfig:
303 "TOOL_START": TOOL_CALL_START,
304 "TOOL_END": TOOL_CALL_END,
305 "CALL_PREFIX": "call:",
306 + "COLON": ":",
307 "OPEN_BRACE": "{",
308 "OPEN_PAREN": "(",
309 },
@@ -348,6 +349,13 @@ def gemma4_config() -> ParserEngineConfig:
349 ParserState.TOOL_NAME,
350 (),
351 ),
352 + # Bare opener: some checkpoints emit "<|tool_call>:name{...}"
353 + # without the "call" prefix. Match the colon directly so the
354 + # tool call is not silently dropped.
355 + (ParserState.TOOL_PREAMBLE, "COLON"): Transition(
356 + ParserState.TOOL_NAME,
357 + (),
358 + ),
359 (ParserState.TOOL_NAME, "OPEN_BRACE"): Transition(
360 ParserState.TOOL_ARGS,
361 (),