[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,