From d95d1dcfb975240d125b327f1eaef1cbd56ef56c Mon Sep 17 00:00:00 2001 From: Shijin Zhang <75300765+Dovis01@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:26:34 +0800 Subject: [PATCH] [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 --- .../test_anthropic_messages_conversion.py | 2 +- .../test_glm4_moe_reasoning_parser.py | 80 +++++++++++++++++++ vllm/parser/glm47_moe.py | 30 +++++++ 3 files changed, 111 insertions(+), 1 deletion(-) diff --git a/tests/entrypoints/anthropic/test_anthropic_messages_conversion.py b/tests/entrypoints/anthropic/test_anthropic_messages_conversion.py index aa68b6bff0..06efefa099 100644 --- a/tests/entrypoints/anthropic/test_anthropic_messages_conversion.py +++ b/tests/entrypoints/anthropic/test_anthropic_messages_conversion.py @@ -1445,7 +1445,7 @@ class TestDetectMergeInlineSystem: AnthropicServingMessages._should_merge_inline_system(online_renderer) is expected ) - assert mock_logger.warning.called is expected + assert mock_logger.warning_once.called is expected # ====================================================================== diff --git a/tests/reasoning/test_glm4_moe_reasoning_parser.py b/tests/reasoning/test_glm4_moe_reasoning_parser.py index 3d6f21b5e1..dcfba7e55d 100644 --- a/tests/reasoning/test_glm4_moe_reasoning_parser.py +++ b/tests/reasoning/test_glm4_moe_reasoning_parser.py @@ -1,6 +1,8 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import copy + import pytest from transformers import AutoTokenizer @@ -227,3 +229,81 @@ def test_is_reasoning_end_full_prompt( token_ids = glm45_tokenizer.convert_tokens_to_ids(tokens) check_is_reasoning_end = parser.is_reasoning_end(token_ids) assert check_is_reasoning_end == is_reasoning_end + + +GLM53_TEMPLATE = ( + "[gMASK]\n" + "{%- set effective_reasoning_effort = reasoning_effort if reasoning_effort is" + " defined and reasoning_effort in ['low', 'high'] else 'max' -%}\n" + "<|system|>Reasoning Effort: {{ effective_reasoning_effort | capitalize }}\n" + "{% for tc in m.tool_calls %}\n" + "{{- '' + tc.name -}}\n" + "{% set _args = tc.arguments %}" + "{% for k, v in _args.items() %}" + "{{ k }}{{ v }}" + "{% endfor %}\n" + "{% endfor %}\n" + "<|assistant|>{{- '' -}}" +) + +GLM53_LEAK = { + "output": "Simple question.2 + 2 = **4**", + "reasoning": "Simple question.", + "content": "2 + 2 = **4**", + "is_reasoning_end": True, +} + + +@pytest.fixture() +def glm53_style_tokenizer(glm45_tokenizer): + tokenizer = copy.copy(glm45_tokenizer) + tokenizer.chat_template = GLM53_TEMPLATE + return tokenizer + + +@pytest.mark.parametrize( + "disable_kwargs", [{"enable_thinking": False}, {"thinking": False}] +) +def test_glm53_template_forces_reasoning(disable_kwargs: dict, glm53_style_tokenizer): + parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name) + parser = parser_cls(glm53_style_tokenizer, chat_template_kwargs=disable_kwargs) + assert parser._parser_engine.thinking_enabled + + output = glm53_style_tokenizer.tokenize(GLM53_LEAK["output"]) + output_tokens: list[str] = [ + glm53_style_tokenizer.convert_tokens_to_string([token]) for token in output + ] + reasoning, content = run_reasoning_extraction(parser, output_tokens) + assert reasoning == GLM53_LEAK["reasoning"] + assert content == GLM53_LEAK["content"] + + output_ids = glm53_style_tokenizer.convert_tokens_to_ids(output) + assert parser.is_reasoning_end(output_ids) == GLM53_LEAK["is_reasoning_end"] + + +def test_glm53_template_forces_reasoning_streaming(glm53_style_tokenizer): + parser = ReasoningParserManager.get_reasoning_parser(parser_name)( + glm53_style_tokenizer, chat_template_kwargs={"enable_thinking": False} + ) + output = glm53_style_tokenizer.tokenize(GLM53_LEAK["output"]) + output_tokens: list[str] = [ + glm53_style_tokenizer.convert_tokens_to_string([token]) for token in output + ] + reasoning, content = run_reasoning_extraction(parser, output_tokens, streaming=True) + assert reasoning == GLM53_LEAK["reasoning"] + assert content == GLM53_LEAK["content"] + + +def test_glm47_template_honors_thinking_disable(glm45_tokenizer): + parser = ReasoningParserManager.get_reasoning_parser(parser_name)( + glm45_tokenizer, chat_template_kwargs={"enable_thinking": False} + ) + assert not parser._parser_engine.thinking_enabled + + output = glm45_tokenizer.tokenize(GLM53_LEAK["output"]) + output_tokens: list[str] = [ + glm45_tokenizer.convert_tokens_to_string([token]) for token in output + ] + reasoning, content = run_reasoning_extraction(parser, output_tokens) + assert reasoning is None + assert content == GLM53_LEAK["output"] diff --git a/vllm/parser/glm47_moe.py b/vllm/parser/glm47_moe.py index 00e517f2ea..9a18228fb0 100644 --- a/vllm/parser/glm47_moe.py +++ b/vllm/parser/glm47_moe.py @@ -20,6 +20,7 @@ import regex as re from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest from vllm.entrypoints.openai.responses.protocol import ResponsesRequest +from vllm.logger import init_logger from vllm.parser.engine.events import EventType from vllm.parser.engine.parser_engine import ParserEngine from vllm.parser.engine.parser_engine_config import ( @@ -28,6 +29,8 @@ from vllm.parser.engine.parser_engine_config import ( Transition, ) +logger = init_logger(__name__) + if TYPE_CHECKING: from vllm.tokenizers import TokenizerLike from vllm.tool_parsers.abstract_tool_parser import Tool @@ -77,6 +80,23 @@ def _glm47_arg_converter(raw_args: str, partial: bool) -> str: return json.dumps(params, ensure_ascii=False) +def _glm53_always_thinks(tokenizer: TokenizerLike) -> bool: + # GLM-5.3 keeps the GLM-4.x prompt and tool-call format but replaces the + # enable_thinking toggle with an always-on "Reasoning Effort:" header, so + # its generation prompt always opens and the model output carries + # no opening tag. + template = getattr(tokenizer, "chat_template", None) + return ( + isinstance(template, str) + and "[gMASK]" in template + and "Reasoning Effort:" in template + and "enable_thinking" not in template + and "" in template + and "" in template + and "" in template + ) + + @functools.cache def glm47_moe_config(thinking: bool = True) -> ParserEngineConfig: arg_tag_transitions = { @@ -192,6 +212,16 @@ class Glm47MoeParser(ParserEngine): chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {} thinking = chat_kwargs.get("thinking", None) enable_thinking = chat_kwargs.get("enable_thinking", None) + if (thinking is False or enable_thinking is False) and _glm53_always_thinks( + tokenizer + ): + logger.warning_once( + "Ignoring enable_thinking/thinking: the GLM-5.3 chat template " + "has no thinking switch, so reasoning is always on and " + "disabling extraction would leak it into the content." + ) + thinking = None + enable_thinking = None self.thinking_enabled = ( True if thinking is None and enable_thinking is None