[Frontend] Add Streaming Parser Engine and new GLM4.7/GLM5.1/GLM5.2 Parser (#45915)

Signed-off-by: chaunceyjiang <chaunceyjiang@gmail.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Chauncey committed Jun 19, 2026 at 00:42 UTC 6c379b9e5439ae305913e4a87ebf2b2e816072b4
11 files changed +534 -1948
tests/parser/engine/trace_builder.py
+76
@@ -30,6 +30,7 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
30 )
31 from vllm.parser.engine.registered_adapters import (
32 Gemma4Parser,
33 + Glm47MoeParser,
34 MinimaxM2Parser,
35 NemotronV3Parser,
36 Qwen3Parser,
@@ -571,6 +572,80 @@ def _build_nemotron_v3(scenario: Scenario, validate: bool = True) -> Sample:
572 )
573
574
575 +# ── GLM-4.7 MoE (XML tool format, starts in REASONING) ──────────────
576 +
577 +_GLM47_MOE_VOCAB: dict[str, int] = {
578 + "<think>": 50,
579 + "</think>": 51,
580 + "<tool_call>": 60,
581 + "</tool_call>": 61,
582 + "<arg_key>": 62,
583 + "</arg_key>": 63,
584 + "<arg_value>": 64,
585 + "</arg_value>": 65,
586 +}
587 +
588 +
589 +def _glm47_moe_arg_value(value: Any) -> str:
590 + if isinstance(value, bool):
591 + return "true" if value else "false"
592 + if isinstance(value, (int, float)):
593 + return str(value)
594 + if isinstance(value, str):
595 + return value
596 + return json.dumps(value, ensure_ascii=False)
597 +
598 +
599 +def _glm47_moe_tool_segments(tc: ToolCallSpec) -> list[tuple[str, bool]]:
600 + segs: list[tuple[str, bool]] = [
601 + ("<tool_call>", True),
602 + (tc.name, False),
603 + ]
604 + for key, value in tc.arguments.items():
605 + segs.extend(
606 + [
607 + ("<arg_key>", True),
608 + (key, False),
609 + ("</arg_key>", True),
610 + ("<arg_value>", True),
611 + (_glm47_moe_arg_value(value), False),
612 + ("</arg_value>", True),
613 + ]
614 + )
615 + segs.append(("</tool_call>", True))
616 + return segs
617 +
618 +
619 +def _glm47_moe_segments(scenario: Scenario) -> list[tuple[str, bool]]:
620 + segs: list[tuple[str, bool]] = []
621 + if scenario.reasoning is not None:
622 + segs.append((scenario.reasoning, False))
623 + if scenario.content is not None or scenario.tool_calls:
624 + segs.append(("</think>", True))
625 + if scenario.content is not None:
626 + segs.append((scenario.content, False))
627 + if scenario.tool_calls:
628 + for tc in scenario.tool_calls:
629 + segs.extend(_glm47_moe_tool_segments(tc))
630 + return segs
631 +
632 +
633 +def _build_glm47_moe(scenario: Scenario, validate: bool = True) -> Sample:
634 + sample = _make_sample(
635 + sample_id=f"glm47_moe-{scenario.id}",
636 + description=scenario.description,
637 + vocab=_GLM47_MOE_VOCAB,
638 + segments=_glm47_moe_segments(scenario),
639 + expected_reasoning=scenario.reasoning if scenario.reasoning is not None else "",
640 + expected_content=_qwen3_expected_content(scenario),
641 + expected_tool_calls=_expected_tc(scenario),
642 + tools=_expected_tools(scenario),
643 + )
644 + if validate:
645 + _validate_sample(sample, Glm47MoeParser)
646 + return sample
647 +
648 +
649 # ── Registry and public API ──────────────────────────────────────────
650
651 _BUILDERS: dict[str, Any] = {
@@ -578,6 +653,7 @@ _BUILDERS: dict[str, Any] = {
653 "gemma4": _build_gemma4,
654 "minimax_m2": _build_minimax_m2,
655 "nemotron_v3": _build_nemotron_v3,
656 + "glm47_moe": _build_glm47_moe,
657 }
658
659
tests/reasoning/test_glm4_moe_reasoning_parser.py
+31 -7
@@ -11,7 +11,7 @@ parser_name = "glm45"
11 start_token = "<think>"
12 end_token = "</think>"
13
14 -REASONING_MODEL_NAME = "zai-org/GLM-4.5"
14 +REASONING_MODEL_NAME = "zai-org/GLM-4.7"
15
16
17 @pytest.fixture(scope="module")
@@ -35,18 +35,32 @@ WITH_THINK_STREAM = {
35
36 WITHOUT_THINK = {
37 "output": "This is the rest",
38 - "reasoning": None,
39 - "content": "This is the rest",
38 + "reasoning": "This is the rest",
39 + "content": None,
40 "is_reasoning_end": False,
41 }
42
43 WITHOUT_THINK_STREAM = {
44 "output": "This is the rest",
45 - "reasoning": None,
46 - "content": "This is the rest",
45 + "reasoning": "This is the rest",
46 + "content": None,
47 "is_reasoning_end": False,
48 }
49
50 +WITHOUT_OPEN_THINK = {
51 + "output": "This is a reasoning section</think>This is the rest",
52 + "reasoning": "This is a reasoning section",
53 + "content": "This is the rest",
54 + "is_reasoning_end": True,
55 +}
56 +
57 +WITHOUT_OPEN_THINK_STREAM = {
58 + "output": "This is a reasoning section</think>This is the rest",
59 + "reasoning": "This is a reasoning section",
60 + "content": "This is the rest",
61 + "is_reasoning_end": True,
62 +}
63 +
64 COMPLETE_REASONING = {
65 "output": "<think>This is a reasoning section</think>",
66 "reasoning": "This is a reasoning section",
@@ -61,8 +75,8 @@ MULTILINE_REASONING = {
75 }
76 ONLY_OPEN_TAG = {
77 "output": "<think>This is a reasoning section",
64 - "reasoning": None,
65 - "content": "<think>This is a reasoning section",
78 + "reasoning": "This is a reasoning section",
79 + "content": None,
80 "is_reasoning_end": False,
81 }
82
@@ -94,6 +108,16 @@ TEST_CASES = [
108 WITHOUT_THINK_STREAM,
109 id="without_think_stream",
110 ),
111 + pytest.param(
112 + False,
113 + WITHOUT_OPEN_THINK,
114 + id="without_open_think",
115 + ),
116 + pytest.param(
117 + True,
118 + WITHOUT_OPEN_THINK_STREAM,
119 + id="without_open_think_stream",
120 + ),
121 pytest.param(
122 False,
123 COMPLETE_REASONING,
tests/tool_parsers/test_glm47_moe_tool_parser.py
+31 -5
@@ -16,7 +16,7 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
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"
19 +MODEL = "zai-org/GLM-4.7"
20
21
22 @pytest.fixture(scope="module")
@@ -136,9 +136,10 @@ class TestGlm47Streaming:
136 _reset(glm47_tool_parser)
137 chunks = ["<tool_call>", "get_current_date", "</tool_call>"]
138 current_text = ""
139 + deltas = []
140 for chunk in chunks:
141 current_text += chunk
141 - glm47_tool_parser.extract_tool_calls_streaming(
142 + delta = glm47_tool_parser.extract_tool_calls_streaming(
143 previous_text="",
144 current_text=current_text,
145 delta_text=chunk,
@@ -147,7 +148,23 @@ class TestGlm47Streaming:
148 delta_token_ids=[],
149 request=mock_request,
150 )
150 - assert len(glm47_tool_parser.prev_tool_call_arr) >= 1
151 + if delta:
152 + deltas.append(delta)
153 + tool_calls = [
154 + tool_call for delta in deltas for tool_call in (delta.tool_calls or [])
155 + ]
156 + names = [
157 + tool_call.function.name
158 + for tool_call in tool_calls
159 + if tool_call.function and tool_call.function.name
160 + ]
161 + arguments = [
162 + tool_call.function.arguments
163 + for tool_call in tool_calls
164 + if tool_call.function and tool_call.function.arguments
165 + ]
166 + assert names == ["get_current_date"]
167 + assert "".join(arguments) == "{}"
168
169 def test_with_args(self, glm47_tool_parser, mock_request):
170 _reset(glm47_tool_parser)
@@ -161,9 +178,10 @@ class TestGlm47Streaming:
178 "</tool_call>",
179 ]
180 current_text = ""
181 + deltas = []
182 for chunk in chunks:
183 current_text += chunk
166 - glm47_tool_parser.extract_tool_calls_streaming(
184 + delta = glm47_tool_parser.extract_tool_calls_streaming(
185 previous_text="",
186 current_text=current_text,
187 delta_text=chunk,
@@ -172,5 +190,13 @@ class TestGlm47Streaming:
190 delta_token_ids=[],
191 request=mock_request,
192 )
175 - args = json.loads(glm47_tool_parser.prev_tool_call_arr[0]["arguments"])
193 + if delta:
194 + deltas.append(delta)
195 + arguments = [
196 + tool_call.function.arguments
197 + for delta in deltas
198 + for tool_call in (delta.tool_calls or [])
199 + if tool_call.function and tool_call.function.arguments
200 + ]
201 + args = json.loads("".join(arguments))
202 assert args["city"] == "Beijing"
tests/tool_parsers/test_glm4_moe_tool_parser.py
+147 -1404
@@ -1,1067 +1,57 @@
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
5 -from unittest.mock import Mock
6 -
7 -import pytest
8 -from openai.types.responses import FunctionTool
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 )
15 -from vllm.entrypoints.openai.engine.protocol import FunctionCall, ToolCall
16 -from vllm.tokenizers import get_tokenizer
17 -from vllm.tool_parsers.glm4_moe_tool_parser import (
18 - Glm4MoeModelToolParser,
19 -)
14 +from vllm.tool_parsers import ToolParserManager
15 +from vllm.tool_parsers.glm47_moe_tool_parser import Glm47MoeModelToolParser
16
21 -# Use a common model that is likely to be available
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
25 -@pytest.fixture(scope="module")
26 -def glm4_moe_tokenizer():
27 - return get_tokenizer(tokenizer_name=MODEL)
28 -
29 -
30 -@pytest.fixture
31 -def sample_tools():
32 - return [
33 - ChatCompletionToolsParam(
34 - function=FunctionDefinition(
35 - name="get_weather",
36 - parameters={"city": {"type": "string"}},
37 - ),
38 - ),
39 - ]
40 -
41 -
42 -@pytest.fixture
43 -def glm4_moe_tool_parser(glm4_moe_tokenizer, sample_tools):
44 - return Glm4MoeModelToolParser(glm4_moe_tokenizer, tools=sample_tools)
45 -
46 -
47 -@pytest.fixture
48 -def mock_request(sample_tools) -> ChatCompletionRequest:
49 - request = Mock(spec=ChatCompletionRequest)
50 - request.tools = sample_tools
51 - return request
52 -
53 -
54 -def assert_tool_calls(
55 - actual_tool_calls: list[ToolCall], expected_tool_calls: list[ToolCall]
56 -):
57 - assert len(actual_tool_calls) == len(expected_tool_calls)
58 -
59 - for actual_tool_call, expected_tool_call in zip(
60 - actual_tool_calls, expected_tool_calls
61 - ):
62 - assert isinstance(actual_tool_call.id, str)
63 - assert len(actual_tool_call.id) > 0
64 -
65 - assert actual_tool_call.type == "function"
66 - assert actual_tool_call.function.name == expected_tool_call.function.name
67 - # Compare arguments as JSON objects to handle formatting differences
68 - actual_args = json.loads(actual_tool_call.function.arguments)
69 - expected_args = json.loads(expected_tool_call.function.arguments)
70 - assert actual_args == expected_args
71 -
72 -
73 -def test_extract_tool_calls_no_tools(glm4_moe_tool_parser, mock_request):
74 - model_output = "This is a test"
75 - extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls(
76 - model_output, request=mock_request
77 - ) # type: ignore[arg-type]
78 - assert not extracted_tool_calls.tools_called
79 - assert extracted_tool_calls.tool_calls == []
80 - assert extracted_tool_calls.content == model_output
81 -
82 -
83 -@pytest.mark.parametrize(
84 - ids=[
85 - "single_tool_call",
86 - "multiple_tool_calls",
87 - "tool_call_with_content_before",
88 - "tool_call_with_mixed_args",
89 - "tool_call_with_chinese_content",
90 - ],
91 - argnames=["model_output", "expected_tool_calls", "expected_content"],
92 - argvalues=[
93 - (
94 - """<tool_call>get_current_weather
95 - <arg_key>city</arg_key>
96 - <arg_value>Dallas</arg_value>
97 - <arg_key>state</arg_key>
98 - <arg_value>TX</arg_value>
99 - <arg_key>unit</arg_key>
100 - <arg_value>fahrenheit</arg_value>
101 - </tool_call>""",
102 - [
103 - ToolCall(
104 - function=FunctionCall(
105 - name="get_current_weather",
106 - arguments=json.dumps(
107 - {
108 - "city": "Dallas",
109 - "state": "TX",
110 - "unit": "fahrenheit",
111 - }
112 - ),
113 - )
114 - )
115 - ],
116 - None,
117 - ),
118 - (
119 - """<tool_call>get_current_weather
120 - <arg_key>city</arg_key>
121 - <arg_value>Dallas</arg_value>
122 - <arg_key>state</arg_key>
123 - <arg_value>TX</arg_value>
124 - <arg_key>unit</arg_key>
125 - <arg_value>fahrenheit</arg_value>
126 - </tool_call>
127 - <tool_call>get_current_weather
128 - <arg_key>city</arg_key>
129 - <arg_value>Orlando</arg_value>
130 - <arg_key>state</arg_key>
131 - <arg_value>FL</arg_value>
132 - <arg_key>unit</arg_key>
133 - <arg_value>fahrenheit</arg_value>
134 - </tool_call>""",
135 - [
136 - ToolCall(
137 - function=FunctionCall(
138 - name="get_current_weather",
139 - arguments=json.dumps(
140 - {
141 - "city": "Dallas",
142 - "state": "TX",
143 - "unit": "fahrenheit",
144 - }
145 - ),
146 - )
147 - ),
148 - ToolCall(
149 - function=FunctionCall(
150 - name="get_current_weather",
151 - arguments=json.dumps(
152 - {
153 - "city": "Orlando",
154 - "state": "FL",
155 - "unit": "fahrenheit",
156 - }
157 - ),
158 - )
159 - ),
160 - ],
161 - None,
162 - ),
163 - (
164 - """I'll help you check the weather. <tool_call>get_current_weather
165 - <arg_key>city</arg_key>
166 - <arg_value>Seattle</arg_value>
167 - <arg_key>state</arg_key>
168 - <arg_value>WA</arg_value>
169 - <arg_key>unit</arg_key>
170 - <arg_value>celsius</arg_value>
171 - </tool_call>""",
172 - [
173 - ToolCall(
174 - function=FunctionCall(
175 - name="get_current_weather",
176 - arguments=json.dumps(
177 - {
178 - "city": "Seattle",
179 - "state": "WA",
180 - "unit": "celsius",
181 - }
182 - ),
183 - )
184 - )
185 - ],
186 - "I'll help you check the weather. ",
187 - ),
188 - (
189 - """<tool_call>get_current_weather
190 - <arg_key>city</arg_key>
191 - <arg_value>New York</arg_value>
192 - <arg_key>state</arg_key>
193 - <arg_value>NY</arg_value>
194 - <arg_key>unit</arg_key>
195 - <arg_value>celsius</arg_value>
196 - </tool_call>""",
197 - [
198 - ToolCall(
199 - function=FunctionCall(
200 - name="get_current_weather",
201 - arguments=json.dumps(
202 - {
203 - "city": "New York",
204 - "state": "NY",
205 - "unit": "celsius",
206 - }
207 - ),
208 - )
209 - )
210 - ],
211 - None,
212 - ),
213 - (
214 - """I will help you get the weather.<tool_call>get_weather
215 - <arg_key>city</arg_key>
216 - <arg_value>Beijing</arg_value>
217 - <arg_key>date</arg_key>
218 - <arg_value>2025-08-01</arg_value>
219 - </tool_call>""",
220 - [
221 - ToolCall(
222 - function=FunctionCall(
223 - name="get_weather",
224 - arguments=json.dumps(
225 - {
226 - "city": "Beijing",
227 - "date": "2025-08-01",
228 - }
229 - ),
230 - )
231 - )
232 - ],
233 - "I will help you get the weather.",
234 - ),
235 - ],
236 -)
237 -def test_extract_tool_calls(
238 - glm4_moe_tool_parser,
239 - mock_request,
240 - model_output,
241 - expected_tool_calls,
242 - expected_content,
243 -):
244 - extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls(
245 - model_output, request=mock_request
246 - ) # type: ignore[arg-type]
247 - assert extracted_tool_calls.tools_called
248 - assert_tool_calls(extracted_tool_calls.tool_calls, expected_tool_calls)
249 -
250 - assert extracted_tool_calls.content == expected_content
251 -
252 -
253 -def test_extract_tool_calls_with_thinking_tags(glm4_moe_tool_parser, mock_request):
254 - """Test tool extraction when thinking tags are present."""
255 - model_output = """<think>I want to get the weather.</think>
256 -
257 -I will help you get the weather.
258 -<tool_call>get_weather
259 -<arg_key>city</arg_key>
260 -<arg_value>Beijing</arg_value>
261 -<arg_key>date</arg_key>
262 -<arg_value>2025-08-01</arg_value>
263 -</tool_call>"""
264 -
265 - extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls(
266 - model_output, request=mock_request
267 - ) # type: ignore[arg-type]
268 -
269 - assert extracted_tool_calls.tools_called
270 - assert len(extracted_tool_calls.tool_calls) == 1
271 - assert extracted_tool_calls.tool_calls[0].function.name == "get_weather"
272 -
273 - expected_content = """<think>I want to get the weather.</think>
274 -
275 -I will help you get the weather.
276 -"""
277 - assert extracted_tool_calls.content == expected_content
278 -
279 -
280 -def test_extract_tool_calls_malformed_xml(glm4_moe_tool_parser, mock_request):
281 - """Test that malformed XML is handled gracefully."""
282 - model_output = """<tool_call>get_weather
283 -<arg_key>city</arg_key>
284 -<arg_value>Seattle</arg_value>
285 -<arg_key>incomplete_arg
286 -<arg_value>value</arg_value>
287 -</tool_call>"""
288 -
289 - extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls(
290 - model_output, request=mock_request
291 - ) # type: ignore[arg-type]
292 -
293 - # Should handle malformed XML gracefully
294 - # The parser should either extract what it can or return no tool calls
295 - # depending on how robust we want the parsing to be
296 - assert isinstance(extracted_tool_calls.tools_called, bool)
297 - assert isinstance(extracted_tool_calls.tool_calls, list)
298 -
299 -
300 -def test_extract_tool_calls_empty_arguments(glm4_moe_tool_parser, mock_request):
301 - """Test tool calls with no arguments."""
302 - model_output = """<tool_call>get_current_time
303 -</tool_call>"""
304 -
305 - extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls(
306 - model_output, request=mock_request
307 - ) # type: ignore[arg-type]
308 -
309 - assert extracted_tool_calls.tools_called
310 - assert len(extracted_tool_calls.tool_calls) == 1
311 - assert extracted_tool_calls.tool_calls[0].function.name == "get_current_time"
312 - # Empty arguments should result in empty JSON object
313 - assert extracted_tool_calls.tool_calls[0].function.arguments == "{}"
314 -
315 -
316 -def test_extract_tool_calls_mixed_content(glm4_moe_tool_parser, mock_request):
317 - """Test extraction with mixed content and multiple tool calls."""
318 - model_output = """I will help you get the weather info.
319 -
320 -<tool_call>get_weather
321 -<arg_key>city</arg_key>
322 -<arg_value>Beijing</arg_value>
323 -<arg_key>date</arg_key>
324 -<arg_value>2025-08-01</arg_value>
325 -</tool_call>
326 -
327 -meaningwhile, I will also check the weather in Shanghai.
328 -
329 -<tool_call>get_weather
330 -<arg_key>city</arg_key>
331 -<arg_value>Shanghai</arg_value>
332 -<arg_key>date</arg_key>
333 -<arg_value>2025-08-01</arg_value>
334 -</tool_call>"""
335 -
336 - extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls(
337 - model_output, request=mock_request
338 - ) # type: ignore[arg-type]
339 -
340 - assert extracted_tool_calls.tools_called
341 - assert len(extracted_tool_calls.tool_calls) == 2
342 -
343 - # Check first tool call
344 - assert extracted_tool_calls.tool_calls[0].function.name == "get_weather"
345 - args1 = json.loads(extracted_tool_calls.tool_calls[0].function.arguments)
346 - assert args1["city"] == "Beijing"
347 - assert args1["date"] == "2025-08-01"
348 -
349 - # Check second tool call
350 - assert extracted_tool_calls.tool_calls[1].function.name == "get_weather"
351 - args2 = json.loads(extracted_tool_calls.tool_calls[1].function.arguments)
352 - assert args2["city"] == "Shanghai"
353 - assert args2["date"] == "2025-08-01"
354 -
355 - # Content should be everything before the first tool call
356 - assert extracted_tool_calls.content == "I will help you get the weather info.\n\n"
357 -
358 -
359 -def test_streaming_basic_functionality(glm4_moe_tool_parser, mock_request):
360 - """Test basic streaming functionality."""
361 - _reset_streaming_state(glm4_moe_tool_parser)
362 -
363 - current_text = """<tool_call>get_weather
364 -<arg_key>city</arg_key>
365 -<arg_value>Beijing</arg_value>
366 -</tool_call>"""
367 -
368 - result = glm4_moe_tool_parser.extract_tool_calls_streaming(
369 - previous_text="",
370 - current_text=current_text,
371 - delta_text=current_text,
372 - previous_token_ids=[],
373 - current_token_ids=[],
374 - delta_token_ids=[],
375 - request=mock_request,
376 - )
377 -
378 - # Should return tool call with name and arguments in one shot
379 - assert result is not None
380 - assert result.tool_calls is not None
381 - assert len(result.tool_calls) >= 1
382 -
383 -
384 -def test_streaming_no_tool_calls(glm4_moe_tool_parser, mock_request):
385 - """Test streaming when there are no tool calls."""
386 - _reset_streaming_state(glm4_moe_tool_parser)
387 -
388 - current_text = "This is just regular text without any tool calls."
389 -
390 - result = glm4_moe_tool_parser.extract_tool_calls_streaming(
391 - previous_text="",
392 - current_text=current_text,
393 - delta_text=current_text,
394 - previous_token_ids=[],
395 - current_token_ids=[],
396 - delta_token_ids=[],
397 - request=mock_request,
398 - )
399 -
400 - # Should return content
401 - assert result is not None
402 - assert result.content == current_text
403 -
404 -
405 -def test_streaming_with_content_before_tool_calls(glm4_moe_tool_parser, mock_request):
406 - """Test streaming when there's content before tool calls."""
407 - _reset_streaming_state(glm4_moe_tool_parser)
408 -
409 - current_text = "I will help you get the weather.<tool_call>"
410 -
411 - result = glm4_moe_tool_parser.extract_tool_calls_streaming(
412 - previous_text="",
413 - current_text=current_text,
414 - delta_text=current_text,
415 - previous_token_ids=[],
416 - current_token_ids=[],
417 - delta_token_ids=[],
418 - request=mock_request,
419 - )
420 -
421 - # Should return content before the <tool_call> tag
422 - assert result is not None
423 - assert result.content == "I will help you get the weather."
424 -
425 -
426 -def test_extract_tool_calls_special_characters(glm4_moe_tool_parser, mock_request):
427 - """Test tool calls with special characters and unicode."""
428 - model_output = """<tool_call>send_message
429 -<arg_key>recipient</arg_key>
430 -<arg_value>Amy</arg_value>
431 -<arg_key>message</arg_key>
432 -<arg_value>It is a nice day</arg_value>
433 -<arg_key>priority</arg_key>
434 -<arg_value>high</arg_value>
435 -</tool_call>"""
436 -
437 - extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls(
438 - model_output, request=mock_request
439 - ) # type: ignore[arg-type]
440 -
441 - assert extracted_tool_calls.tools_called
442 - assert len(extracted_tool_calls.tool_calls) == 1
443 - assert extracted_tool_calls.tool_calls[0].function.name == "send_message"
444 -
445 - args = json.loads(extracted_tool_calls.tool_calls[0].function.arguments)
446 - assert args["recipient"] == "Amy"
447 - assert args["message"] == "It is a nice day"
448 - assert args["priority"] == "high"
449 -
450 -
451 -def test_extract_tool_calls_incomplete_tool_call(glm4_moe_tool_parser, mock_request):
452 - """Test incomplete tool calls (missing closing tag)."""
453 - model_output = """<tool_call>get_weather
454 -<arg_key>city</arg_key>
455 -<arg_value>Beijing</arg_value>
456 -<arg_key>date</arg_key>
457 -<arg_value>2025-08-01</arg_value>"""
458 -
459 - extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls(
460 - model_output, request=mock_request
461 - ) # type: ignore[arg-type]
462 -
463 - # Incomplete tool calls should not be extracted
464 - assert not extracted_tool_calls.tools_called
465 - assert extracted_tool_calls.tool_calls == []
466 - assert extracted_tool_calls.content == model_output
467 -
468 -
469 -def _reset_streaming_state(parser):
470 - """Helper to reset parser streaming state."""
471 - parser.current_tool_name_sent = False
472 - parser.prev_tool_call_arr = []
473 - parser.current_tool_id = -1
474 - parser.streamed_args_for_tool = []
475 - parser._tool_call_ids = []
476 - parser._sent_content_idx = 0
477 -
478 -
479 -def test_streaming_incremental_string_value(glm4_moe_tool_parser, mock_request):
480 - """Test incremental streaming of string argument values."""
481 - _reset_streaming_state(glm4_moe_tool_parser)
482 -
483 - # Simulate streaming a tool call chunk by chunk
484 - chunks = [
485 - "<tool_call>",
486 - "get_weather\n",
487 - "<arg_key>city</arg_key>",
488 - "<arg_value>",
489 - "Bei",
490 - "jing",
491 - "</arg_value>",
492 - "</tool_call>",
493 - ]
494 -
495 - collected_fragments = []
496 - current_text = ""
497 - for chunk in chunks:
498 - current_text += chunk
499 - result = glm4_moe_tool_parser.extract_tool_calls_streaming(
500 - previous_text="",
501 - current_text=current_text,
502 - delta_text=chunk,
503 - previous_token_ids=[],
504 - current_token_ids=[],
505 - delta_token_ids=[],
506 - request=mock_request,
507 - )
508 - if result is not None and result.tool_calls:
509 - for tc in result.tool_calls:
510 - func = tc.function
511 - if isinstance(func, dict):
512 - if func.get("arguments"):
513 - collected_fragments.append(func["arguments"])
514 - if func.get("name"):
515 - collected_fragments.append(f"name:{func['name']}")
516 - else:
517 - if func.arguments:
518 - collected_fragments.append(func.arguments)
519 - if func.name:
520 - collected_fragments.append(f"name:{func.name}")
521 -
522 - # Verify we got incremental streaming of the argument value
523 - assert len(collected_fragments) > 0
524 - # The fragments should include the tool name and argument pieces
525 - combined = "".join(collected_fragments)
526 - assert "get_weather" in combined or "name:get_weather" in combined
527 -
528 -
529 -def test_streaming_empty_tool_call(glm4_moe_tool_parser, mock_request):
530 - """Test that empty tool calls don't cause infinite loops."""
531 - _reset_streaming_state(glm4_moe_tool_parser)
532 -
533 - current_text = "<tool_call></tool_call>"
534 - result = glm4_moe_tool_parser.extract_tool_calls_streaming(
535 - previous_text="",
536 - current_text=current_text,
537 - delta_text=current_text,
538 - previous_token_ids=[],
539 - current_token_ids=[],
540 - delta_token_ids=[],
541 - request=mock_request,
542 - )
543 -
544 - # Should not hang and should return something (None or content)
545 - # The key is that this completes without hanging
546 - assert result is None or hasattr(result, "content") or hasattr(result, "tool_calls")
547 -
548 -
549 -def test_streaming_prev_tool_call_arr_updates(glm4_moe_tool_parser, mock_request):
550 - """Test that prev_tool_call_arr is populated incrementally."""
551 - _reset_streaming_state(glm4_moe_tool_parser)
552 -
553 - chunks = [
554 - "<tool_call>get_weather\n",
555 - "<arg_key>city</arg_key>",
556 - "<arg_value>Beijing</arg_value>",
557 - "</tool_call>",
558 - ]
559 -
560 - current_text = ""
561 - for chunk in chunks:
562 - current_text += chunk
563 - glm4_moe_tool_parser.extract_tool_calls_streaming(
564 - previous_text="",
565 - current_text=current_text,
566 - delta_text=chunk,
567 - previous_token_ids=[],
568 - current_token_ids=[],
569 - delta_token_ids=[],
570 - request=mock_request,
571 - )
572 -
573 - # After the tool call completes, prev_tool_call_arr should be populated
574 - assert len(glm4_moe_tool_parser.prev_tool_call_arr) == 1
575 - tool_entry = glm4_moe_tool_parser.prev_tool_call_arr[0]
576 - assert tool_entry.get("name") == "get_weather"
577 -
578 - # arguments is a JSON string in the re-parse approach
579 - args_str = tool_entry.get("arguments")
580 - assert isinstance(args_str, str), f"Expected str, got {type(args_str)}"
581 - parsed = json.loads(args_str)
582 - assert parsed["city"] == "Beijing"
583 -
584 - # streamed_args_for_tool should match prev_tool_call_arr arguments
585 - streamed = glm4_moe_tool_parser.streamed_args_for_tool[0]
586 - assert streamed == args_str
587 -
588 -
589 -def test_streaming_multiple_tool_calls_sequential(glm4_moe_tool_parser, mock_request):
590 - """Test streaming multiple sequential tool calls."""
591 - _reset_streaming_state(glm4_moe_tool_parser)
592 -
593 - chunks = [
594 - "<tool_call>get_weather\n",
595 - "<arg_key>city</arg_key>",
596 - "<arg_value>Beijing</arg_value>",
597 - "</tool_call>",
598 - "<tool_call>get_weather\n",
599 - "<arg_key>city</arg_key>",
600 - "<arg_value>Shanghai</arg_value>",
601 - "</tool_call>",
602 - ]
603 -
604 - current_text = ""
605 - for chunk in chunks:
606 - current_text += chunk
607 - glm4_moe_tool_parser.extract_tool_calls_streaming(
608 - previous_text="",
609 - current_text=current_text,
610 - delta_text=chunk,
611 - previous_token_ids=[],
612 - current_token_ids=[],
613 - delta_token_ids=[],
614 - request=mock_request,
615 - )
616 -
617 - # Should have two tool calls in prev_tool_call_arr
618 - assert len(glm4_moe_tool_parser.prev_tool_call_arr) == 2
619 - args0 = json.loads(glm4_moe_tool_parser.prev_tool_call_arr[0]["arguments"])
620 - args1 = json.loads(glm4_moe_tool_parser.prev_tool_call_arr[1]["arguments"])
621 - assert args0["city"] == "Beijing"
622 - assert args1["city"] == "Shanghai"
623 -
624 -
625 -def test_streaming_json_escape_in_string(glm4_moe_tool_parser, mock_request):
626 - """Test that special characters in string values are properly escaped."""
627 - _reset_streaming_state(glm4_moe_tool_parser)
628 -
629 - chunks = [
630 - "<tool_call>send_message\n",
631 - "<arg_key>message</arg_key>",
632 - '<arg_value>Hello "world"\nNew line</arg_value>',
633 - "</tool_call>",
634 - ]
635 -
636 - current_text = ""
637 - for chunk in chunks:
638 - current_text += chunk
639 - glm4_moe_tool_parser.extract_tool_calls_streaming(
640 - previous_text="",
641 - current_text=current_text,
642 - delta_text=chunk,
643 - previous_token_ids=[],
644 - current_token_ids=[],
645 - delta_token_ids=[],
646 - request=mock_request,
647 - )
648 -
649 - # The streamed_args_for_tool should contain valid JSON
650 - assert len(glm4_moe_tool_parser.streamed_args_for_tool) == 1
651 - args_json = glm4_moe_tool_parser.streamed_args_for_tool[0]
652 - parsed = json.loads(args_json)
653 - assert "message" in parsed
654 - assert '"' in parsed["message"] or "world" in parsed["message"]
655 -
656 -
657 -def test_streaming_long_content_incremental(glm4_moe_tokenizer):
658 - """Test incremental streaming of long content (Issue #32829).
659 -
660 - This is the core fix: for long string values like code (4000+ chars),
661 - the parser should stream incrementally rather than buffering until
662 - complete. This test verifies we get many fragments, not just 1-3.
663 - """
664 -
665 - # Bubble sort example from Issue #32829 - realistic long content
666 - bubble_sort_code = '''#!/usr/bin/env python3
667 -# -*- coding: utf-8 -*-
668 -"""
669 -Bubble Sort Implementation
670 -"""
671 -
672 -def bubble_sort(arr):
673 - n = len(arr)
674 - for i in range(n):
675 - swapped = False
676 - for j in range(0, n - i - 1):
677 - if arr[j] > arr[j + 1]:
678 - arr[j], arr[j + 1] = arr[j + 1], arr[j]
679 - swapped = True
680 - if not swapped:
681 - break
682 - return arr
683 -
684 -if __name__ == "__main__":
685 - test_arr = [64, 34, 25, 12, 22, 11, 90]
686 - print(f"Original: {test_arr}")
687 - sorted_arr = bubble_sort(test_arr.copy())
688 - print(f"Sorted: {sorted_arr}")'''
689 -
690 - # Create tools with schema to enable string type detection
691 - # This is required for incremental streaming of string values
692 - tools = [
693 - ChatCompletionToolsParam(
694 - function=FunctionDefinition(
695 - name="write_to_file",
696 - parameters={
697 - "type": "object",
698 - "properties": {
699 - "file_path": {"type": "string"},
700 - "content": {"type": "string"},
701 - },
702 - },
703 - ),
704 - ),
705 - ]
706 - glm4_moe_tool_parser = Glm4MoeModelToolParser(glm4_moe_tokenizer, tools=tools)
707 - request = ChatCompletionRequest(
708 - model=MODEL,
709 - messages=[],
710 - tools=tools,
711 - )
712 -
713 - # Simulate token-based streaming (special tags as single tokens)
714 - chunks = [
715 - "<tool_call>",
716 - "write_to_file\n",
717 - "<arg_key>file_path</arg_key>",
718 - "<arg_value>/tmp/bubble_sort.py</arg_value>",
719 - "<arg_key>content</arg_key>",
720 - "<arg_value>",
721 - ]
722 - # Add content line by line (realistic token streaming)
723 - for line in bubble_sort_code.split("\n"):
724 - chunks.append(line + "\n")
725 - chunks.append("</arg_value>")
726 - chunks.append("</tool_call>")
727 -
728 - # Count argument fragments
729 - fragment_count = 0
730 - current_text = ""
731 - for chunk in chunks:
732 - current_text += chunk
733 - result = glm4_moe_tool_parser.extract_tool_calls_streaming(
734 - previous_text="",
735 - current_text=current_text,
736 - delta_text=chunk,
737 - previous_token_ids=[],
738 - current_token_ids=[],
739 - delta_token_ids=[],
740 - request=request,
741 - )
742 - if result is not None and result.tool_calls:
743 - for tc in result.tool_calls:
744 - func = tc.function
745 - if isinstance(func, dict):
746 - args = func.get("arguments")
747 - else:
748 - args = getattr(func, "arguments", None)
749 - if args:
750 - fragment_count += 1
751 -
752 - # For true incremental streaming, we expect many fragments (10+)
753 - # Old buffered implementation would give only 1-3 fragments
754 - assert fragment_count >= 10, (
755 - f"Expected >=10 fragments for incremental streaming, got {fragment_count}"
756 - )
757 -
758 - # Verify final result is valid JSON
759 - assert len(glm4_moe_tool_parser.streamed_args_for_tool) == 1
760 - args_json = glm4_moe_tool_parser.streamed_args_for_tool[0]
761 - parsed = json.loads(args_json)
762 - assert parsed["file_path"] == "/tmp/bubble_sort.py"
763 - assert "def bubble_sort" in parsed["content"]
764 -
765 -
766 -def test_extract_tool_calls_numeric_deserialization(glm4_moe_tool_parser, mock_request):
767 - """Test that numeric arguments are deserialized as numbers, not strings."""
768 - model_output = """<tool_call>calculate
769 -<arg_key>operation</arg_key>
770 -<arg_value>add</arg_value>
771 -<arg_key>a</arg_key>
772 -<arg_value>42</arg_value>
773 -<arg_key>b</arg_key>
774 -<arg_value>3.14</arg_value>
775 -<arg_key>enabled</arg_key>
776 -<arg_value>true</arg_value>
777 -</tool_call>"""
778 -
779 - extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls(
780 - model_output, request=mock_request
781 - ) # type: ignore[arg-type]
782 -
783 - assert extracted_tool_calls.tools_called
784 - assert len(extracted_tool_calls.tool_calls) == 1
785 -
786 - args = json.loads(extracted_tool_calls.tool_calls[0].function.arguments)
30
788 - # String should remain string
789 - assert args["operation"] == "add"
790 - assert isinstance(args["operation"], str)
31 +class _CollectedToolDelta(TypedDict):
32 + name: str | None
33 + args_fragments: list[str]
34
792 - # Integer should be deserialized as int
793 - assert args["a"] == 42
794 - assert isinstance(args["a"], int)
35
796 - # Float should be deserialized as float
797 - assert args["b"] == 3.14
798 - assert isinstance(args["b"], float)
36 +def _mock_tokenizer() -> MockTokenizer:
37 + return MockTokenizer(vocab=_GLM_VOCAB, tokens=[])
38
800 - # Boolean should be deserialized as bool
801 - assert args["enabled"] is True
802 - assert isinstance(args["enabled"], bool)
39
804 -
805 -def test_whitespace_preserved_in_arg_values(glm4_moe_tokenizer):
806 - """Test that string arguments preserve leading and trailing whitespace."""
807 - tools = [
40 +def _tools() -> list[ChatCompletionToolsParam]:
41 + return [
42 ChatCompletionToolsParam(
43 function=FunctionDefinition(
810 - name="apply_diff",
44 + name="get_current_weather",
45 parameters={
46 "type": "object",
47 "properties": {
814 - "s": {"type": "string"},
48 + "city": {"type": "string"},
49 + "state": {"type": "string"},
50 + "unit": {"type": "string"},
51 },
816 - "required": ["s"],
52 },
53 ),
54 ),
820 - ]
821 - parser = Glm4MoeModelToolParser(glm4_moe_tokenizer, tools=tools)
822 - request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools)
823 -
824 - model_output = """<tool_call>apply_diff
825 -<arg_key>s</arg_key>
826 -<arg_value> indented code </arg_value>
827 -</tool_call>"""
828 -
829 - extracted_tool_calls = parser.extract_tool_calls(model_output, request=request)
830 - args = json.loads(extracted_tool_calls.tool_calls[0].function.arguments)
831 -
832 - assert args["s"] == " indented code "
833 -
834 -
835 -def test_zero_argument_tool_call(glm4_moe_tool_parser, mock_request):
836 - """Regression: zero-argument tool call crash (PR #32321)."""
837 - model_output = """<tool_call>get_time
838 -</tool_call>"""
839 -
840 - extracted = glm4_moe_tool_parser.extract_tool_calls(
841 - model_output, request=mock_request
842 - ) # type: ignore[arg-type]
843 -
844 - assert extracted.tools_called
845 - assert len(extracted.tool_calls) == 1
846 - assert extracted.tool_calls[0].function.name == "get_time"
847 - args = json.loads(extracted.tool_calls[0].function.arguments)
848 - assert args == {}
849 -
850 -
851 -def test_malformed_tool_call_no_regex_match(glm4_moe_tool_parser, mock_request):
852 - """Regression: malformed tool_call with no regex match (PR #32321)."""
853 - model_output = "<tool_call> </tool_call>"
854 -
855 - extracted = glm4_moe_tool_parser.extract_tool_calls(
856 - model_output, request=mock_request
857 - ) # type: ignore[arg-type]
858 -
859 - assert extracted.tools_called is False
860 - assert extracted.tool_calls == []
861 -
862 -
863 -def test_delimiter_preserved_transformers_5x(glm4_moe_tool_parser):
864 - """Regression: adjust_request sets skip_special_tokens=False (PR #31622)."""
865 - # Tools enabled
866 - request_with_tools = ChatCompletionRequest(
867 - model=MODEL,
868 - messages=[],
869 - tools=[
870 - {
871 - "type": "function",
872 - "function": {
873 - "name": "get_weather",
874 - "parameters": {
875 - "type": "object",
876 - "properties": {"city": {"type": "string"}},
877 - },
878 - },
879 - }
880 - ],
881 - ) # type: ignore
882 - adjusted = glm4_moe_tool_parser.adjust_request(request_with_tools)
883 - assert adjusted.skip_special_tokens is False
884 -
885 - # tool_choice="none"
886 - request_no_choice = ChatCompletionRequest(
887 - model=MODEL,
888 - messages=[],
889 - tools=[
890 - {
891 - "type": "function",
892 - "function": {
893 - "name": "get_weather",
894 - "parameters": {
895 - "type": "object",
896 - "properties": {"city": {"type": "string"}},
897 - },
898 - },
899 - }
900 - ],
901 - tool_choice="none",
902 - ) # type: ignore
903 - adjusted_none = glm4_moe_tool_parser.adjust_request(request_no_choice)
904 - assert adjusted_none.skip_special_tokens is True
905 -
906 - # No tools at all
907 - request_no_tools = ChatCompletionRequest(
908 - model=MODEL,
909 - messages=[],
910 - ) # type: ignore
911 - adjusted_empty = glm4_moe_tool_parser.adjust_request(request_no_tools)
912 - assert adjusted_empty.skip_special_tokens is True
913 -
914 -
915 -def test_unicode_characters_preserved(glm4_moe_tool_parser, mock_request):
916 - """Regression: Unicode chars must not be escaped to \\uXXXX (PR #30920)."""
917 - model_output = """<tool_call>send_message
918 -<arg_key>greeting</arg_key>
919 -<arg_value>你好世界</arg_value>
920 -<arg_key>emoji</arg_key>
921 -<arg_value>🎉</arg_value>
922 -</tool_call>"""
923 -
924 - extracted = glm4_moe_tool_parser.extract_tool_calls(
925 - model_output, request=mock_request
926 - ) # type: ignore[arg-type]
927 -
928 - assert extracted.tools_called
929 - assert len(extracted.tool_calls) == 1
930 -
931 - raw_args = extracted.tool_calls[0].function.arguments
932 - assert "你好世界" in raw_args
933 - assert "🎉" in raw_args
934 - assert "\\u4f60" not in raw_args
935 - parsed_args = json.loads(raw_args)
936 - assert parsed_args["greeting"] == "你好世界"
937 - assert parsed_args["emoji"] == "🎉"
938 -
939 -
940 -def test_streaming_multi_token_chunks(glm4_moe_tool_parser, mock_request):
941 - """Test that multi-token chunks (stream_interval > 1) are handled correctly.
942 -
943 - With stream_interval > 1 or MTP, multiple XML tags arrive in one delta.
944 - The old buffer-based parser could only return one delta per call, losing
945 - data on the final output. The re-parse approach handles this correctly.
946 - """
947 - _reset_streaming_state(glm4_moe_tool_parser)
948 -
949 - # Simulate stream_interval=3: chunks contain multiple XML tags
950 - chunks = [
951 - "<tool_call>get_weather\n<arg_key>city</arg_key><arg_value>Bei",
952 - "jing</arg_value>",
953 - "</tool_call>",
954 - ]
955 -
956 - current_text = ""
957 - for chunk in chunks:
958 - current_text += chunk
959 - glm4_moe_tool_parser.extract_tool_calls_streaming(
960 - previous_text="",
961 - current_text=current_text,
962 - delta_text=chunk,
963 - previous_token_ids=[],
964 - current_token_ids=[],
965 - delta_token_ids=[],
966 - request=mock_request,
967 - )
968 -
969 - # All data should be captured despite multi-token chunks
970 - assert len(glm4_moe_tool_parser.prev_tool_call_arr) == 1
971 - args = json.loads(glm4_moe_tool_parser.streamed_args_for_tool[0])
972 - assert args["city"] == "Beijing"
973 -
974 -
975 -def test_streaming_entire_tool_call_at_once(glm4_moe_tool_parser, mock_request):
976 - """Test that a complete tool call arriving in one delta works.
977 -
978 - This simulates the extreme MTP case where all tokens arrive at once.
979 - """
980 - _reset_streaming_state(glm4_moe_tool_parser)
981 -
982 - full_text = (
983 - "<tool_call>get_weather\n"
984 - "<arg_key>city</arg_key>"
985 - "<arg_value>Beijing</arg_value>"
986 - "</tool_call>"
987 - )
988 -
989 - result = glm4_moe_tool_parser.extract_tool_calls_streaming(
990 - previous_text="",
991 - current_text=full_text,
992 - delta_text=full_text,
993 - previous_token_ids=[],
994 - current_token_ids=[],
995 - delta_token_ids=[],
996 - request=mock_request,
997 - )
998 -
999 - # Should emit tool call with complete arguments in one shot
1000 - assert result is not None
1001 - assert result.tool_calls is not None
1002 -
1003 - # Verify final state
1004 - assert len(glm4_moe_tool_parser.prev_tool_call_arr) == 1
1005 - args = json.loads(glm4_moe_tool_parser.streamed_args_for_tool[0])
1006 - assert args["city"] == "Beijing"
1007 -
1008 -
1009 -def test_streaming_content_between_tool_calls_multi_token(
1010 - glm4_moe_tool_parser, mock_request
1011 -):
1012 - """Test content between tool calls with multi-token chunks."""
1013 - _reset_streaming_state(glm4_moe_tool_parser)
1014 -
1015 - # Deliver everything at once — worst case for the old buffer parser
1016 - full_text = (
1017 - "I will check.\n"
1018 - "<tool_call>get_weather\n"
1019 - "<arg_key>city</arg_key>"
1020 - "<arg_value>Beijing</arg_value>"
1021 - "</tool_call>"
1022 - "\nAlso Shanghai.\n"
1023 - "<tool_call>get_weather\n"
1024 - "<arg_key>city</arg_key>"
1025 - "<arg_value>Shanghai</arg_value>"
1026 - "</tool_call>"
1027 - )
1028 -
1029 - # First call with partial text (content only)
1030 - partial = "I will check.\n"
1031 - result1 = glm4_moe_tool_parser.extract_tool_calls_streaming(
1032 - previous_text="",
1033 - current_text=partial,
1034 - delta_text=partial,
1035 - previous_token_ids=[],
1036 - current_token_ids=[],
1037 - delta_token_ids=[],
1038 - request=mock_request,
1039 - )
1040 - assert result1 is not None
1041 - assert result1.content == "I will check.\n"
1042 -
1043 - # Second call with everything
1044 - glm4_moe_tool_parser.extract_tool_calls_streaming(
1045 - previous_text="",
1046 - current_text=full_text,
1047 - delta_text=full_text[len(partial) :],
1048 - previous_token_ids=[],
1049 - current_token_ids=[],
1050 - delta_token_ids=[],
1051 - request=mock_request,
1052 - )
1053 -
1054 - # Should have both tool calls
1055 - assert len(glm4_moe_tool_parser.prev_tool_call_arr) == 2
1056 - args0 = json.loads(glm4_moe_tool_parser.prev_tool_call_arr[0]["arguments"])
1057 - args1 = json.loads(glm4_moe_tool_parser.prev_tool_call_arr[1]["arguments"])
1058 - assert args0["city"] == "Beijing"
1059 - assert args1["city"] == "Shanghai"
1060 -
1061 -
1062 -def test_streaming_multi_token_with_multiple_args(glm4_moe_tokenizer):
1063 - """Test multi-token streaming with multiple arguments of mixed types."""
1064 - tools = [
55 ChatCompletionToolsParam(
56 function=FunctionDefinition(
57 name="calculate",
@@ -1071,415 +61,168 @@ def test_streaming_multi_token_with_multiple_args(glm4_moe_tokenizer):
61 "operation": {"type": "string"},
62 "a": {"type": "number"},
63 "b": {"type": "number"},
64 + "enabled": {"type": "boolean"},
65 },
66 },
67 ),
68 ),
1078 - ]
1079 - parser = Glm4MoeModelToolParser(glm4_moe_tokenizer, tools=tools)
1080 - request = ChatCompletionRequest(
1081 - model=MODEL,
1082 - messages=[],
1083 - tools=tools,
1084 - )
1085 -
1086 - # All arguments arrive in two big chunks (simulates stream_interval=5)
1087 - chunks = [
1088 - "<tool_call>calculate\n<arg_key>operation</arg_key><arg_value>add</arg_value><arg_key>a</arg_key>",
1089 - "<arg_value>42</arg_value><arg_key>b</arg_key><arg_value>3.14</arg_value></tool_call>",
1090 - ]
1091 -
1092 - current_text = ""
1093 - for chunk in chunks:
1094 - current_text += chunk
1095 - parser.extract_tool_calls_streaming(
1096 - previous_text="",
1097 - current_text=current_text,
1098 - delta_text=chunk,
1099 - previous_token_ids=[],
1100 - current_token_ids=[],
1101 - delta_token_ids=[],
1102 - request=request,
1103 - )
1104 -
1105 - args = json.loads(parser.streamed_args_for_tool[0])
1106 - assert args["operation"] == "add"
1107 - assert args["a"] == 42
1108 - assert args["b"] == 3.14
1109 -
1110 -
1111 -def _simulate_streaming(tokenizer, parser, request, text, stream_interval=1):
1112 - """Simulate streaming with a given stream_interval.
1113 -
1114 - Tokens are batched into chunks of ``stream_interval`` tokens,
1115 - mimicking how the output processor delivers them.
1116 - Returns a list of non-None DeltaMessages.
1117 - """
1118 - tokens = tokenizer.encode(text)
1119 - previous_text = ""
1120 - deltas = []
1121 - for i in range(0, len(tokens), stream_interval):
1122 - chunk_ids = tokens[i : i + stream_interval]
1123 - delta_text = tokenizer.decode(chunk_ids)
1124 - current_text = previous_text + delta_text
1125 - delta = parser.extract_tool_calls_streaming(
1126 - previous_text=previous_text,
1127 - current_text=current_text,
1128 - delta_text=delta_text,
1129 - previous_token_ids=[],
1130 - current_token_ids=[],
1131 - delta_token_ids=chunk_ids,
1132 - request=request,
1133 - )
1134 - previous_text = current_text
1135 - if delta is not None:
1136 - deltas.append(delta)
1137 - return deltas
1138 -
1139 -
1140 -def _collect_from_deltas(deltas):
1141 - """Reconstruct tool call names/args and content from a delta stream."""
1142 - tools: dict[int, dict] = {}
1143 - content_parts: list[str] = []
1144 - for d in deltas:
1145 - if d.content:
1146 - content_parts.append(d.content)
1147 - if d.tool_calls:
1148 - for tc in d.tool_calls:
1149 - func = tc.function
1150 - if isinstance(func, dict):
1151 - name = func.get("name")
1152 - args = func.get("arguments")
1153 - else:
1154 - name = getattr(func, "name", None)
1155 - args = getattr(func, "arguments", None)
1156 - idx = tc.index
1157 - if idx not in tools:
1158 - tools[idx] = {"name": None, "args_fragments": []}
1159 - if name:
1160 - tools[idx]["name"] = name
1161 - if args:
1162 - tools[idx]["args_fragments"].append(args)
1163 - return content_parts, tools
1164 -
1165 -
1166 -@pytest.mark.parametrize("stream_interval", [1, 2, 3, 5, 8])
1167 -def test_stream_interval_single_tool_call(glm4_moe_tokenizer, stream_interval):
1168 - """Tool call streaming produces correct name + args at any interval."""
1169 - tools = [
69 ChatCompletionToolsParam(
1171 - function=FunctionDefinition(
1172 - name="get_weather",
1173 - parameters={
1174 - "type": "object",
1175 - "properties": {"city": {"type": "string"}},
1176 - },
1177 - ),
1178 - ),
1179 - ]
1180 - parser = Glm4MoeModelToolParser(glm4_moe_tokenizer, tools=tools)
1181 - request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools)
1182 -
1183 - text = (
1184 - "<tool_call>get_weather\n"
1185 - "<arg_key>city</arg_key>"
1186 - "<arg_value>Beijing</arg_value>"
1187 - "</tool_call>"
1188 - )
1189 -
1190 - deltas = _simulate_streaming(
1191 - glm4_moe_tokenizer, parser, request, text, stream_interval
1192 - )
1193 - _, tools_found = _collect_from_deltas(deltas)
1194 -
1195 - assert 0 in tools_found
1196 - assert tools_found[0]["name"] == "get_weather"
1197 - args_json = "".join(tools_found[0]["args_fragments"])
1198 - parsed = json.loads(args_json)
1199 - assert parsed == {"city": "Beijing"}
1200 -
1201 -
1202 -@pytest.mark.parametrize("stream_interval", [1, 2, 3, 5, 8])
1203 -def test_stream_interval_multiple_tool_calls(glm4_moe_tokenizer, stream_interval):
1204 - """Multiple sequential tool calls with correct indices at any interval."""
1205 - tools = [
1206 - ChatCompletionToolsParam(
1207 - function=FunctionDefinition(
1208 - name="get_weather",
1209 - parameters={
1210 - "type": "object",
1211 - "properties": {"city": {"type": "string"}},
1212 - },
1213 - ),
1214 - ),
1215 - ]
1216 - parser = Glm4MoeModelToolParser(glm4_moe_tokenizer, tools=tools)
1217 - request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools)
1218 -
1219 - text = (
1220 - "<tool_call>get_weather\n"
1221 - "<arg_key>city</arg_key>"
1222 - "<arg_value>Beijing</arg_value>"
1223 - "</tool_call>"
1224 - "<tool_call>get_weather\n"
1225 - "<arg_key>city</arg_key>"
1226 - "<arg_value>Shanghai</arg_value>"
1227 - "</tool_call>"
1228 - )
1229 -
1230 - deltas = _simulate_streaming(
1231 - glm4_moe_tokenizer, parser, request, text, stream_interval
1232 - )
1233 - _, tools_found = _collect_from_deltas(deltas)
1234 -
1235 - assert 0 in tools_found and 1 in tools_found
1236 - args0 = json.loads("".join(tools_found[0]["args_fragments"]))
1237 - args1 = json.loads("".join(tools_found[1]["args_fragments"]))
1238 - assert args0 == {"city": "Beijing"}
1239 - assert args1 == {"city": "Shanghai"}
1240 -
1241 -
1242 -@pytest.mark.parametrize("stream_interval", [1, 2, 3, 5, 8])
1243 -def test_stream_interval_content_then_tool_call(glm4_moe_tokenizer, stream_interval):
1244 - """Content before a tool call is fully emitted before tool deltas."""
1245 - tools = [
1246 - ChatCompletionToolsParam(
1247 - function=FunctionDefinition(
1248 - name="get_weather",
1249 - parameters={
1250 - "type": "object",
1251 - "properties": {"city": {"type": "string"}},
1252 - },
1253 - ),
70 + function=FunctionDefinition(name="get_time", parameters={}),
71 ),
72 ]
1256 - parser = Glm4MoeModelToolParser(glm4_moe_tokenizer, tools=tools)
1257 - request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools)
1258 -
1259 - text = (
1260 - "I will check the weather for you.\n"
1261 - "<tool_call>get_weather\n"
1262 - "<arg_key>city</arg_key>"
1263 - "<arg_value>Beijing</arg_value>"
1264 - "</tool_call>"
1265 - )
73
1267 - deltas = _simulate_streaming(
1268 - glm4_moe_tokenizer, parser, request, text, stream_interval
1269 - )
1270 - content_parts, tools_found = _collect_from_deltas(deltas)
74
1272 - # Content must be present and precede tool calls
1273 - full_content = "".join(content_parts)
1274 - assert "I will check the weather" in full_content
75 +def _request(tools: list[ChatCompletionToolsParam]) -> ChatCompletionRequest:
76 + return ChatCompletionRequest(model=MODEL, messages=[], tools=tools)
77
1276 - # Tool call must be correct
1277 - assert 0 in tools_found
1278 - assert tools_found[0]["name"] == "get_weather"
1279 - args = json.loads("".join(tools_found[0]["args_fragments"]))
1280 - assert args == {"city": "Beijing"}
78
79 +def _parser(tools: list[ChatCompletionToolsParam] | None = None):
80 + return Glm47MoeModelToolParser(_mock_tokenizer(), tools=tools)
81
1283 -def test_stream_interval_extreme_single_chunk(glm4_moe_tokenizer):
1284 - """Extreme MTP: entire output arrives in one chunk (interval=9999)."""
1285 - tools = [
1286 - ChatCompletionToolsParam(
1287 - function=FunctionDefinition(
1288 - name="get_weather",
1289 - parameters={
1290 - "type": "object",
1291 - "properties": {"city": {"type": "string"}},
1292 - },
1293 - ),
1294 - ),
1295 - ]
1296 - parser = Glm4MoeModelToolParser(glm4_moe_tokenizer, tools=tools)
1297 - request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools)
82
1299 - text = (
1300 - "Here is the weather.\n"
1301 - "<tool_call>get_weather\n"
1302 - "<arg_key>city</arg_key>"
1303 - "<arg_value>Beijing</arg_value>"
1304 - "</tool_call>"
1305 - )
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
1307 - deltas = _simulate_streaming(
1308 - glm4_moe_tokenizer, parser, request, text, stream_interval=9999
1309 - )
1310 - content_parts, tools_found = _collect_from_deltas(deltas)
108
1312 - assert "Here is the weather" in "".join(content_parts)
1313 - assert 0 in tools_found
1314 - assert tools_found[0]["name"] == "get_weather"
1315 - args = json.loads("".join(tools_found[0]["args_fragments"]))
1316 - assert args == {"city": "Beijing"}
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
1319 -@pytest.mark.parametrize("stream_interval", [1, 2, 5])
1320 -def test_stream_interval_content_between_tool_calls(
1321 - glm4_moe_tokenizer, stream_interval
1322 -):
1323 - """Content between tool calls must be emitted, not silently dropped."""
1324 - tools = [
1325 - ChatCompletionToolsParam(
1326 - function=FunctionDefinition(
1327 - name="get_weather",
1328 - parameters={
1329 - "type": "object",
1330 - "properties": {"city": {"type": "string"}},
1331 - },
1332 - ),
1333 - ),
1334 - ]
1335 - parser = Glm4MoeModelToolParser(glm4_moe_tokenizer, tools=tools)
1336 - request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools)
1337 -
1338 - text = (
1339 - "Checking Beijing.\n"
1340 - "<tool_call>get_weather\n"
1341 - "<arg_key>city</arg_key>"
1342 - "<arg_value>Beijing</arg_value>"
1343 - "</tool_call>"
1344 - "\nAlso Shanghai.\n"
1345 - "<tool_call>get_weather\n"
1346 - "<arg_key>city</arg_key>"
1347 - "<arg_value>Shanghai</arg_value>"
1348 - "</tool_call>"
1349 - )
1350 -
1351 - deltas = _simulate_streaming(
1352 - glm4_moe_tokenizer, parser, request, text, stream_interval
1353 - )
1354 - content_parts, tools_found = _collect_from_deltas(deltas)
1355 -
1356 - full_content = "".join(content_parts)
1357 - # Both prefix and inter-tool-call content must appear
1358 - assert "Checking Beijing" in full_content
1359 - assert "Also Shanghai" in full_content
1360 -
1361 - # Both tool calls must be correct
1362 - assert 0 in tools_found and 1 in tools_found
1363 - args0 = json.loads("".join(tools_found[0]["args_fragments"]))
1364 - args1 = json.loads("".join(tools_found[1]["args_fragments"]))
1365 - assert args0 == {"city": "Beijing"}
1366 - assert args1 == {"city": "Shanghai"}
1367 -
1368 -
1369 -# ── FunctionTool (Responses API) tests ──────────────────────────────
1370 -
1371 -
1372 -@pytest.fixture
1373 -def function_tools():
1374 - return [
1375 - FunctionTool(
1376 - type="function",
1377 - name="get_weather",
1378 - parameters={
1379 - "type": "object",
1380 - "properties": {
1381 - "city": {"type": "string"},
1382 - "unit": {"type": "string"},
1383 - },
1384 - },
1385 - ),
1386 - FunctionTool(
1387 - type="function",
1388 - name="calculate",
1389 - parameters={
1390 - "type": "object",
1391 - "properties": {
1392 - "operation": {"type": "string"},
1393 - "a": {"type": "number"},
1394 - "b": {"type": "number"},
1395 - },
1396 - },
1397 - ),
1398 - ]
1399 -
1400 -
1401 -@pytest.fixture
1402 -def glm4_moe_parser_function_tools(glm4_moe_tokenizer, function_tools):
1403 - return Glm4MoeModelToolParser(glm4_moe_tokenizer, tools=function_tools)
1404 -
1405 -
1406 -@pytest.fixture
1407 -def mock_request_function_tools(function_tools) -> ChatCompletionRequest:
1408 - request = Mock(spec=ChatCompletionRequest)
1409 - request.tools = function_tools
1410 - return request
1411 -
1412 -
1413 -def test_extract_tool_calls_with_function_tool(
1414 - glm4_moe_parser_function_tools, mock_request_function_tools
1415 -):
1416 - model_output = """<tool_call>get_weather
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
1423 - extracted = glm4_moe_parser_function_tools.extract_tool_calls(
1424 - model_output, request=mock_request_function_tools
1425 - )
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
1428 - assert extracted.tool_calls[0].function.name == "get_weather"
1429 - args = json.loads(extracted.tool_calls[0].function.arguments)
1430 - assert args["city"] == "Dallas"
1431 - assert args["unit"] == "fahrenheit"
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
1434 -def test_extract_tool_calls_with_function_tool_mixed_types(
1435 - glm4_moe_parser_function_tools, mock_request_function_tools
1436 -):
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
1438 -<arg_key>operation</arg_key>
1439 -<arg_value>add</arg_value>
1440 -<arg_key>a</arg_key>
1441 -<arg_value>42</arg_value>
1442 -<arg_key>b</arg_key>
1443 -<arg_value>3.14</arg_value>
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
1446 - extracted = glm4_moe_parser_function_tools.extract_tool_calls(
1447 - model_output, request=mock_request_function_tools
1448 - )
172 + extracted = parser.extract_tool_calls(model_output, request=_request(tools))
173 +
174 assert extracted.tools_called
1450 - args = json.loads(extracted.tool_calls[0].function.arguments)
1451 - assert args["operation"] == "add"
1452 - assert isinstance(args["a"], (int, float))
1453 - assert isinstance(args["b"], float)
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
1456 -def test_streaming_with_function_tool(
1457 - glm4_moe_parser_function_tools, mock_request_function_tools
1458 -):
1459 - _reset_streaming_state(glm4_moe_parser_function_tools)
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 = [
1462 - "<tool_call>get_weather\n",
202 + "<tool_call>",
203 + "get_current_weather\n",
204 "<arg_key>city</arg_key>",
205 "<arg_value>Bei",
1465 - "jing",
1466 - "</arg_value>",
206 + "jing</arg_value>",
207 "</tool_call>",
208 ]
1469 -
209 + deltas = []
210 current_text = ""
211 +
212 for chunk in chunks:
213 current_text += chunk
1473 - glm4_moe_parser_function_tools.extract_tool_calls_streaming(
1474 - previous_text="",
1475 - current_text=current_text,
1476 - delta_text=chunk,
1477 - previous_token_ids=[],
1478 - current_token_ids=[],
1479 - delta_token_ids=[],
1480 - request=mock_request_function_tools,
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
1483 - assert len(glm4_moe_parser_function_tools.prev_tool_call_arr) == 1
1484 - args = json.loads(glm4_moe_parser_function_tools.prev_tool_call_arr[0]["arguments"])
1485 - assert args["city"] == "Beijing"
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"}
vllm/parser/engine/registered_adapters.py
+6
@@ -9,6 +9,7 @@ names so that :class:`ReasoningParserManager` and
9
10 from vllm.parser.engine.adapters import make_adapters
11 from vllm.parser.gemma4 import Gemma4Parser
12 +from vllm.parser.glm47_moe import Glm47MoeParser
13 from vllm.parser.minimax_m2 import MinimaxM2Parser
14 from vllm.parser.nemotron_v3 import NemotronV3Parser
15 from vllm.parser.qwen3 import Qwen3Parser
@@ -32,3 +33,8 @@ from vllm.parser.qwen3 import Qwen3Parser
33 Qwen3ParserReasoningAdapter,
34 Qwen3ParserToolAdapter,
35 ) = make_adapters(Qwen3Parser)
36 +
37 +(
38 + Glm47MoeParserReasoningAdapter,
39 + Glm47MoeParserToolAdapter,
40 +) = make_adapters(Glm47MoeParser)
vllm/parser/glm47_moe.py new
+226
@@ -0,0 +1,226 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""GLM-4.7 parser for reasoning and tool calls.
4 +
5 +GLM-4.7 uses XML-like tool calls::
6 +
7 + <tool_call>func_name<arg_key>key</arg_key><arg_value>value</arg_value></tool_call>
8 +
9 +The function name can be followed directly by the first ``<arg_key>`` tag,
10 +and tool calls may have no arguments.
11 +"""
12 +
13 +from __future__ import annotations
14 +
15 +import functools
16 +import json
17 +from typing import TYPE_CHECKING
18 +
19 +import regex as re
20 +
21 +from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
22 +from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
23 +from vllm.parser.engine.events import EventType
24 +from vllm.parser.engine.parser_engine import ParserEngine
25 +from vllm.parser.engine.parser_engine_config import (
26 + ParserEngineConfig,
27 + ParserState,
28 + Transition,
29 +)
30 +
31 +if TYPE_CHECKING:
32 + from vllm.tokenizers import TokenizerLike
33 + from vllm.tool_parsers.abstract_tool_parser import Tool
34 +
35 +THINK_START = "<think>"
36 +THINK_END = "</think>"
37 +TOOL_CALL_START = "<tool_call>"
38 +TOOL_CALL_END = "</tool_call>"
39 +ARG_KEY_START = "<arg_key>"
40 +ARG_KEY_END = "</arg_key>"
41 +ARG_VALUE_START = "<arg_value>"
42 +ARG_VALUE_END = "</arg_value>"
43 +
44 +_ARG_RE = re.compile(
45 + r"<arg_key>(?P<key>.*?)</arg_key>\s*"
46 + r"<arg_value>(?P<value>.*?)</arg_value>",
47 + re.DOTALL,
48 +)
49 +_PARTIAL_ARG_RE = re.compile(
50 + r"<arg_key>(?P<key>.*?)</arg_key>\s*"
51 + r"<arg_value>(?P<value>.*)$",
52 + re.DOTALL,
53 +)
54 +
55 +
56 +def _glm47_arg_converter(raw_args: str, partial: bool) -> str:
57 + params: dict[str, object] = {}
58 +
59 + for match in _ARG_RE.finditer(raw_args):
60 + params[match.group("key").strip()] = match.group("value")
61 +
62 + if partial:
63 + remaining = _ARG_RE.sub("", raw_args)
64 + match = _PARTIAL_ARG_RE.search(remaining)
65 + if match:
66 + key = match.group("key").strip()
67 + if key:
68 + params[key] = match.group("value")
69 +
70 + return json.dumps(params, ensure_ascii=False)
71 +
72 +
73 +@functools.cache
74 +def glm47_moe_config(thinking: bool = True) -> ParserEngineConfig:
75 + arg_tag_transitions = {
76 + (ParserState.TOOL_ARGS, terminal): Transition(
77 + ParserState.TOOL_ARGS,
78 + (EventType.ARG_VALUE_CHUNK,),
79 + )
80 + for terminal in (
81 + "ARG_KEY_START",
82 + "ARG_KEY_END",
83 + "ARG_VALUE_START",
84 + "ARG_VALUE_END",
85 + )
86 + }
87 +
88 + reasoning_terminals = (
89 + {
90 + "THINK_START": THINK_START,
91 + "THINK_END": THINK_END,
92 + }
93 + if thinking
94 + else {}
95 + )
96 + reasoning_token_id_terminals = (
97 + {
98 + "THINK_START": THINK_START,
99 + "THINK_END": THINK_END,
100 + }
101 + if thinking
102 + else {}
103 + )
104 + reasoning_transitions = (
105 + {
106 + (ParserState.CONTENT, "THINK_START"): Transition(
107 + ParserState.REASONING,
108 + (EventType.REASONING_START,),
109 + ),
110 + (ParserState.REASONING, "THINK_END"): Transition(
111 + ParserState.CONTENT,
112 + (EventType.REASONING_END,),
113 + ),
114 + (ParserState.CONTENT, "THINK_END"): Transition(
115 + ParserState.CONTENT,
116 + (),
117 + ),
118 + }
119 + if thinking
120 + else {}
121 + )
122 +
123 + return ParserEngineConfig(
124 + name="glm47_moe",
125 + initial_state=ParserState.REASONING if thinking else ParserState.CONTENT,
126 + terminals={
127 + **reasoning_terminals,
128 + "TOOL_START": TOOL_CALL_START,
129 + "TOOL_END": TOOL_CALL_END,
130 + "ARG_KEY_START": ARG_KEY_START,
131 + "ARG_KEY_END": ARG_KEY_END,
132 + "ARG_VALUE_START": ARG_VALUE_START,
133 + "ARG_VALUE_END": ARG_VALUE_END,
134 + },
135 + token_id_terminals={
136 + **reasoning_token_id_terminals,
137 + "TOOL_START": TOOL_CALL_START,
138 + "TOOL_END": TOOL_CALL_END,
139 + },
140 + transitions={
141 + **reasoning_transitions,
142 + (ParserState.REASONING, "THINK_START"): Transition(
143 + ParserState.REASONING,
144 + (),
145 + ),
146 + (ParserState.REASONING, "TOOL_START"): Transition(
147 + ParserState.TOOL_NAME,
148 + (EventType.REASONING_END, EventType.TOOL_CALL_START),
149 + ),
150 + (ParserState.CONTENT, "TOOL_START"): Transition(
151 + ParserState.TOOL_NAME,
152 + (EventType.TOOL_CALL_START,),
153 + ),
154 + (ParserState.TOOL_NAME, "ARG_KEY_START"): Transition(
155 + ParserState.TOOL_ARGS,
156 + (EventType.ARG_VALUE_CHUNK,),
157 + ),
158 + (ParserState.TOOL_NAME, "TOOL_END"): Transition(
159 + ParserState.CONTENT,
160 + (EventType.TOOL_CALL_END,),
161 + ),
162 + (ParserState.TOOL_ARGS, "TOOL_END"): Transition(
163 + ParserState.CONTENT,
164 + (EventType.TOOL_CALL_END,),
165 + ),
166 + **arg_tag_transitions,
167 + },
168 + arg_converter=_glm47_arg_converter,
169 + stream_arg_deltas=True,
170 + tool_args_json=False,
171 + validate_tool_names=True,
172 + )
173 +
174 +
175 +class Glm47MoeParser(ParserEngine):
176 + """GLM-4.7 parser backed by the declarative parser engine."""
177 +
178 + def __init__(
179 + self,
180 + tokenizer: TokenizerLike,
181 + tools: list[Tool] | None = None,
182 + **kwargs,
183 + ) -> None:
184 + chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {}
185 + thinking = chat_kwargs.get("thinking", None)
186 + enable_thinking = chat_kwargs.get("enable_thinking", None)
187 + self.thinking_enabled = (
188 + True
189 + if thinking is None and enable_thinking is None
190 + else bool(thinking) or bool(enable_thinking)
191 + )
192 + kwargs.setdefault(
193 + "parser_engine_config",
194 + glm47_moe_config(thinking=self.thinking_enabled),
195 + )
196 + super().__init__(tokenizer, tools, **kwargs)
197 +
198 + def _emit_name_delta(self, idx: int, deltas, name: str | None) -> None:
199 + if name is not None:
200 + name = name.strip()
201 + super()._emit_name_delta(idx, deltas, name)
202 +
203 + def _handle_tool_end(self, event, deltas) -> None:
204 + idx = event.tool_index
205 + if 0 <= idx < len(self._tool_slots):
206 + self._tool_slots[idx].name = self._tool_slots[idx].name.strip()
207 + super()._handle_tool_end(event, deltas)
208 +
209 + def is_reasoning_end(self, input_ids: list[int]) -> bool:
210 + if not self.thinking_enabled:
211 + return True
212 + return super().is_reasoning_end(input_ids)
213 +
214 + def extract_content_ids(self, input_ids: list[int]) -> list[int]:
215 + if not self.thinking_enabled:
216 + return input_ids
217 + return super().extract_content_ids(input_ids)
218 +
219 + def extract_reasoning(
220 + self,
221 + model_output: str,
222 + request: ChatCompletionRequest | ResponsesRequest,
223 + ) -> tuple[str | None, str | None]:
224 + if not self.thinking_enabled:
225 + return None, model_output
226 + return super().extract_reasoning(model_output, request)
vllm/reasoning/__init__.py
+6 -2
@@ -53,8 +53,12 @@ _REASONING_PARSERS_TO_REGISTER = {
53 "Gemma4ParserReasoningAdapter",
54 ),
55 "glm45": (
56 - "deepseek_v3_reasoning_parser",
57 - "DeepSeekV3ReasoningWithThinkingParser",
56 + "glm47_moe_reasoning_parser",
57 + "Glm47MoeParserReasoningAdapter",
58 + ),
59 + "glm47": (
60 + "glm47_moe_reasoning_parser",
61 + "Glm47MoeParserReasoningAdapter",
62 ),
63 "openai_gptoss": (
64 "gptoss_reasoning_parser",
vllm/reasoning/glm47_moe_reasoning_parser.py new
+6
@@ -0,0 +1,6 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from vllm.parser.engine.registered_adapters import Glm47MoeParserReasoningAdapter
5 +
6 +__all__ = ["Glm47MoeParserReasoningAdapter"]
vllm/tool_parsers/__init__.py
+2 -2
@@ -51,8 +51,8 @@ _TOOL_PARSERS_TO_REGISTER = {
51 "Ernie45ToolParser",
52 ),
53 "glm45": (
54 - "glm4_moe_tool_parser",
55 - "Glm4MoeModelToolParser",
54 + "glm47_moe_tool_parser",
55 + "Glm47MoeModelToolParser",
56 ),
57 "glm47": (
58 "glm47_moe_tool_parser",
vllm/tool_parsers/glm47_moe_tool_parser.py
+3 -33
@@ -1,41 +1,11 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -"""
4 -GLM-4.7 Tool Call Parser.
3
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>``).
4 +from __future__ import annotations
5
12 -This parser overrides the parent regex patterns to handle both formats.
13 -"""
6 +from vllm.parser.engine.registered_adapters import Glm47MoeParserToolAdapter
7
15 -import regex as re
8
17 -from vllm.logger import init_logger
18 -from vllm.tokenizers import TokenizerLike
19 -from vllm.tool_parsers.abstract_tool_parser import Tool
20 -from vllm.tool_parsers.glm4_moe_tool_parser import Glm4MoeModelToolParser
21 -
22 -logger = init_logger(__name__)
23 -
24 -
25 -class Glm47MoeModelToolParser(Glm4MoeModelToolParser):
9 +class Glm47MoeModelToolParser(Glm47MoeParserToolAdapter): # type: ignore[valid-type, misc]
10 supports_required_and_named = False
11 structural_tag_model = "glm_4_7"
28 -
29 - def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None):
30 - super().__init__(tokenizer, tools)
31 - # GLM-4.7 format: <tool_call>func_name[<arg_key>...]*</tool_call>
32 - # The function name can be followed by a newline, whitespace, or
33 - # directly by <arg_key> tags (no separator). The arg section is
34 - # optional so that zero-argument calls are supported.
35 - self.func_detail_regex = re.compile(
36 - r"<tool_call>\s*(\S+?)\s*(<arg_key>.*)?</tool_call>", re.DOTALL
37 - )
38 - self.func_arg_regex = re.compile(
39 - r"<arg_key>(.*?)</arg_key>\s*<arg_value>(.*?)</arg_value>",
40 - re.DOTALL,
41 - )
vllm/tool_parsers/glm4_moe_tool_parser.py deleted
-495
@@ -1,495 +0,0 @@
1 -# SPDX-License-Identifier: Apache-2.0
2 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -"""
4 -GLM-4 Tool Call Parser with incremental string streaming support.
5 -
6 -This parser fixes the streaming issue reported in Issue #32829 where long string
7 -parameters (e.g., file content with 4000+ characters of code) are buffered until
8 -complete, causing multi-second delays before the user sees any content.
9 -
10 -The fix streams string values incrementally as they arrive, providing a true
11 -streaming experience for long content.
12 -"""
13 -
14 -import json
15 -from collections.abc import Sequence
16 -from typing import Any
17 -
18 -import regex as re
19 -
20 -from vllm.entrypoints.chat_utils import make_tool_call_id
21 -from vllm.entrypoints.openai.chat_completion.protocol import (
22 - ChatCompletionNamedToolChoiceParam,
23 - ChatCompletionRequest,
24 -)
25 -from vllm.entrypoints.openai.engine.protocol import (
26 - DeltaFunctionCall,
27 - DeltaMessage,
28 - DeltaToolCall,
29 - ExtractedToolCallInformation,
30 - FunctionCall,
31 - ToolCall,
32 -)
33 -from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
34 -from vllm.logger import init_logger
35 -from vllm.tokenizers import TokenizerLike
36 -from vllm.tool_parsers.abstract_tool_parser import (
37 - Tool,
38 - ToolParser,
39 -)
40 -from vllm.tool_parsers.utils import (
41 - extract_types_from_schema,
42 - find_tool_properties,
43 - partial_tag_overlap,
44 - safe_literal_eval,
45 -)
46 -
47 -logger = init_logger(__name__)
48 -
49 -
50 -class Glm4MoeModelToolParser(ToolParser):
51 - """Tool parser for GLM-4 models with incremental string streaming.
52 -
53 - On every streaming call the parser re-parses ``current_text`` to find
54 - ``<tool_call>`` regions, builds the JSON arguments string for each tool
55 - call, and diffs against what was previously sent to emit only new content.
56 - """
57 -
58 - supports_required_and_named = False
59 -
60 - def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None):
61 - super().__init__(tokenizer, tools)
62 - # Stateful streaming fields
63 - self.current_tool_name_sent: bool = False
64 - self.prev_tool_call_arr: list[dict[str, Any]] = []
65 - self.current_tool_id: int = -1
66 - self.streamed_args_for_tool: list[str] = []
67 -
68 - self.tool_call_start_token: str = "<tool_call>"
69 - self.tool_call_end_token: str = "</tool_call>"
70 - self.arg_key_start: str = "<arg_key>"
71 - self.arg_key_end: str = "</arg_key>"
72 - self.arg_val_start: str = "<arg_value>"
73 - self.arg_val_end: str = "</arg_value>"
74 -
75 - self.tool_calls_start_token = self.tool_call_start_token
76 -
77 - self.func_call_regex = re.compile(r"<tool_call>.*?</tool_call>", re.DOTALL)
78 - self.func_detail_regex = re.compile(
79 - r"<tool_call>([^\n]*)\n(.*)</tool_call>", re.DOTALL
80 - )
81 - self.func_arg_regex = re.compile(
82 - r"<arg_key>(.*?)</arg_key>\s*<arg_value>(.*?)</arg_value>", re.DOTALL
83 - )
84 -
85 - if not self.model_tokenizer:
86 - raise ValueError(
87 - "The model tokenizer must be passed to the ToolParser "
88 - "constructor during construction."
89 - )
90 -
91 - self.tool_call_start_token_id = self.vocab.get(self.tool_call_start_token)
92 - self.tool_call_end_token_id = self.vocab.get(self.tool_call_end_token)
93 -
94 - # Pre-compiled pattern for finding the last <arg_key>...</arg_key>
95 - # before a partial <arg_value> (used in _build_args_json_so_far).
96 - self._arg_key_pattern = re.compile(
97 - re.escape(self.arg_key_start) + r"(.*?)" + re.escape(self.arg_key_end),
98 - re.DOTALL,
99 - )
100 -
101 - # Streaming state for re-parse-and-diff approach
102 - self._sent_content_idx: int = 0
103 - self._tool_call_ids: list[str] = []
104 -
105 - @staticmethod
106 - def _deserialize(value: str) -> Any:
107 - try:
108 - return json.loads(value)
109 - except json.JSONDecodeError:
110 - pass
111 -
112 - try:
113 - return safe_literal_eval(value)
114 - except (ValueError, SyntaxError):
115 - pass
116 -
117 - return value
118 -
119 - @staticmethod
120 - def _json_escape_string_content(s: str) -> str:
121 - """JSON-escape string content for incremental streaming.
122 -
123 - This escapes the content that goes INSIDE a JSON string (between quotes),
124 - not including the surrounding quotes themselves.
125 - """
126 - if not s:
127 - return ""
128 - return json.dumps(s, ensure_ascii=False)[1:-1]
129 -
130 - def _is_string_type(self, tool_name: str, arg_name: str) -> bool:
131 - tool_properties = find_tool_properties(self.tools, tool_name)
132 - param_schema = tool_properties.get(arg_name)
133 - if param_schema is None:
134 - return False
135 - param_types = extract_types_from_schema(param_schema)
136 - return set(param_types) - {"null"} == {"string"}
137 -
138 - @staticmethod
139 - def _tools_enabled(request: ChatCompletionRequest) -> bool:
140 - """Return whether tool parsing should be applied for this request."""
141 - try:
142 - tools = getattr(request, "tools", None)
143 - tool_choice = getattr(request, "tool_choice", None)
144 - return bool(tools) and tool_choice != "none"
145 - except Exception:
146 - logger.exception("Failed to determine if tools are enabled.")
147 - return False
148 -
149 - def adjust_request(
150 - self, request: ChatCompletionRequest | ResponsesRequest
151 - ) -> ChatCompletionRequest | ResponsesRequest:
152 - """Adjust request parameters for tool call token handling.
153 -
154 - For required/named tool_choice, skip setting structured_outputs
155 - because GLM models output tool calls in XML format (per chat
156 - template). Guided decoding would force JSON output, conflicting
157 - with the XML format and causing parsing failures.
158 - """
159 - if request.tools:
160 - tc = request.tool_choice
161 - if tc == "required" or isinstance(tc, ChatCompletionNamedToolChoiceParam):
162 - # Do NOT call super().adjust_request() for required/named,
163 - # because it would set structured_outputs and force JSON
164 - # output via guided decoding. GLM models use XML tool-call
165 - # syntax (defined in the chat template), so guided decoding
166 - # must be skipped to let the model output XML freely.
167 - # The tool_parser handles extraction from XML output.
168 - if request.tool_choice != "none":
169 - request.skip_special_tokens = False
170 - return request
171 - request = super().adjust_request(request)
172 - if request.tools and request.tool_choice != "none":
173 - # Ensure tool call tokens (<tool_call>, </tool_call>) are not skipped
174 - # during decoding. Even though they are not marked as special tokens,
175 - # setting skip_special_tokens=False ensures proper handling in
176 - # transformers 5.x where decoding behavior may have changed.
177 - request.skip_special_tokens = False
178 - return request
179 -
180 - def extract_tool_calls(
181 - self,
182 - model_output: str,
183 - request: ChatCompletionRequest,
184 - ) -> ExtractedToolCallInformation:
185 - matched_tool_calls = self.func_call_regex.findall(model_output)
186 - logger.debug("model_output: %s", model_output)
187 - try:
188 - tool_calls: list[ToolCall] = []
189 - for match in matched_tool_calls:
190 - tc_detail = self.func_detail_regex.search(match)
191 - if not tc_detail:
192 - logger.warning(
193 - "Failed to parse tool call details from: %s",
194 - match,
195 - )
196 - continue
197 - tc_name = tc_detail.group(1).strip()
198 - tc_args = tc_detail.group(2)
199 - pairs = self.func_arg_regex.findall(tc_args) if tc_args else []
200 - arg_dct: dict[str, Any] = {}
201 - for key, value in pairs:
202 - arg_key = key.strip()
203 - if self._is_string_type(tc_name, arg_key):
204 - arg_val = value
205 - else:
206 - arg_val = self._deserialize(value.strip())
207 - logger.debug("arg_key = %s, arg_val = %s", arg_key, arg_val)
208 - arg_dct[arg_key] = arg_val
209 - tool_calls.append(
210 - ToolCall(
211 - type="function",
212 - function=FunctionCall(
213 - name=tc_name,
214 - arguments=json.dumps(arg_dct, ensure_ascii=False),
215 - ),
216 - )
217 - )
218 - except Exception:
219 - logger.exception("Failed to extract tool call spec")
220 - return ExtractedToolCallInformation(
221 - tools_called=False, tool_calls=[], content=model_output
222 - )
223 - else:
224 - if len(tool_calls) > 0:
225 - content: str | None = model_output[
226 - : model_output.find(self.tool_calls_start_token)
227 - ]
228 - # Normalize empty/whitespace-only content to None
229 - if not content or not content.strip():
230 - content = None
231 - return ExtractedToolCallInformation(
232 - tools_called=True, tool_calls=tool_calls, content=content
233 - )
234 - return ExtractedToolCallInformation(
235 - tools_called=False, tool_calls=[], content=model_output
236 - )
237 -
238 - def _extract_content(self, current_text: str) -> str | None:
239 - """Return unsent non-tool-call text, or None.
240 -
241 - Collects all text outside ``<tool_call>...</tool_call>`` regions,
242 - including text between consecutive tool calls. Holds back any
243 - suffix that could be a partial ``<tool_call>`` tag.
244 - """
245 - # Build the "sendable index" — the furthest point we can send
246 - # content up to. We scan through the text collecting segments
247 - # that are outside tool-call regions.
248 - content_segments: list[str] = []
249 - pos = self._sent_content_idx
250 -
251 - while pos < len(current_text):
252 - start = current_text.find(self.tool_call_start_token, pos)
253 - if start == -1:
254 - # No more tool calls — send up to (len - partial-tag overlap)
255 - tail = current_text[pos:]
256 - overlap = partial_tag_overlap(tail, self.tool_call_start_token)
257 - sendable = tail[: len(tail) - overlap] if overlap else tail
258 - if sendable:
259 - content_segments.append(sendable)
260 - pos = len(current_text) - overlap
261 - break
262 -
263 - # Text before this <tool_call>
264 - if start > pos:
265 - content_segments.append(current_text[pos:start])
266 -
267 - # Skip past the </tool_call> (or to end if incomplete)
268 - end = current_text.find(self.tool_call_end_token, start)
269 - if end != -1:
270 - pos = end + len(self.tool_call_end_token)
271 - else:
272 - # Incomplete tool call — nothing more to send
273 - pos = start
274 - break
275 -
276 - if content_segments:
277 - self._sent_content_idx = pos
278 - return "".join(content_segments)
279 - # Even if no content, advance past completed tool-call regions
280 - if pos > self._sent_content_idx:
281 - self._sent_content_idx = pos
282 - return None
283 -
284 - def _extract_tool_call_regions(self, text: str) -> list[tuple[str, bool]]:
285 - """Extract ``(inner_text, is_complete)`` for each ``<tool_call>`` region."""
286 - results: list[tuple[str, bool]] = []
287 - pos = 0
288 - while True:
289 - start = text.find(self.tool_call_start_token, pos)
290 - if start == -1:
291 - break
292 - inner_start = start + len(self.tool_call_start_token)
293 - end = text.find(self.tool_call_end_token, inner_start)
294 - if end != -1:
295 - results.append((text[inner_start:end], True))
296 - pos = end + len(self.tool_call_end_token)
297 - else:
298 - # Incomplete tool call — strip partial </tool_call> suffix
299 - raw = text[inner_start:]
300 - overlap = partial_tag_overlap(raw, self.tool_call_end_token)
301 - if overlap:
302 - raw = raw[:-overlap]
303 - results.append((raw, False))
304 - break
305 - return results
306 -
307 - def _extract_tool_name_from_region(self, inner_text: str) -> str | None:
308 - """Extract the tool name from the beginning of a tool-call region.
309 -
310 - The name is everything before the first ``\\n`` or ``<arg_key>``.
311 - Returns ``None`` if the name hasn't fully arrived yet.
312 - """
313 - nl = inner_text.find("\n")
314 - ak = inner_text.find(self.arg_key_start)
315 - candidates = [i for i in [nl, ak] if i != -1]
316 - if not candidates:
317 - return None
318 - cut = min(candidates)
319 - name = inner_text[:cut].strip()
320 - return name if name else None
321 -
322 - def _build_args_json_so_far(
323 - self,
324 - tool_name: str,
325 - inner_text: str,
326 - is_complete: bool,
327 - ) -> str:
328 - """Build the JSON arguments string from the XML pairs seen so far.
329 -
330 - For complete ``<arg_key>/<arg_value>`` pairs the value is fully
331 - formatted. For the last argument whose ``<arg_value>`` has been
332 - opened but not closed, the partial string content is included
333 - (JSON-escaped, with an opening ``"`` but no closing ``"``).
334 -
335 - The closing ``}`` is only appended when ``is_complete`` is True
336 - (i.e. the ``</tool_call>`` tag has arrived).
337 - """
338 - # Find all complete arg pairs
339 - pairs = self.func_arg_regex.findall(inner_text)
340 -
341 - parts: list[str] = []
342 - for key, value in pairs:
343 - key = key.strip()
344 - key_json = json.dumps(key, ensure_ascii=False)
345 - if self._is_string_type(tool_name, key):
346 - # Don't strip string values — whitespace is significant
347 - # and must match the partial-value path for diffing.
348 - val_json = json.dumps(value, ensure_ascii=False)
349 - else:
350 - val_json = json.dumps(
351 - self._deserialize(value.strip()), ensure_ascii=False
352 - )
353 - parts.append(f"{key_json}: {val_json}")
354 -
355 - # Check for a partial (incomplete) arg value
356 - # Find the last <arg_value> that isn't closed
357 - last_val_start = inner_text.rfind(self.arg_val_start)
358 - last_val_end = inner_text.rfind(self.arg_val_end)
359 - has_partial_value = last_val_start != -1 and (
360 - last_val_end == -1 or last_val_end < last_val_start
361 - )
362 -
363 - if has_partial_value:
364 - # Find the key for this partial value
365 - # Look for the last <arg_key>...</arg_key> before this <arg_value>
366 - last_key_match = None
367 - for m in self._arg_key_pattern.finditer(inner_text[:last_val_start]):
368 - last_key_match = m
369 -
370 - if last_key_match:
371 - partial_key = last_key_match.group(1).strip()
372 - partial_content_start = last_val_start + len(self.arg_val_start)
373 - partial_content = inner_text[partial_content_start:]
374 -
375 - # Hold back any partial </arg_value> suffix
376 - overlap = partial_tag_overlap(partial_content, self.arg_val_end)
377 - if overlap:
378 - partial_content = partial_content[:-overlap]
379 -
380 - key_json = json.dumps(partial_key, ensure_ascii=False)
381 - if is_complete:
382 - # Tool call finished but </arg_value> is missing
383 - # (malformed output). Treat partial as complete value
384 - # so the diff naturally closes any open quotes.
385 - if self._is_string_type(tool_name, partial_key):
386 - val_json = json.dumps(partial_content, ensure_ascii=False)
387 - else:
388 - val_json = json.dumps(
389 - self._deserialize(partial_content.strip()),
390 - ensure_ascii=False,
391 - )
392 - parts.append(f"{key_json}: {val_json}")
393 - elif self._is_string_type(tool_name, partial_key):
394 - escaped = self._json_escape_string_content(partial_content)
395 - # Open quote but no close — more content may arrive
396 - parts.append(f'{key_json}: "{escaped}')
397 - else:
398 - # Non-string partial: include raw content, no wrapping
399 - parts.append(f"{key_json}: {partial_content}")
400 -
401 - if not parts:
402 - return "{}" if is_complete else ""
403 -
404 - joined = "{" + ", ".join(parts)
405 - if is_complete:
406 - joined += "}"
407 - return joined
408 -
409 - def _compute_args_diff(self, index: int, args_so_far: str) -> str | None:
410 - """Return new argument text not yet sent for tool *index*, or None."""
411 - if not args_so_far or len(args_so_far) <= len(
412 - self.streamed_args_for_tool[index]
413 - ):
414 - return None
415 - diff = args_so_far[len(self.streamed_args_for_tool[index]) :]
416 - self.streamed_args_for_tool[index] = args_so_far
417 - self.prev_tool_call_arr[index]["arguments"] = args_so_far
418 - return diff
419 -
420 - def _ensure_tool_state_for(self, index: int) -> None:
421 - """Grow state arrays so that *index* is valid."""
422 - while len(self._tool_call_ids) <= index:
423 - self._tool_call_ids.append(
424 - make_tool_call_id(id_type="random", func_name=None, idx=None)
425 - )
426 - while len(self.streamed_args_for_tool) <= index:
427 - self.streamed_args_for_tool.append("")
428 - while len(self.prev_tool_call_arr) <= index:
429 - self.prev_tool_call_arr.append({})
430 -
431 - def extract_tool_calls_streaming(
432 - self,
433 - previous_text: str,
434 - current_text: str,
435 - delta_text: str,
436 - previous_token_ids: Sequence[int],
437 - current_token_ids: Sequence[int],
438 - delta_token_ids: Sequence[int],
439 - request: ChatCompletionRequest,
440 - ) -> DeltaMessage | None:
441 - if not self._tools_enabled(request):
442 - return DeltaMessage(content=delta_text) if delta_text else None
443 -
444 - content = self._extract_content(current_text)
445 - regions = self._extract_tool_call_regions(current_text)
446 - tool_call_deltas: list[DeltaToolCall] = []
447 -
448 - for i, (inner_text, is_complete) in enumerate(regions):
449 - self._ensure_tool_state_for(i)
450 -
451 - # Extract tool name
452 - tool_name = self._extract_tool_name_from_region(inner_text)
453 - if not tool_name:
454 - break
455 -
456 - # Emit tool name (once per tool call)
457 - if "name" not in self.prev_tool_call_arr[i]:
458 - self.prev_tool_call_arr[i]["name"] = tool_name
459 - tool_call_deltas.append(
460 - DeltaToolCall(
461 - index=i,
462 - id=self._tool_call_ids[i],
463 - type="function",
464 - function=DeltaFunctionCall(
465 - name=tool_name,
466 - arguments="",
467 - ).model_dump(exclude_none=True),
468 - )
469 - )
470 -
471 - # Build args JSON so far, diff, emit
472 - args_so_far = self._build_args_json_so_far(
473 - tool_name, inner_text, is_complete
474 - )
475 - diff = self._compute_args_diff(i, args_so_far)
476 - if diff:
477 - tool_call_deltas.append(
478 - DeltaToolCall(
479 - index=i,
480 - function=DeltaFunctionCall(arguments=diff).model_dump(
481 - exclude_none=True
482 - ),
483 - )
484 - )
485 -
486 - # Update current_tool_id for serving layer compatibility
487 - if regions:
488 - self.current_tool_id = len(regions) - 1
489 -
490 - if content or tool_call_deltas:
491 - return DeltaMessage(
492 - content=content,
493 - tool_calls=tool_call_deltas,
494 - )
495 - return None