[Parser] Fix: correct parser frontend handling of reasoning end and boundary tokens (#56635)
Signed-off-by: DEV TYAGI <devtyagi3909@gmail.com>
dev committed
Sep 16, 2026 at 13:47 UTC
8be5205abbabf4c377c603d6c4180a99373f6415
3 files changed
+135
-6
tests/parser/engine/test_glm47_moe.py
new
+108
@@ -0,0 +1,108 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Reasoning-boundary tests for the GLM-4.7 parser.
4
+
5
+GLM can transition straight from reasoning into a tool call without ever
6
+emitting ``</think>``, so ``<tool_call>`` is an implicit reasoning
7
+terminator. These cover that path plus the multi-turn prompts where an
8
+earlier turn's markers must not be read as the current turn's state.
9
+"""
10
+
11
+import pytest
12
+
13
+from tests.parser.engine.conftest import make_mock_tokenizer
14
+from vllm.parser.glm47_moe import (
15
+ THINK_END,
16
+ THINK_START,
17
+ TOOL_CALL_END,
18
+ TOOL_CALL_START,
19
+ Glm47MoeParser,
20
+)
21
+
22
+THINK_S, THINK_E = 1000, 1001
23
+TOOL_S, TOOL_E = 1002, 1003
24
+ASSISTANT, OBSERVATION, USER = 1004, 1005, 1006
25
+TEXT = 42 # stand-in for an ordinary reasoning/content token
26
+
27
+VOCAB = {
28
+ THINK_START: THINK_S,
29
+ THINK_END: THINK_E,
30
+ TOOL_CALL_START: TOOL_S,
31
+ TOOL_CALL_END: TOOL_E,
32
+ "<|assistant|>": ASSISTANT,
33
+ "<|observation|>": OBSERVATION,
34
+ "<|user|>": USER,
35
+}
36
+
37
+
38
+@pytest.fixture
39
+def parser():
40
+ return Glm47MoeParser(make_mock_tokenizer(VOCAB))
41
+
42
+
43
+@pytest.fixture
44
+def no_thinking_parser():
45
+ return Glm47MoeParser(
46
+ make_mock_tokenizer(VOCAB),
47
+ chat_template_kwargs={"thinking": False},
48
+ )
49
+
50
+
51
+class TestIsReasoningEnd:
52
+ def test_open_reasoning(self, parser):
53
+ assert not parser.is_reasoning_end([THINK_S, TEXT])
54
+
55
+ def test_think_end(self, parser):
56
+ assert parser.is_reasoning_end([THINK_S, TEXT, THINK_E, TEXT])
57
+
58
+ def test_tool_call_without_think_end(self, parser):
59
+ """Reasoning -> tool call with no ``</think>`` still ends reasoning."""
60
+ assert parser.is_reasoning_end([THINK_S, TEXT, TOOL_S, TEXT])
61
+
62
+ def test_previous_turn_tool_call_ignored(self, parser):
63
+ """A finished tool call from an earlier turn says nothing about the
64
+ turn currently being generated."""
65
+ prompt = [THINK_S, TEXT, TOOL_S, TEXT, TOOL_E, OBSERVATION, TEXT, ASSISTANT]
66
+ assert not parser.is_reasoning_end(prompt)
67
+
68
+ def test_previous_turn_think_end_ignored(self, parser):
69
+ prompt = [THINK_S, TEXT, THINK_E, TEXT, USER, TEXT, ASSISTANT]
70
+ assert not parser.is_reasoning_end(prompt)
71
+
72
+ def test_empty_input(self, parser):
73
+ assert not parser.is_reasoning_end([])
74
+
75
+ def test_thinking_disabled(self, no_thinking_parser):
76
+ assert no_thinking_parser.is_reasoning_end([THINK_S, TEXT])
77
+
78
+
79
+class TestExtractContentIds:
80
+ def test_think_end_wins_over_later_tool_call(self, parser):
81
+ """Content between ``</think>`` and a tool call must survive."""
82
+ ids = [THINK_S, TEXT, THINK_E, 20, 21, TOOL_S, 30, TOOL_E]
83
+ assert parser.extract_content_ids(ids) == [20, 21, TOOL_S, 30, TOOL_E]
84
+
85
+ def test_every_tool_call_after_think_end_kept(self, parser):
86
+ ids = [THINK_E, 20, TOOL_S, 30, TOOL_E, 21, TOOL_S, 31, TOOL_E]
87
+ assert parser.extract_content_ids(ids) == ids[1:]
88
+
89
+ def test_falls_back_to_tool_call(self, parser):
90
+ """Without ``</think>``, content starts at the opener itself so the
91
+ tool parser still receives a well-formed call."""
92
+ ids = [THINK_S, TEXT, TOOL_S, 30, TOOL_E]
93
+ assert parser.extract_content_ids(ids) == [TOOL_S, 30, TOOL_E]
94
+
95
+ def test_falls_back_to_first_tool_call_of_turn(self, parser):
96
+ ids = [THINK_S, TEXT, TOOL_S, 30, TOOL_E, TOOL_S, 31, TOOL_E]
97
+ assert parser.extract_content_ids(ids) == ids[2:]
98
+
99
+ def test_previous_turn_tool_call_ignored(self, parser):
100
+ ids = [TOOL_S, 30, TOOL_E, OBSERVATION, TEXT, ASSISTANT, THINK_S, TEXT]
101
+ assert parser.extract_content_ids(ids) == ids
102
+
103
+ def test_no_markers_returns_input_ids(self, parser):
104
+ assert parser.extract_content_ids([20, 21]) == [20, 21]
105
+
106
+ def test_thinking_disabled(self, no_thinking_parser):
107
+ ids = [THINK_S, TEXT, TOOL_S]
108
+ assert no_thinking_parser.extract_content_ids(ids) == ids
vllm/parser/engine/parser_engine.py
+19
@@ -672,11 +672,30 @@ class ParserEngine(Parser):
672
return not wait_for_reasoning
673
674
def extract_content_ids(self, input_ids: list[int]) -> list[int]:
675
+ config = self.parser_engine_config
676
+ wait_for_reasoning = config.wait_for_reasoning
677
+ if wait_for_reasoning is None:
678
+ wait_for_reasoning = config.initial_state is ParserState.REASONING
679
+ if not wait_for_reasoning:
680
+ return input_ids
681
+
682
end_id = self._reasoning_end_token_id
683
if end_id is not None:
684
for i in range(len(input_ids) - 1, -1, -1):
685
if input_ids[i] == end_id:
686
return input_ids[i + 1 :]
687
+
688
+ end_ids = self._reasoning_end_token_ids
689
+ if end_ids:
690
+ turn_start = 0
691
+ boundary_ids = self._turn_boundary_token_ids
692
+ for i in range(len(input_ids) - 1, -1, -1):
693
+ if input_ids[i] in boundary_ids:
694
+ turn_start = i + 1
695
+ break
696
+ for i in range(turn_start, len(input_ids)):
697
+ if input_ids[i] in end_ids:
698
+ return input_ids[i:]
699
return input_ids
700
701
def get_streaming_fallback_content(
vllm/parser/glm47_moe.py
+8
-6
@@ -41,6 +41,13 @@ ARG_KEY_END = "</arg_key>"
41
ARG_VALUE_START = "<arg_value>"
42
ARG_VALUE_END = "</arg_value>"
43
44
+# Special tokens that delimit conversation turns in a rendered GLM prompt.
45
+# Reasoning markers belonging to earlier turns must not be mistaken for the
46
+# state of the turn currently being generated.
47
+GLM_TURN_BOUNDARIES = frozenset(
48
+ ("<|system|>", "<|user|>", "<|assistant|>", "<|observation|>")
49
+)
50
+
51
_ARG_RE = re.compile(
52
r"<arg_key>(?P<key>.*?)</arg_key>\s*"
53
r"<arg_value>(?P<value>.*?)</arg_value>",
@@ -123,7 +130,6 @@ def glm47_moe_config(thinking: bool = True) -> ParserEngineConfig:
130
return ParserEngineConfig(
131
name="glm47_moe",
132
initial_state=ParserState.REASONING if thinking else ParserState.CONTENT,
126
- wait_for_reasoning=thinking,
133
terminals={
134
**reasoning_terminals,
135
"TOOL_START": TOOL_CALL_START,
@@ -166,6 +172,7 @@ def glm47_moe_config(thinking: bool = True) -> ParserEngineConfig:
172
),
173
**arg_tag_transitions,
174
},
175
+ turn_boundary_tokens=GLM_TURN_BOUNDARIES,
176
arg_converter=_glm47_arg_converter,
177
stream_arg_deltas=True,
178
tool_args_json=False,
@@ -207,11 +214,6 @@ class Glm47MoeParser(ParserEngine):
214
self._tool_slots[idx].name = self._tool_slots[idx].name.strip()
215
super()._handle_tool_end(event, deltas)
216
210
- def extract_content_ids(self, input_ids: list[int]) -> list[int]:
211
- if not self.thinking_enabled:
212
- return input_ids
213
- return super().extract_content_ids(input_ids)
214
-
217
def extract_reasoning(
218
self,
219
model_output: str,