main
py 228 lines 6.92 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Compatibility tests for GLM-4.5 using the shared GLM XML parser."""
4
5 import json
6 from typing import Any, TypedDict
7
8 from tests.parser.engine.replay_harness import MockTokenizer
9 from vllm.entrypoints.openai.chat_completion.protocol import (
10 ChatCompletionRequest,
11 ChatCompletionToolsParam,
12 FunctionDefinition,
13 )
14 from vllm.tool_parsers import ToolParserManager
15 from vllm.tool_parsers.glm47_moe_tool_parser import Glm47MoeModelToolParser
16
17 MODEL = "zai-org/GLM-4.5"
18
19 _GLM_VOCAB = {
20 "<think>": 50,
21 "</think>": 51,
22 "<tool_call>": 60,
23 "</tool_call>": 61,
24 "<arg_key>": 62,
25 "</arg_key>": 63,
26 "<arg_value>": 64,
27 "</arg_value>": 65,
28 }
29
30
31 class _CollectedToolDelta(TypedDict):
32 name: str | None
33 args_fragments: list[str]
34
35
36 def _mock_tokenizer() -> MockTokenizer:
37 return MockTokenizer(vocab=_GLM_VOCAB, tokens=[])
38
39
40 def _tools() -> list[ChatCompletionToolsParam]:
41 return [
42 ChatCompletionToolsParam(
43 function=FunctionDefinition(
44 name="get_current_weather",
45 parameters={
46 "type": "object",
47 "properties": {
48 "city": {"type": "string"},
49 "state": {"type": "string"},
50 "unit": {"type": "string"},
51 },
52 },
53 ),
54 ),
55 ChatCompletionToolsParam(
56 function=FunctionDefinition(
57 name="calculate",
58 parameters={
59 "type": "object",
60 "properties": {
61 "operation": {"type": "string"},
62 "a": {"type": "number"},
63 "b": {"type": "number"},
64 "enabled": {"type": "boolean"},
65 },
66 },
67 ),
68 ),
69 ChatCompletionToolsParam(
70 function=FunctionDefinition(name="get_time", parameters={}),
71 ),
72 ]
73
74
75 def _request(tools: list[ChatCompletionToolsParam]) -> ChatCompletionRequest:
76 return ChatCompletionRequest(model=MODEL, messages=[], tools=tools)
77
78
79 def _parser(tools: list[ChatCompletionToolsParam] | None = None):
80 return Glm47MoeModelToolParser(_mock_tokenizer(), tools=tools)
81
82
83 def _collect_tool_deltas(deltas: Any) -> dict[int, _CollectedToolDelta]:
84 calls: dict[int, _CollectedToolDelta] = {}
85 for delta in deltas:
86 if delta is None or not delta.tool_calls:
87 continue
88 for tool_call in delta.tool_calls:
89 entry = calls.setdefault(
90 tool_call.index,
91 {"name": None, "args_fragments": []},
92 )
93 function = tool_call.function
94 if function is None:
95 continue
96 if isinstance(function, dict):
97 name = function.get("name")
98 arguments = function.get("arguments")
99 else:
100 name = function.name
101 arguments = function.arguments
102 if isinstance(name, str) and name:
103 entry["name"] = name
104 if isinstance(arguments, str) and arguments:
105 entry["args_fragments"].append(arguments)
106 return calls
107
108
109 def test_glm45_uses_shared_glm47_parser():
110 assert ToolParserManager.get_tool_parser("glm45") is Glm47MoeModelToolParser
111 assert ToolParserManager.get_tool_parser("glm47") is Glm47MoeModelToolParser
112
113
114 def test_extract_tool_calls_with_glm45_newline_format():
115 tools = _tools()
116 parser = _parser(tools)
117 model_output = """I'll check it. <tool_call>get_current_weather
118 <arg_key>city</arg_key>
119 <arg_value>Dallas</arg_value>
120 <arg_key>state</arg_key>
121 <arg_value>TX</arg_value>
122 <arg_key>unit</arg_key>
123 <arg_value>fahrenheit</arg_value>
124 </tool_call>"""
125
126 extracted = parser.extract_tool_calls(model_output, request=_request(tools))
127
128 assert extracted.tools_called
129 assert extracted.content == "I'll check it."
130 assert len(extracted.tool_calls) == 1
131 tool_call = extracted.tool_calls[0]
132 assert tool_call.function.name == "get_current_weather"
133 assert json.loads(tool_call.function.arguments) == {
134 "city": "Dallas",
135 "state": "TX",
136 "unit": "fahrenheit",
137 }
138
139
140 def test_extract_multiple_tool_calls_with_glm45_newline_format():
141 tools = _tools()
142 parser = _parser(tools)
143 model_output = """<tool_call>get_current_weather
144 <arg_key>city</arg_key><arg_value>Dallas</arg_value>
145 </tool_call>
146 <tool_call>get_current_weather
147 <arg_key>city</arg_key><arg_value>Orlando</arg_value>
148 </tool_call>"""
149
150 extracted = parser.extract_tool_calls(model_output, request=_request(tools))
151
152 assert extracted.tools_called
153 assert [tc.function.name for tc in extracted.tool_calls] == [
154 "get_current_weather",
155 "get_current_weather",
156 ]
157 assert [
158 json.loads(tc.function.arguments)["city"] for tc in extracted.tool_calls
159 ] == ["Dallas", "Orlando"]
160
161
162 def test_extract_tool_calls_coerces_schema_types():
163 tools = _tools()
164 parser = _parser(tools)
165 model_output = """<tool_call>calculate
166 <arg_key>operation</arg_key><arg_value>add</arg_value>
167 <arg_key>a</arg_key><arg_value>42</arg_value>
168 <arg_key>b</arg_key><arg_value>3.14</arg_value>
169 <arg_key>enabled</arg_key><arg_value>true</arg_value>
170 </tool_call>"""
171
172 extracted = parser.extract_tool_calls(model_output, request=_request(tools))
173
174 assert extracted.tools_called
175 assert json.loads(extracted.tool_calls[0].function.arguments) == {
176 "operation": "add",
177 "a": 42,
178 "b": 3.14,
179 "enabled": True,
180 }
181
182
183 def test_extract_zero_argument_tool_call_with_glm45_newline_format():
184 tools = _tools()
185 parser = _parser(tools)
186
187 extracted = parser.extract_tool_calls(
188 "<tool_call>get_time\n</tool_call>",
189 request=_request(tools),
190 )
191
192 assert extracted.tools_called
193 assert extracted.tool_calls[0].function.name == "get_time"
194 assert json.loads(extracted.tool_calls[0].function.arguments) == {}
195
196
197 def test_streaming_tool_call_with_glm45_newline_format():
198 tools = _tools()
199 parser = _parser(tools)
200 request = _request(tools)
201 chunks = [
202 "<tool_call>",
203 "get_current_weather\n",
204 "<arg_key>city</arg_key>",
205 "<arg_value>Bei",
206 "jing</arg_value>",
207 "</tool_call>",
208 ]
209 deltas = []
210 current_text = ""
211
212 for chunk in chunks:
213 current_text += chunk
214 deltas.append(
215 parser.extract_tool_calls_streaming(
216 previous_text="",
217 current_text=current_text,
218 delta_text=chunk,
219 previous_token_ids=[],
220 current_token_ids=[],
221 delta_token_ids=[],
222 request=request,
223 )
224 )
225
226 calls = _collect_tool_deltas(deltas)
227 assert calls[0]["name"] == "get_current_weather"
228 assert json.loads("".join(calls[0]["args_fragments"])) == {"city": "Beijing"}