[Bugfix] Gracefully handle unsupported reasoning_effort in chat templates (#54022)

Signed-off-by: frankie <wangyongsheng686@gmail.com> Co-authored-by: Trae Agent <noreply@trae.ai>

frankie committed Sep 7, 2026 at 12:11 UTC 9cc7793e32f8340e111a6c822f231aa526a8ea21
2 files changed +121 -4
tests/renderers/test_hf.py
+101
@@ -1,6 +1,7 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 +import jinja2
5 import pytest
6
7 from vllm.config import ModelConfig
@@ -12,6 +13,7 @@ from vllm.renderers.hf import (
13 _convert_developer_to_system,
14 _detect_developer_role_support,
15 _get_hf_base_chat_template_params,
16 + _template_error_reason,
17 _try_extract_ast,
18 resolve_chat_template,
19 resolve_chat_template_content_format,
@@ -844,6 +846,105 @@ SYSTEM_FIRST_TEMPLATE = (
846 )
847
848
849 +EFFORT_VALIDATING_TEMPLATE = (
850 + "{% if reasoning_effort is defined and "
851 + "reasoning_effort not in ['xhigh', 'medium', 'low'] %}"
852 + "{{ raise_exception('Unexpected reasoning effort ' + reasoning_effort + "
853 + "'. Supported types are xhigh (default), medium, and low.') }}"
854 + "{% endif %}"
855 + "{% for message in messages %}"
856 + "{{ message['role'] }}: {{ message['content'] }}\n"
857 + "{% endfor %}"
858 +)
859 +
860 +EFFORT_BREAKING_TEMPLATE = (
861 + "{% if reasoning_effort is defined %}"
862 + "{{ raise_exception('Template broke for an unrelated reason') }}"
863 + "{% endif %}"
864 + "{% for message in messages %}"
865 + "{{ message['role'] }}: {{ message['content'] }}\n"
866 + "{% endfor %}"
867 +)
868 +
869 +
870 +class TestApplyChatTemplateEffortTolerant:
871 + """Chat templates that reject unsupported reasoning_effort values should
872 + surface a 400-style client error instead of crashing the request."""
873 +
874 + @pytest.fixture
875 + def model_config(self):
876 + return ModelConfig(
877 + "facebook/opt-125m",
878 + tokenizer="facebook/opt-125m",
879 + tokenizer_mode="auto",
880 + trust_remote_code=False,
881 + dtype="float16",
882 + )
883 +
884 + @pytest.fixture
885 + def tokenizer(self):
886 + return get_tokenizer("facebook/opt-125m")
887 +
888 + def test_unsupported_effort_raises_bad_request(self, model_config, tokenizer):
889 + conversation = [{"role": "user", "content": "Hello"}]
890 + with pytest.raises(
891 + VLLMValidationError,
892 + match="Unexpected reasoning effort high",
893 + ) as excinfo:
894 + safe_apply_chat_template(
895 + model_config,
896 + tokenizer,
897 + conversation,
898 + chat_template=EFFORT_VALIDATING_TEMPLATE,
899 + tokenize=False,
900 + reasoning_effort="high",
901 + )
902 + # The client sees exactly the template's own reason, with no wrapper
903 + # noise. It tells them which values are supported.
904 + assert str(excinfo.value) == (
905 + "Unexpected reasoning effort high. Supported types are xhigh "
906 + "(default), medium, and low."
907 + )
908 +
909 + def test_supported_effort_accepted(self, model_config, tokenizer):
910 + conversation = [{"role": "user", "content": "Hello"}]
911 + result = safe_apply_chat_template(
912 + model_config,
913 + tokenizer,
914 + conversation,
915 + chat_template=EFFORT_VALIDATING_TEMPLATE,
916 + tokenize=False,
917 + reasoning_effort="medium",
918 + )
919 + assert result == "user: Hello\n"
920 +
921 + def test_non_effort_template_error_is_bad_request(self, model_config, tokenizer):
922 + conversation = [{"role": "user", "content": "Hello"}]
923 + with pytest.raises(VLLMValidationError, match="unrelated reason"):
924 + safe_apply_chat_template(
925 + model_config,
926 + tokenizer,
927 + conversation,
928 + chat_template=EFFORT_BREAKING_TEMPLATE,
929 + tokenize=False,
930 + reasoning_effort="high",
931 + )
932 +
933 +
934 +def test_template_error_reason_prefers_template_error():
935 + # Upstream wrappers must not hide the template's own reason from clients.
936 + reason = jinja2.TemplateError("Unexpected reasoning effort high")
937 + wrapper = ValueError(f"An error occurred while rendering the template: {reason}")
938 + wrapper.__cause__ = reason
939 + assert _template_error_reason(wrapper) == str(reason)
940 + assert _template_error_reason(reason) == str(reason)
941 +
942 +
943 +def test_template_error_reason_falls_back_to_message():
944 + err = ValueError("plain failure")
945 + assert _template_error_reason(err) == "plain failure"
946 +
947 +
948 class TestConsolidateSystemMessages:
949 def test_no_system_messages_unchanged(self):
950 conversation = [
vllm/renderers/hf.py
+20 -4
@@ -27,6 +27,7 @@ from vllm.entrypoints.chat_utils import (
27 parse_chat_messages,
28 parse_chat_messages_async,
29 )
30 +from vllm.exceptions import VLLMValidationError
31 from vllm.inputs import EmbedsPrompt
32 from vllm.inputs.engine import MultiModalInput
33 from vllm.logger import init_logger
@@ -684,6 +685,18 @@ def resolve_chat_template_kwargs(
685 return {k: v for k, v in chat_template_kwargs.items() if k in accept_vars}
686
687
688 +def _template_error_reason(exc: BaseException) -> str:
689 + # Extract the most specific reason from a chat template error chain.
690 + seen: set[int] = set()
691 + current: BaseException | None = exc
692 + while current is not None and id(current) not in seen:
693 + seen.add(id(current))
694 + if isinstance(current, jinja2.TemplateError):
695 + return str(current)
696 + current = current.__cause__ or current.__context__
697 + return str(exc)
698 +
699 +
700 @overload
701 def safe_apply_chat_template(
702 model_config: ModelConfig,
@@ -806,10 +819,13 @@ def safe_apply_chat_template(
819 **resolved_kwargs,
820 )
821 except Exception as e:
809 - logger.exception(
810 - "An error occurred in `transformers` while applying chat template"
811 - )
812 - raise ValueError(str(e)) from e
822 + # Chat templates reject invalid user input (e.g. an unsupported
823 + # `reasoning_effort` value) by raising from within the template.
824 + # Surface those as a 400 Bad Request carrying the template's own
825 + # reason (which typically lists the supported values) instead of a
826 + # 500 or any generic upstream wrapper message.
827 + logger.warning("Chat template rejected the request: %s", e)
828 + raise VLLMValidationError(_template_error_reason(e)) from e
829
830 if return_assistant_tokens_mask:
831 assert isinstance(plain, list), f"Expected list[int], got {type(plain)}"