diff --git a/tests/tool_parsers/test_glm47_moe_tool_parser.py b/tests/tool_parsers/test_glm47_moe_tool_parser.py
new file mode 100644
index 000000000..c7170e675
--- /dev/null
+++ b/tests/tool_parsers/test_glm47_moe_tool_parser.py
@@ -0,0 +1,168 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+# ruff: noqa: E501
+"""Tests for the GLM-4.7 tool call parser."""
+
+import json
+from unittest.mock import Mock
+
+import pytest
+
+from vllm.entrypoints.openai.chat_completion.protocol import (
+ ChatCompletionRequest,
+ ChatCompletionToolsParam,
+ FunctionDefinition,
+)
+from vllm.tokenizers import get_tokenizer
+from vllm.tool_parsers.glm47_moe_tool_parser import Glm47MoeModelToolParser
+
+MODEL = "zai-org/GLM-4.5"
+
+
+@pytest.fixture(scope="module")
+def glm47_tokenizer():
+ return get_tokenizer(tokenizer_name=MODEL)
+
+
+@pytest.fixture
+def glm47_tool_parser(glm47_tokenizer):
+ return Glm47MoeModelToolParser(glm47_tokenizer)
+
+
+@pytest.fixture
+def mock_request() -> ChatCompletionRequest:
+ request = Mock(spec=ChatCompletionRequest)
+ request.tools = [
+ ChatCompletionToolsParam(
+ function=FunctionDefinition(name="get_current_date", parameters={}),
+ ),
+ ChatCompletionToolsParam(
+ function=FunctionDefinition(
+ name="get_weather",
+ parameters={
+ "type": "object",
+ "properties": {
+ "city": {"type": "string"},
+ "date": {"type": "string"},
+ },
+ },
+ ),
+ ),
+ ]
+ request.tool_choice = "auto"
+ return request
+
+
+class TestGlm47ExtractToolCalls:
+ def test_no_tool_call(self, glm47_tool_parser, mock_request):
+ out = "This is a plain response."
+ r = glm47_tool_parser.extract_tool_calls(out, request=mock_request)
+ assert not r.tools_called
+ assert r.content == out
+
+ def test_zero_arg_inline(self, glm47_tool_parser, mock_request):
+ out = "get_current_date"
+ r = glm47_tool_parser.extract_tool_calls(out, request=mock_request)
+ assert r.tools_called
+ assert r.tool_calls[0].function.name == "get_current_date"
+ assert json.loads(r.tool_calls[0].function.arguments) == {}
+ assert r.content is None
+
+ def test_zero_arg_newline(self, glm47_tool_parser, mock_request):
+ out = "get_current_date\n"
+ r = glm47_tool_parser.extract_tool_calls(out, request=mock_request)
+ assert r.tools_called
+ assert r.tool_calls[0].function.name == "get_current_date"
+
+ def test_args_same_line(self, glm47_tool_parser, mock_request):
+ out = "get_weathercityBeijing"
+ r = glm47_tool_parser.extract_tool_calls(out, request=mock_request)
+ assert r.tools_called
+ assert json.loads(r.tool_calls[0].function.arguments) == {"city": "Beijing"}
+
+ def test_args_with_newlines(self, glm47_tool_parser, mock_request):
+ out = "get_weather\ncity\nBeijing\n"
+ r = glm47_tool_parser.extract_tool_calls(out, request=mock_request)
+ assert r.tools_called
+ assert json.loads(r.tool_calls[0].function.arguments) == {"city": "Beijing"}
+
+ def test_content_before(self, glm47_tool_parser, mock_request):
+ out = "Checking.get_current_date"
+ r = glm47_tool_parser.extract_tool_calls(out, request=mock_request)
+ assert r.tools_called
+ assert r.content == "Checking."
+
+ def test_multiple(self, glm47_tool_parser, mock_request):
+ out = (
+ "get_weathercityBeijing"
+ "get_weathercityShanghai"
+ )
+ r = glm47_tool_parser.extract_tool_calls(out, request=mock_request)
+ assert len(r.tool_calls) == 2
+
+ def test_empty_content_none(self, glm47_tool_parser, mock_request):
+ out = "get_current_date"
+ r = glm47_tool_parser.extract_tool_calls(out, request=mock_request)
+ assert r.content is None
+
+ def test_whitespace_content_none(self, glm47_tool_parser, mock_request):
+ out = " \n get_current_date"
+ r = glm47_tool_parser.extract_tool_calls(out, request=mock_request)
+ assert r.content is None
+
+
+def _reset(parser):
+ parser._buffer = ""
+ parser._in_tool_call = False
+ parser.current_tool_name_sent = False
+ parser._current_tool_name = None
+ parser._pending_key = None
+ parser._streaming_string_value = False
+ parser.prev_tool_call_arr = []
+ parser.current_tool_id = -1
+ parser.streamed_args_for_tool = []
+ parser._tool_call_ids = []
+ parser._args_started = []
+ parser._args_closed = []
+ parser._seen_keys = []
+
+
+class TestGlm47Streaming:
+ def test_no_args(self, glm47_tool_parser, mock_request):
+ _reset(glm47_tool_parser)
+ for chunk in ["", "get_current_date", ""]:
+ glm47_tool_parser.extract_tool_calls_streaming(
+ previous_text="",
+ current_text="",
+ delta_text=chunk,
+ previous_token_ids=[],
+ current_token_ids=[],
+ delta_token_ids=[],
+ request=mock_request,
+ )
+ assert len(glm47_tool_parser.prev_tool_call_arr) >= 1
+
+ def test_with_args(self, glm47_tool_parser, mock_request):
+ _reset(glm47_tool_parser)
+ # Split chunks so that the incremental string streaming path
+ # processes the value, its closing tag, and the tool-call closing
+ # tag in separate calls.
+ for chunk in [
+ "",
+ "get_weather\n",
+ "city",
+ "",
+ "Beijing",
+ "",
+ "",
+ ]:
+ glm47_tool_parser.extract_tool_calls_streaming(
+ previous_text="",
+ current_text="",
+ delta_text=chunk,
+ previous_token_ids=[],
+ current_token_ids=[],
+ delta_token_ids=[],
+ request=mock_request,
+ )
+ assert glm47_tool_parser.prev_tool_call_arr[0]["arguments"]["city"] == "Beijing"
diff --git a/tests/tool_parsers/test_glm4_moe_tool_parser.py b/tests/tool_parsers/test_glm4_moe_tool_parser.py
index 9ee9ea008..213cc75db 100644
--- a/tests/tool_parsers/test_glm4_moe_tool_parser.py
+++ b/tests/tool_parsers/test_glm4_moe_tool_parser.py
@@ -107,7 +107,7 @@ def test_extract_tool_calls_no_tools(glm4_moe_tool_parser, mock_request):
)
)
],
- "",
+ None,
),
(
"""get_current_weather
@@ -152,7 +152,7 @@ def test_extract_tool_calls_no_tools(glm4_moe_tool_parser, mock_request):
)
),
],
- "",
+ None,
),
(
"""I'll help you check the weather. get_current_weather
@@ -202,7 +202,7 @@ def test_extract_tool_calls_no_tools(glm4_moe_tool_parser, mock_request):
)
)
],
- "",
+ None,
),
(
"""I will help you get the weather.get_weather
diff --git a/vllm/tool_parsers/glm47_moe_tool_parser.py b/vllm/tool_parsers/glm47_moe_tool_parser.py
index ae42a640d..8c72342d7 100644
--- a/vllm/tool_parsers/glm47_moe_tool_parser.py
+++ b/vllm/tool_parsers/glm47_moe_tool_parser.py
@@ -1,6 +1,16 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""
+GLM-4.7 Tool Call Parser.
+GLM-4.7 uses a slightly different tool call format compared to GLM-4.5:
+ - The function name may appear on the same line as ```` without
+ a newline separator before the first ````.
+ - Tool calls may have zero arguments
+ (e.g. ``func``).
+
+This parser overrides the parent regex patterns to handle both formats.
+"""
import regex as re
@@ -14,10 +24,14 @@ logger = init_logger(__name__)
class Glm47MoeModelToolParser(Glm4MoeModelToolParser):
def __init__(self, tokenizer: TokenizerLike):
super().__init__(tokenizer)
+ # GLM-4.7 format: func_name[...]*
+ # The function name can be followed by a newline, whitespace, or
+ # directly by tags (no separator). The arg section is
+ # optional so that zero-argument calls are supported.
self.func_detail_regex = re.compile(
- r"(.*?)(.*?)?", re.DOTALL
+ r"\s*(\S+?)\s*(.*)?", re.DOTALL
)
self.func_arg_regex = re.compile(
- r"(.*?)(?:\\n|\s)*(.*?)",
+ r"(.*?)\s*(.*?)",
re.DOTALL,
)
diff --git a/vllm/tool_parsers/glm4_moe_tool_parser.py b/vllm/tool_parsers/glm4_moe_tool_parser.py
index 2a03c8583..28d86b68b 100644
--- a/vllm/tool_parsers/glm4_moe_tool_parser.py
+++ b/vllm/tool_parsers/glm4_moe_tool_parser.py
@@ -206,7 +206,12 @@ class Glm4MoeModelToolParser(ToolParser):
)
else:
if len(tool_calls) > 0:
- content = model_output[: model_output.find(self.tool_calls_start_token)]
+ content: str | None = model_output[
+ : model_output.find(self.tool_calls_start_token)
+ ]
+ # Normalize empty/whitespace-only content to None
+ if not content or not content.strip():
+ content = None
return ExtractedToolCallInformation(
tools_called=True, tool_calls=tool_calls, content=content
)