[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