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