[Bugfix][Frontend] Force reasoning mode for GLM-5.3 chat templates in the GLM MoE parser (#56994)

Signed-off-by: Shijin Zhang <75300765+Dovis01@users.noreply.github.com> Co-authored-by: Chauncey <chaunceyjiang@gmail.com>

Shijin Zhang committed Sep 29, 2026 at 21:26 UTC d95d1dcfb975240d125b327f1eaef1cbd56ef56c
3 files changed +111 -1
tests/entrypoints/anthropic/test_anthropic_messages_conversion.py
+1 -1
@@ -1445,7 +1445,7 @@ class TestDetectMergeInlineSystem:
1445 AnthropicServingMessages._should_merge_inline_system(online_renderer)
1446 is expected
1447 )
1448 - assert mock_logger.warning.called is expected
1448 + assert mock_logger.warning_once.called is expected
1449
1450
1451 # ======================================================================
tests/reasoning/test_glm4_moe_reasoning_parser.py
+80
@@ -1,6 +1,8 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 +import copy
5 +
6 import pytest
7 from transformers import AutoTokenizer
8
@@ -227,3 +229,81 @@ def test_is_reasoning_end_full_prompt(
229 token_ids = glm45_tokenizer.convert_tokens_to_ids(tokens)
230 check_is_reasoning_end = parser.is_reasoning_end(token_ids)
231 assert check_is_reasoning_end == is_reasoning_end
232 +
233 +
234 +GLM53_TEMPLATE = (
235 + "[gMASK]<sop>\n"
236 + "{%- set effective_reasoning_effort = reasoning_effort if reasoning_effort is"
237 + " defined and reasoning_effort in ['low', 'high'] else 'max' -%}\n"
238 + "<|system|>Reasoning Effort: {{ effective_reasoning_effort | capitalize }}\n"
239 + "{% for tc in m.tool_calls %}\n"
240 + "{{- '<tool_call>' + tc.name -}}\n"
241 + "{% set _args = tc.arguments %}"
242 + "{% for k, v in _args.items() %}"
243 + "<arg_key>{{ k }}</arg_key><arg_value>{{ v }}</arg_value>"
244 + "{% endfor %}</tool_call>\n"
245 + "{% endfor %}\n"
246 + "<|assistant|>{{- '<think>' -}}"
247 +)
248 +
249 +GLM53_LEAK = {
250 + "output": "Simple question.</think>2 + 2 = **4**",
251 + "reasoning": "Simple question.",
252 + "content": "2 + 2 = **4**",
253 + "is_reasoning_end": True,
254 +}
255 +
256 +
257 +@pytest.fixture()
258 +def glm53_style_tokenizer(glm45_tokenizer):
259 + tokenizer = copy.copy(glm45_tokenizer)
260 + tokenizer.chat_template = GLM53_TEMPLATE
261 + return tokenizer
262 +
263 +
264 +@pytest.mark.parametrize(
265 + "disable_kwargs", [{"enable_thinking": False}, {"thinking": False}]
266 +)
267 +def test_glm53_template_forces_reasoning(disable_kwargs: dict, glm53_style_tokenizer):
268 + parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
269 + parser = parser_cls(glm53_style_tokenizer, chat_template_kwargs=disable_kwargs)
270 + assert parser._parser_engine.thinking_enabled
271 +
272 + output = glm53_style_tokenizer.tokenize(GLM53_LEAK["output"])
273 + output_tokens: list[str] = [
274 + glm53_style_tokenizer.convert_tokens_to_string([token]) for token in output
275 + ]
276 + reasoning, content = run_reasoning_extraction(parser, output_tokens)
277 + assert reasoning == GLM53_LEAK["reasoning"]
278 + assert content == GLM53_LEAK["content"]
279 +
280 + output_ids = glm53_style_tokenizer.convert_tokens_to_ids(output)
281 + assert parser.is_reasoning_end(output_ids) == GLM53_LEAK["is_reasoning_end"]
282 +
283 +
284 +def test_glm53_template_forces_reasoning_streaming(glm53_style_tokenizer):
285 + parser = ReasoningParserManager.get_reasoning_parser(parser_name)(
286 + glm53_style_tokenizer, chat_template_kwargs={"enable_thinking": False}
287 + )
288 + output = glm53_style_tokenizer.tokenize(GLM53_LEAK["output"])
289 + output_tokens: list[str] = [
290 + glm53_style_tokenizer.convert_tokens_to_string([token]) for token in output
291 + ]
292 + reasoning, content = run_reasoning_extraction(parser, output_tokens, streaming=True)
293 + assert reasoning == GLM53_LEAK["reasoning"]
294 + assert content == GLM53_LEAK["content"]
295 +
296 +
297 +def test_glm47_template_honors_thinking_disable(glm45_tokenizer):
298 + parser = ReasoningParserManager.get_reasoning_parser(parser_name)(
299 + glm45_tokenizer, chat_template_kwargs={"enable_thinking": False}
300 + )
301 + assert not parser._parser_engine.thinking_enabled
302 +
303 + output = glm45_tokenizer.tokenize(GLM53_LEAK["output"])
304 + output_tokens: list[str] = [
305 + glm45_tokenizer.convert_tokens_to_string([token]) for token in output
306 + ]
307 + reasoning, content = run_reasoning_extraction(parser, output_tokens)
308 + assert reasoning is None
309 + assert content == GLM53_LEAK["output"]
vllm/parser/glm47_moe.py
+30
@@ -20,6 +20,7 @@ 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.logger import init_logger
24 from vllm.parser.engine.events import EventType
25 from vllm.parser.engine.parser_engine import ParserEngine
26 from vllm.parser.engine.parser_engine_config import (
@@ -28,6 +29,8 @@ from vllm.parser.engine.parser_engine_config import (
29 Transition,
30 )
31
32 +logger = init_logger(__name__)
33 +
34 if TYPE_CHECKING:
35 from vllm.tokenizers import TokenizerLike
36 from vllm.tool_parsers.abstract_tool_parser import Tool
@@ -77,6 +80,23 @@ def _glm47_arg_converter(raw_args: str, partial: bool) -> str:
80 return json.dumps(params, ensure_ascii=False)
81
82
83 +def _glm53_always_thinks(tokenizer: TokenizerLike) -> bool:
84 + # GLM-5.3 keeps the GLM-4.x prompt and tool-call format but replaces the
85 + # enable_thinking toggle with an always-on "Reasoning Effort:" header, so
86 + # its generation prompt always opens <think> and the model output carries
87 + # no opening tag.
88 + template = getattr(tokenizer, "chat_template", None)
89 + return (
90 + isinstance(template, str)
91 + and "[gMASK]<sop>" in template
92 + and "Reasoning Effort:" in template
93 + and "enable_thinking" not in template
94 + and "<tool_call>" in template
95 + and "<arg_key>" in template
96 + and "<arg_value>" in template
97 + )
98 +
99 +
100 @functools.cache
101 def glm47_moe_config(thinking: bool = True) -> ParserEngineConfig:
102 arg_tag_transitions = {
@@ -192,6 +212,16 @@ class Glm47MoeParser(ParserEngine):
212 chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {}
213 thinking = chat_kwargs.get("thinking", None)
214 enable_thinking = chat_kwargs.get("enable_thinking", None)
215 + if (thinking is False or enable_thinking is False) and _glm53_always_thinks(
216 + tokenizer
217 + ):
218 + logger.warning_once(
219 + "Ignoring enable_thinking/thinking: the GLM-5.3 chat template "
220 + "has no thinking switch, so reasoning is always on and "
221 + "disabling extraction would leak it into the content."
222 + )
223 + thinking = None
224 + enable_thinking = None
225 self.thinking_enabled = (
226 True
227 if thinking is None and enable_thinking is None