[Refactor] Derive is_reasoning_end from the engine grammar (#56200)

Signed-off-by: sfeng33 <4florafeng@gmail.com>

Flora Feng committed Sep 15, 2026 at 06:07 UTC 24bfbd5d4d530a2657a8925e5d12e86b1ccb9cca
13 files changed +111 -117
tests/parser/engine/test_gemma4_streaming_reasoning.py
+33
@@ -1616,3 +1616,36 @@ class TestCommaInStringValueRegression:
1616 assert result.tools_called is True
1617 args = json.loads(result.tool_calls[0].function.arguments)
1618 assert args["destination"] == "456 Oakwood Avenue, Rivermist, 83214"
1619 +
1620 +
1621 +class TestGemma4IsReasoningEnd:
1622 + @staticmethod
1623 + def _parser(thinking: bool):
1624 + return Gemma4Parser(
1625 + _make_tokenizer(_PLAIN_ANSWER_TOKENS),
1626 + chat_template_kwargs={"enable_thinking": thinking},
1627 + )
1628 +
1629 + @pytest.mark.parametrize(
1630 + ("ids", "thinking", "ended"),
1631 + [
1632 + ([CHANNEL_START_ID, 3000, CHANNEL_END_ID], True, True),
1633 + ([CHANNEL_START_ID, 3000, TOOL_CALL_START_ID], True, True),
1634 + ([CHANNEL_START_ID, 3000], True, False),
1635 + ([NEW_TURN_ID, 9100], True, False),
1636 + ([CHANNEL_END_ID, NEW_TURN_ID, 9100], True, False),
1637 + ([NEW_TURN_ID, 9100], False, True),
1638 + # The bundled template's thinking-off prompt: closed channel.
1639 + ([NEW_TURN_ID, 9100, CHANNEL_START_ID, 3000, CHANNEL_END_ID], False, True),
1640 + # A marker still wins with thinking off: the grammar honours it.
1641 + ([CHANNEL_START_ID, 3000], False, False),
1642 + # A tool-call opener after the channel closed: still ended.
1643 + (
1644 + [CHANNEL_START_ID, 3000, CHANNEL_END_ID, TOOL_CALL_START_ID, 9200],
1645 + True,
1646 + True,
1647 + ),
1648 + ],
1649 + )
1650 + def test_is_reasoning_end(self, ids, thinking, ended):
1651 + assert self._parser(thinking).is_reasoning_end(ids) is ended
tests/parser/engine/test_qwen3_reasoning.py
+39 -3
@@ -180,9 +180,8 @@ class TestIsReasoningEnd:
180 """Tool examples before the generation <think> must not end reasoning."""
181 assert not parser.is_reasoning_end([_TOOL_CALL_ID, _TEXT_ID, _THINK_START_ID])
182
183 - def test_paired_tool_call_not_end(self, parser):
184 - """Paired <tool_call>...</tool_call> (from template) is NOT end."""
185 - assert not parser.is_reasoning_end(
183 + def test_completed_tool_call_after_open_think_is_end(self, parser):
184 + assert parser.is_reasoning_end(
185 [_THINK_START_ID, 1, _TOOL_CALL_ID, 2, _TOOL_CALL_END_ID]
186 )
187
@@ -196,6 +195,43 @@ class TestIsReasoningEnd:
195 assert not parser.is_reasoning_end([])
196
197
198 +class TestIsReasoningEndDerivedFromGrammar:
199 + @pytest.mark.parametrize(
200 + ("ids", "thinking", "ended"),
201 + [
202 + # Explicit close, and the implicit close on <tool_call>.
203 + ([_THINK_START_ID, 1, _THINK_END_ID], True, True),
204 + ([_THINK_START_ID, 1, _TOOL_CALL_ID], True, True),
205 + # Still inside the block.
206 + ([_THINK_START_ID, 1], True, False),
207 + ([_THINK_END_ID, _THINK_START_ID, 1], True, False),
208 + # Uncommitted: a bare turn, an empty sequence. Thinking on starts
209 + # in REASONING, so reasoning is expected.
210 + ([_IM_END_ID, _IM_START_ID, 1], True, False),
211 + ([], True, False),
212 + # Thinking off starts in CONTENT with no way into REASONING, so
213 + # a <think> the template left behind is inert, and a silent tail
214 + # is ended because no block is expected.
215 + ([_THINK_START_ID, 1], False, True),
216 + ([_THINK_END_ID, _THINK_START_ID, 1], False, True),
217 + ([_IM_END_ID, _IM_START_ID, 1], False, True),
218 + ([], False, True),
219 + ],
220 + )
221 + def test_is_reasoning_end(self, mock_tokenizer, ids, thinking, ended):
222 + parser = Qwen3Parser(
223 + mock_tokenizer, chat_template_kwargs={"enable_thinking": thinking}
224 + )
225 + assert parser.is_reasoning_end(ids) is ended
226 +
227 + @pytest.mark.parametrize("thinking", [True, False])
228 + def test_wait_for_reasoning_follows_thinking(self, mock_tokenizer, thinking):
229 + parser = Qwen3Parser(
230 + mock_tokenizer, chat_template_kwargs={"enable_thinking": thinking}
231 + )
232 + assert parser.parser_engine_config.wait_for_reasoning is thinking
233 +
234 +
235 class TestIsReasoningEndTurnBoundaries:
236 """Reasoning markers from earlier turns must not mark the new turn's
237 reasoning as finished (think-marker injection / interleaved-thinking
vllm/parser/deepseek_v32.py
+1
@@ -53,6 +53,7 @@ def deepseek_v32_config() -> ParserEngineConfig:
53 return ParserEngineConfig(
54 name="deepseek_v32",
55 initial_state=ParserState.CONTENT,
56 + wait_for_reasoning=False,
57 terminals={
58 "TOOL_START": DSML_FUNC_START,
59 "TOOL_END": DSML_FUNC_END,
vllm/parser/deepseek_v4.py
+1
@@ -140,6 +140,7 @@ def deepseek_v4_config(thinking: bool = False) -> ParserEngineConfig:
140 return ParserEngineConfig(
141 name="deepseek_v4",
142 initial_state=ParserState.REASONING if thinking else ParserState.CONTENT,
143 + wait_for_reasoning=thinking,
144 terminals={
145 "THINK_START": DSML_THINK_START,
146 "THINK_END": DSML_THINK_END,
vllm/parser/engine/parser_engine.py
+20 -16
@@ -648,24 +648,28 @@ class ParserEngine(Parser):
648 return None
649
650 def is_reasoning_end(self, input_ids: list[int]) -> bool:
651 - end_id = self._reasoning_end_token_id
651 + config = self.parser_engine_config
652 + wait_for_reasoning = config.wait_for_reasoning
653 + if wait_for_reasoning is None:
654 + wait_for_reasoning = config.initial_state is ParserState.REASONING
655 + start_transition = config.transitions.get((config.initial_state, "THINK_START"))
656 + start_opens_reasoning = (
657 + start_transition is not None
658 + and start_transition.next_state is ParserState.REASONING
659 + )
660 + end_ids = self._reasoning_end_token_ids
661 start_id = self._reasoning_start_token_id
653 - if end_id is not None:
654 - if not input_ids:
655 - return self.parser_engine_config.initial_state != ParserState.REASONING
656 - boundary_ids = self._turn_boundary_token_ids
657 - for i in range(len(input_ids) - 1, -1, -1):
658 - token_id = input_ids[i]
659 - if token_id == end_id:
660 - return True
661 - if start_id is not None and token_id == start_id:
662 + boundary_ids = self._turn_boundary_token_ids
663 + for token_id in reversed(input_ids):
664 + if token_id in end_ids:
665 + return True
666 + if token_id == start_id:
667 + if start_opens_reasoning:
668 return False
663 - if token_id in boundary_ids:
664 - return (
665 - self.parser_engine_config.initial_state != ParserState.REASONING
666 - )
667 - return False
668 - return self._reasoning_ended
669 + break
670 + if token_id in boundary_ids:
671 + break
672 + return not wait_for_reasoning
673
674 def extract_content_ids(self, input_ids: list[int]) -> list[int]:
675 end_id = self._reasoning_end_token_id
vllm/parser/engine/parser_engine_config.py
+2
@@ -74,6 +74,8 @@ class ParserEngineConfig:
74
75 initial_state: ParserState = ParserState.CONTENT
76
77 + wait_for_reasoning: bool | None = None
78 +
79 arg_converter: Callable[[str, bool], str] | None = None
80
81 stream_arg_deltas: bool = True
vllm/parser/gemma4.py
+7 -27
@@ -293,10 +293,14 @@ def _gemma4_arg_converter(raw_args: str, partial: bool) -> str:
293
294
295 @functools.cache
296 -def gemma4_config() -> ParserEngineConfig:
296 +def gemma4_config(thinking: bool = False) -> ParserEngineConfig:
297 return ParserEngineConfig(
298 name="gemma4",
299 initial_state=ParserState.CONTENT,
300 + # Decides a silent tail: the E-series HF templates write no marker
301 + # with thinking off, and no template does after a tool response.
302 + wait_for_reasoning=thinking,
303 + turn_boundary_tokens=frozenset({"<|turn>", "<|tool_response>"}),
304 terminals={
305 "THINK_START": CHANNEL_START,
306 "THINK_END": CHANNEL_END,
@@ -417,11 +421,11 @@ class Gemma4Parser(ParserEngine):
421 **kwargs,
422 ) -> None:
423 chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {}
420 - self._thinking_enabled = chat_kwargs.get("enable_thinking", False)
424 + thinking = chat_kwargs.get("enable_thinking", False)
425 super().__init__(
426 tokenizer,
427 tools,
424 - parser_engine_config=gemma4_config(),
428 + parser_engine_config=gemma4_config(thinking=thinking),
429 **kwargs,
430 )
431 vocab = self.vocab
@@ -472,30 +476,6 @@ class Gemma4Parser(ParserEngine):
476
477 return delta_text, delta_token_ids
478
475 - def is_reasoning_end(self, input_ids: list[int]) -> bool:
476 - end_id = self._reasoning_end_token_id
477 - start_id = self._reasoning_start_token_id
478 - tool_call_id = self._tool_call_token_id
479 - new_turn_id = self._new_turn_token_id
480 - tool_response_id = self._tool_response_token_id
481 -
482 - if end_id is not None and not input_ids:
483 - return self.parser_engine_config.initial_state != ParserState.REASONING
484 -
485 - for i in range(len(input_ids) - 1, -1, -1):
486 - tid = input_ids[i]
487 - if start_id is not None and tid == start_id:
488 - return False
489 - if tool_call_id is not None and tid == tool_call_id:
490 - return True
491 - if new_turn_id is not None and tid == new_turn_id:
492 - return not self._thinking_enabled
493 - if tool_response_id is not None and tid == tool_response_id:
494 - return not self._thinking_enabled
495 - if end_id is not None and tid == end_id:
496 - return True
497 - return True
498 -
479 def _prompt_ends_in_open_reasoning(self, prompt_token_ids: Sequence[int]) -> bool:
480 """Whether the prompt tail is inside an open ``<|channel>`` block.
481
vllm/parser/glm47_moe.py
+1 -5
@@ -123,6 +123,7 @@ def glm47_moe_config(thinking: bool = True) -> ParserEngineConfig:
123 return ParserEngineConfig(
124 name="glm47_moe",
125 initial_state=ParserState.REASONING if thinking else ParserState.CONTENT,
126 + wait_for_reasoning=thinking,
127 terminals={
128 **reasoning_terminals,
129 "TOOL_START": TOOL_CALL_START,
@@ -206,11 +207,6 @@ class Glm47MoeParser(ParserEngine):
207 self._tool_slots[idx].name = self._tool_slots[idx].name.strip()
208 super()._handle_tool_end(event, deltas)
209
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 -
210 def extract_content_ids(self, input_ids: list[int]) -> list[int]:
211 if not self.thinking_enabled:
212 return input_ids
vllm/parser/inkling.py
+1
@@ -282,6 +282,7 @@ def inkling_config() -> ParserEngineConfig:
282 # `<|message_model|>`. Non-streaming parsing receives only the generated
283 # suffix, so begin in the corresponding message-header state as well.
284 initial_state=ParserState.MESSAGE_HEADER,
285 + wait_for_reasoning=True,
286 terminals=terminals,
287 # Inkling content-kind markers are the grammar. When the engine is
288 # used through DelegatingParser, the reasoning pass can hand the tool
vllm/parser/kimi_k2.py
+1 -18
@@ -81,6 +81,7 @@ def kimi_k2_config(thinking: bool = True) -> ParserEngineConfig:
81 return ParserEngineConfig(
82 name="kimi_k2",
83 initial_state=ParserState.REASONING if thinking else ParserState.CONTENT,
84 + wait_for_reasoning=thinking,
85 terminals={
86 **reasoning_terminals,
87 "TOOL_SECTION_START": TOOL_SECTION_START,
@@ -236,24 +237,6 @@ class KimiK2Parser(ParserEngine):
237 def _extract_args_json(self, raw_args: str, func_name: str) -> str:
238 return raw_args.strip() or "{}"
239
239 - def is_reasoning_end(self, input_ids: list[int]) -> bool:
240 - if not self.thinking_enabled:
241 - return True
242 -
243 - start_id = self._start_token_id
244 - end_id = self._end_token_id
245 - tool_section_id = self._tool_section_start_token_id
246 -
247 - for i in range(len(input_ids) - 1, -1, -1):
248 - token_id = input_ids[i]
249 - if start_id is not None and token_id == start_id:
250 - return False
251 - if end_id is not None and token_id == end_id:
252 - return True
253 - if tool_section_id is not None and token_id == tool_section_id:
254 - return True
255 - return False
256 -
240 def extract_content_ids(self, input_ids: list[int]) -> list[int]:
241 if not self.thinking_enabled:
242 return input_ids
vllm/parser/minimax_m2.py
+1
@@ -95,6 +95,7 @@ def minimax_m2_config() -> ParserEngineConfig:
95 return ParserEngineConfig(
96 name="minimax_m2",
97 initial_state=ParserState.REASONING,
98 + wait_for_reasoning=True,
99 terminals={
100 "THINK_START": THINK_START,
101 "THINK_END": THINK_END,
vllm/parser/mistral.py
+1 -18
@@ -193,6 +193,7 @@ def mistral_config(
193 return ParserEngineConfig(
194 name=name,
195 initial_state=ParserState.CONTENT,
196 + wait_for_reasoning=reasoning_encoding != "none",
197 terminals={
198 **reasoning_terminals,
199 "TOOL_CALLS": _TOOL_CALLS,
@@ -891,24 +892,6 @@ class MistralParser(ParserEngine):
892
893 return (delta_to_be_parsed, "")
894
894 - def is_reasoning_end(self, input_ids: list[int]) -> bool:
895 - if self._reasoning_encoding == "none":
896 - return True
897 - if super().is_reasoning_end(input_ids):
898 - return True
899 - # [TOOL_CALLS] acts as an implicit reasoning-end marker
900 - if self.bot_token_id is not None:
901 - reasoning_start_id = self._reasoning_start_token_id
902 - for i in range(len(input_ids) - 1, -1, -1):
903 - if (
904 - reasoning_start_id is not None
905 - and input_ids[i] == reasoning_start_id
906 - ):
907 - return False
908 - if input_ids[i] == self.bot_token_id:
909 - return True
910 - return False
911 -
895 def extract_reasoning(
896 self,
897 model_output: str,
vllm/parser/qwen3.py
+3 -30
@@ -100,6 +100,7 @@ def qwen3_config(
100 return ParserEngineConfig(
101 name=name,
102 initial_state=ParserState.REASONING if thinking else ParserState.CONTENT,
103 + wait_for_reasoning=thinking,
104 turn_boundary_tokens=turn_boundary_tokens,
105 terminals={
106 # Reasoning terminals
@@ -205,8 +206,8 @@ class Qwen3Parser(ParserEngine):
206 """Qwen3 parser: ``<think>``/``</think>`` reasoning +
207 ``<tool_call>`` XML tool calls in a single engine.
208
208 - - ``<tool_call>`` as implicit reasoning end
209 - - Unpaired ``<tool_call>`` token ID detection for ``is_reasoning_end``
209 + - ``<tool_call>`` as implicit reasoning end (a grammar transition, so it
210 + also feeds ``is_reasoning_end`` and the structured-output gate)
211
212 Subclasses that share the grammar but differ only in the four wrapper
213 token strings (reasoning + tool-call) override the class attributes
@@ -245,9 +246,6 @@ class Qwen3Parser(ParserEngine):
246 tools,
247 **kwargs,
248 )
248 - vocab = self.vocab
249 - self._tool_call_token_id: int | None = vocab.get(self.TOOL_START)
250 - self._tool_call_end_token_id: int | None = vocab.get(self.TOOL_END)
249
250 def extract_reasoning(
251 self,
@@ -257,28 +255,3 @@ class Qwen3Parser(ParserEngine):
255 if not self.thinking_enabled:
256 return None, model_output
257 return super().extract_reasoning(model_output, request)
260 -
261 - def is_reasoning_end(self, input_ids: list[int]) -> bool:
262 - if super().is_reasoning_end(input_ids):
263 - return True
264 - tool_call_id = self._tool_call_token_id
265 - tool_call_end_id = self._tool_call_end_token_id
266 - reasoning_start_id = self._reasoning_start_token_id
267 - boundary_ids = self._turn_boundary_token_ids
268 - if tool_call_id is not None:
269 - for i in range(len(input_ids) - 1, -1, -1):
270 - if (
271 - reasoning_start_id is not None
272 - and input_ids[i] == reasoning_start_id
273 - ):
274 - return False
275 - if input_ids[i] in boundary_ids:
276 - return False
277 - if input_ids[i] == tool_call_id:
278 - if tool_call_end_id is not None and any(
279 - input_ids[j] == tool_call_end_id
280 - for j in range(i + 1, len(input_ids))
281 - ):
282 - continue
283 - return True
284 - return False