| 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"} |