A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 6, 13:15 UTC
fix(glm47): improve tool call parsing and content normalization (#37386)
Signed-off-by: karanb192 <karan@example.com> Co-authored-by: karanb192 <karan@example.com>
Karan Bansal committed
Mar 18, 2026 at 13:42 UTC
fad09e8a1f51b31eba1f42ff5d651256c77a734d
4 files changed
+193
-6
tests/tool_parsers/test_glm47_moe_tool_parser.py
new
+168
@@ -0,0 +1,168 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+# ruff: noqa: E501
4
+"""Tests for the GLM-4.7 tool call parser."""
5
+
6
+import json
7
+from unittest.mock import Mock
8
+
9
+import pytest
10
+
11
+from vllm.entrypoints.openai.chat_completion.protocol import (
12
+ ChatCompletionRequest,
13
+ ChatCompletionToolsParam,
14
+ FunctionDefinition,
15
+)
16
+from vllm.tokenizers import get_tokenizer
17
+from vllm.tool_parsers.glm47_moe_tool_parser import Glm47MoeModelToolParser
18
+
19
+MODEL = "zai-org/GLM-4.5"
20
+
21
+
22
+@pytest.fixture(scope="module")
23
+def glm47_tokenizer():
24
+ return get_tokenizer(tokenizer_name=MODEL)
25
+
26
+
27
+@pytest.fixture
28
+def glm47_tool_parser(glm47_tokenizer):
29
+ return Glm47MoeModelToolParser(glm47_tokenizer)
30
+
31
+
32
+@pytest.fixture
33
+def mock_request() -> ChatCompletionRequest:
34
+ request = Mock(spec=ChatCompletionRequest)
35
+ request.tools = [
36
+ ChatCompletionToolsParam(
37
+ function=FunctionDefinition(name="get_current_date", parameters={}),
38
+ ),
39
+ ChatCompletionToolsParam(
40
+ function=FunctionDefinition(
41
+ name="get_weather",
42
+ parameters={
43
+ "type": "object",
44
+ "properties": {
45
+ "city": {"type": "string"},
46
+ "date": {"type": "string"},
47
+ },
48
+ },
49
+ ),
50
+ ),
51
+ ]
52
+ request.tool_choice = "auto"
53
+ return request
54
+
55
+
56
+class TestGlm47ExtractToolCalls:
57
+ def test_no_tool_call(self, glm47_tool_parser, mock_request):
58
+ out = "This is a plain response."
59
+ r = glm47_tool_parser.extract_tool_calls(out, request=mock_request)
60
+ assert not r.tools_called
61
+ assert r.content == out
62
+
63
+ def test_zero_arg_inline(self, glm47_tool_parser, mock_request):
64
+ out = "<tool_call>get_current_date</tool_call>"
65
+ r = glm47_tool_parser.extract_tool_calls(out, request=mock_request)
66
+ assert r.tools_called
67
+ assert r.tool_calls[0].function.name == "get_current_date"
68
+ assert json.loads(r.tool_calls[0].function.arguments) == {}
69
+ assert r.content is None
70
+
71
+ def test_zero_arg_newline(self, glm47_tool_parser, mock_request):
72
+ out = "<tool_call>get_current_date\n</tool_call>"
73
+ r = glm47_tool_parser.extract_tool_calls(out, request=mock_request)
74
+ assert r.tools_called
75
+ assert r.tool_calls[0].function.name == "get_current_date"
76
+
77
+ def test_args_same_line(self, glm47_tool_parser, mock_request):
78
+ out = "<tool_call>get_weather<arg_key>city</arg_key><arg_value>Beijing</arg_value></tool_call>"
79
+ r = glm47_tool_parser.extract_tool_calls(out, request=mock_request)
80
+ assert r.tools_called
81
+ assert json.loads(r.tool_calls[0].function.arguments) == {"city": "Beijing"}
82
+
83
+ def test_args_with_newlines(self, glm47_tool_parser, mock_request):
84
+ out = "<tool_call>get_weather\n<arg_key>city</arg_key>\n<arg_value>Beijing</arg_value>\n</tool_call>"
85
+ r = glm47_tool_parser.extract_tool_calls(out, request=mock_request)
86
+ assert r.tools_called
87
+ assert json.loads(r.tool_calls[0].function.arguments) == {"city": "Beijing"}
88
+
89
+ def test_content_before(self, glm47_tool_parser, mock_request):
90
+ out = "Checking.<tool_call>get_current_date</tool_call>"
91
+ r = glm47_tool_parser.extract_tool_calls(out, request=mock_request)
92
+ assert r.tools_called
93
+ assert r.content == "Checking."
94
+
95
+ def test_multiple(self, glm47_tool_parser, mock_request):
96
+ out = (
97
+ "<tool_call>get_weather<arg_key>city</arg_key><arg_value>Beijing</arg_value></tool_call>"
98
+ "<tool_call>get_weather<arg_key>city</arg_key><arg_value>Shanghai</arg_value></tool_call>"
99
+ )
100
+ r = glm47_tool_parser.extract_tool_calls(out, request=mock_request)
101
+ assert len(r.tool_calls) == 2
102
+
103
+ def test_empty_content_none(self, glm47_tool_parser, mock_request):
104
+ out = "<tool_call>get_current_date</tool_call>"
105
+ r = glm47_tool_parser.extract_tool_calls(out, request=mock_request)
106
+ assert r.content is None
107
+
108
+ def test_whitespace_content_none(self, glm47_tool_parser, mock_request):
109
+ out = " \n <tool_call>get_current_date</tool_call>"
110
+ r = glm47_tool_parser.extract_tool_calls(out, request=mock_request)
111
+ assert r.content is None
112
+
113
+
114
+def _reset(parser):
115
+ parser._buffer = ""
116
+ parser._in_tool_call = False
117
+ parser.current_tool_name_sent = False
118
+ parser._current_tool_name = None
119
+ parser._pending_key = None
120
+ parser._streaming_string_value = False
121
+ parser.prev_tool_call_arr = []
122
+ parser.current_tool_id = -1
123
+ parser.streamed_args_for_tool = []
124
+ parser._tool_call_ids = []
125
+ parser._args_started = []
126
+ parser._args_closed = []
127
+ parser._seen_keys = []
128
+
129
+
130
+class TestGlm47Streaming:
131
+ def test_no_args(self, glm47_tool_parser, mock_request):
132
+ _reset(glm47_tool_parser)
133
+ for chunk in ["<tool_call>", "get_current_date", "</tool_call>"]:
134
+ glm47_tool_parser.extract_tool_calls_streaming(
135
+ previous_text="",
136
+ current_text="",
137
+ delta_text=chunk,
138
+ previous_token_ids=[],
139
+ current_token_ids=[],
140
+ delta_token_ids=[],
141
+ request=mock_request,
142
+ )
143
+ assert len(glm47_tool_parser.prev_tool_call_arr) >= 1
144
+
145
+ def test_with_args(self, glm47_tool_parser, mock_request):
146
+ _reset(glm47_tool_parser)
147
+ # Split chunks so that the incremental string streaming path
148
+ # processes the value, its closing tag, and the tool-call closing
149
+ # tag in separate calls.
150
+ for chunk in [
151
+ "<tool_call>",
152
+ "get_weather\n",
153
+ "<arg_key>city</arg_key>",
154
+ "<arg_value>",
155
+ "Beijing",
156
+ "</arg_value>",
157
+ "</tool_call>",
158
+ ]:
159
+ glm47_tool_parser.extract_tool_calls_streaming(
160
+ previous_text="",
161
+ current_text="",
162
+ delta_text=chunk,
163
+ previous_token_ids=[],
164
+ current_token_ids=[],
165
+ delta_token_ids=[],
166
+ request=mock_request,
167
+ )
168
+ assert glm47_tool_parser.prev_tool_call_arr[0]["arguments"]["city"] == "Beijing"
tests/tool_parsers/test_glm4_moe_tool_parser.py
+3
-3
@@ -107,7 +107,7 @@ def test_extract_tool_calls_no_tools(glm4_moe_tool_parser, mock_request):
107
)
108
)
109
],
110
- "",
110
+ None,
111
),
112
(
113
"""<tool_call>get_current_weather
@@ -152,7 +152,7 @@ def test_extract_tool_calls_no_tools(glm4_moe_tool_parser, mock_request):
152
)
153
),
154
],
155
- "",
155
+ None,
156
),
157
(
158
"""I'll help you check the weather. <tool_call>get_current_weather
@@ -202,7 +202,7 @@ def test_extract_tool_calls_no_tools(glm4_moe_tool_parser, mock_request):
202
)
203
)
204
],
205
- "",
205
+ None,
206
),
207
(
208
"""I will help you get the weather.<tool_call>get_weather
vllm/tool_parsers/glm47_moe_tool_parser.py
+16
-2
@@ -1,6 +1,16 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""
4
+GLM-4.7 Tool Call Parser.
5
6
+GLM-4.7 uses a slightly different tool call format compared to GLM-4.5:
7
+ - The function name may appear on the same line as ``<tool_call>`` without
8
+ a newline separator before the first ``<arg_key>``.
9
+ - Tool calls may have zero arguments
10
+ (e.g. ``<tool_call>func</tool_call>``).
11
+
12
+This parser overrides the parent regex patterns to handle both formats.
13
+"""
14
15
import regex as re
16
@@ -14,10 +24,14 @@ logger = init_logger(__name__)
24
class Glm47MoeModelToolParser(Glm4MoeModelToolParser):
25
def __init__(self, tokenizer: TokenizerLike):
26
super().__init__(tokenizer)
27
+ # GLM-4.7 format: <tool_call>func_name[<arg_key>...]*</tool_call>
28
+ # The function name can be followed by a newline, whitespace, or
29
+ # directly by <arg_key> tags (no separator). The arg section is
30
+ # optional so that zero-argument calls are supported.
31
self.func_detail_regex = re.compile(
18
- r"<tool_call>(.*?)(<arg_key>.*?)?</tool_call>", re.DOTALL
32
+ r"<tool_call>\s*(\S+?)\s*(<arg_key>.*)?</tool_call>", re.DOTALL
33
)
34
self.func_arg_regex = re.compile(
21
- r"<arg_key>(.*?)</arg_key>(?:\\n|\s)*<arg_value>(.*?)</arg_value>",
35
+ r"<arg_key>(.*?)</arg_key>\s*<arg_value>(.*?)</arg_value>",
36
re.DOTALL,
37
)
vllm/tool_parsers/glm4_moe_tool_parser.py
+6
-1
@@ -206,7 +206,12 @@ class Glm4MoeModelToolParser(ToolParser):
206
)
207
else:
208
if len(tool_calls) > 0:
209
- content = model_output[: model_output.find(self.tool_calls_start_token)]
209
+ content: str | None = model_output[
210
+ : model_output.find(self.tool_calls_start_token)
211
+ ]
212
+ # Normalize empty/whitespace-only content to None
213
+ if not content or not content.strip():
214
+ content = None
215
return ExtractedToolCallInformation(
216
tools_called=True, tool_calls=tool_calls, content=content
217
)