@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / fad09e8a1f
Fork 0

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 )