[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)}"