[Bugfix][Rust Frontend] Support raise_exception in chat templates (#59020)

Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Bugen Zhao <i@bugenzhao.com> Signed-off-by: Yashasvi Asthana <yashasvi.asthana@gmail.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Yashasvi Asthana committed Sep 29, 2026 at 13:31 UTC 9af952c55568f596c1b76c68ced8c5aeb5593a39
4 files changed +138 -2
rust/src/chat/src/error.rs
+3
@@ -17,6 +17,8 @@ pub enum Error {
17 MissingChatTemplate,
18 #[error("chat template error: {0}")]
19 ChatTemplate(String),
20 + #[error("{message}")]
21 + ChatTemplateThrown { message: String },
22 #[error("{0}")]
23 InvalidReasoningEffort(String),
24 #[error("{message}")]
@@ -112,6 +114,7 @@ impl Error {
114 pub fn is_request_validation_error(&self) -> bool {
115 match self {
116 Self::PromptTooLong { .. }
117 + | Self::ChatTemplateThrown { .. }
118 | Self::InvalidReasoningEffort(_)
119 | Self::InvalidReasoningControl { .. }
120 | Self::DuplicateToolName { .. }
rust/src/chat/src/renderer/hf/mod.rs
+23 -1
@@ -226,7 +226,12 @@ impl HfChatRenderer {
226 template_kwargs: Some(&effective_template_kwargs),
227 special_tokens: self.special_tokens.as_ref(),
228 })
229 - .map_err(|error| Error::ChatTemplate(error.to_report_string()))?;
229 + .map_err(|error| match error.thrown_message() {
230 + Some(message) => Error::ChatTemplateThrown {
231 + message: message.to_owned(),
232 + },
233 + None => Error::ChatTemplate(error.to_report_string()),
234 + })?;
235
236 let prompt = match &final_message_text {
237 Some(final_message_text) => {
@@ -973,6 +978,23 @@ mod tests {
978 assert!(matches!(error, Error::ChatTemplate(_)));
979 }
980
981 + #[test]
982 + fn raise_exception_in_template_is_a_request_validation_error() {
983 + let request = sample_request(vec![ChatMessage::text(ChatRole::User, "hi")]);
984 +
985 + let error = render(
986 + Some("{{- raise_exception('No user query found in messages.') }}"),
987 + &request,
988 + )
989 + .unwrap_err();
990 +
991 + assert!(
992 + matches!(&error, Error::ChatTemplateThrown { message } if message == "No user query found in messages."),
993 + "expected an exception thrown by the template, got: {error:?}"
994 + );
995 + assert!(error.is_request_validation_error());
996 + }
997 +
998 #[test]
999 fn chat_template_flattens_text_parts_for_string_templates() {
1000 let request = sample_request(vec![ChatMessage::user(vec![
rust/src/chat/src/renderer/hf/template.rs
+91 -1
@@ -8,12 +8,15 @@
8 //! - special tokens are wired to `vllm_text::backends::hf::HfSpecialTokens`
9
10 use std::collections::HashMap;
11 +use std::error::Error as _;
12 use std::fs;
13 +use std::iter;
14 use std::path::Path;
15
14 -use minijinja::Environment;
16 +use minijinja::{Environment, Error as MinijinjaError, ErrorKind, Value};
17 use serde::{Deserialize, Serialize};
18 use serde_json::{self};
19 +use thiserror::Error as ThisError;
20 use vllm_text::backend::hf::HfSpecialTokens;
21
22 use super::error::TemplateError;
@@ -38,10 +41,44 @@ fn build_environment(template: String) -> Result<Environment<'static>> {
41 env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
42 env.add_filter("tojson", hf_tojson_filter);
43 env.add_function("__hf_generation", render_generation);
44 + env.add_function("raise_exception", raise_exception);
45
46 Ok(env)
47 }
48
49 +/// Error source attached by [`raise_exception`], so that an exception thrown
50 +/// by the template can be told apart from a template that fails to render.
51 +#[derive(Debug, ThisError)]
52 +#[error("{message}")]
53 +struct ThrownTemplateException {
54 + message: String,
55 +}
56 +
57 +/// Throw an exception with the template's own message, aborting rendering.
58 +fn raise_exception(message: Value) -> std::result::Result<Value, MinijinjaError> {
59 + let thrown = ThrownTemplateException {
60 + message: message.to_string(),
61 + };
62 + let error = MinijinjaError::new(
63 + ErrorKind::InvalidOperation,
64 + "chat template threw an exception",
65 + );
66 + Err(error.with_source(thrown))
67 +}
68 +
69 +impl TemplateError {
70 + /// Return the template's own message if rendering was aborted by
71 + /// [`raise_exception`].
72 + pub(super) fn thrown_message(&self) -> Option<&str> {
73 + let Self::Jinja(error) = self else {
74 + return None;
75 + };
76 + iter::successors(error.source(), |&error| error.source())
77 + .find_map(|error| error.downcast_ref::<ThrownTemplateException>())
78 + .map(|thrown| thrown.message.as_str())
79 + }
80 +}
81 +
82 #[serde_with::skip_serializing_none]
83 #[derive(Default, Serialize)]
84 pub(super) struct TemplateContext<'a> {
@@ -189,6 +226,59 @@ mod tests {
226 );
227 }
228
229 + #[test]
230 + fn test_raise_exception_returns_template_message() {
231 + let template = CompiledChatTemplate::new(
232 + "{{- raise_exception('Unexpected reasoning effort ' ~ reasoning_effort ~ '.') }}"
233 + .to_string(),
234 + ChatTemplateContentFormatOption::Auto,
235 + )
236 + .unwrap();
237 + let mut kwargs = HashMap::new();
238 + kwargs.insert("reasoning_effort".to_string(), serde_json::json!("high"));
239 +
240 + let error = template
241 + .apply(TemplateContext {
242 + template_kwargs: Some(&kwargs),
243 + ..Default::default()
244 + })
245 + .unwrap_err();
246 +
247 + assert_eq!(
248 + error.thrown_message(),
249 + Some("Unexpected reasoning effort high."),
250 + "expected an exception thrown by the template, got: {error:?}"
251 + );
252 + }
253 +
254 + #[test]
255 + fn test_raise_exception_in_untaken_branch_renders() {
256 + let template = CompiledChatTemplate::new(
257 + "{% if messages %}{{ raise_exception('unreachable') }}{% endif %}ok".to_string(),
258 + ChatTemplateContentFormatOption::Auto,
259 + )
260 + .unwrap();
261 +
262 + assert_eq!(template.apply(TemplateContext::default()).unwrap(), "ok");
263 + }
264 +
265 + #[test]
266 + fn test_unknown_function_is_not_reported_as_thrown() {
267 + let template = CompiledChatTemplate::new(
268 + "{{ undefined_helper('not registered') }}".to_string(),
269 + ChatTemplateContentFormatOption::Auto,
270 + )
271 + .unwrap();
272 +
273 + let error = template.apply(TemplateContext::default()).unwrap_err();
274 +
275 + assert_eq!(
276 + error.thrown_message(),
277 + None,
278 + "expected a jinja render error, got: {error:?}"
279 + );
280 + }
281 +
282 #[test]
283 fn test_special_tokens_injected_into_context() {
284 let template = "{{ bos_token }}hello{{ eos_token }}";
rust/src/server/src/error.rs
+21
@@ -193,6 +193,27 @@ mod tests {
193 }
194 }
195
196 + #[test]
197 + fn chat_template_throw_maps_to_invalid_request() {
198 + let error = vllm_chat::Error::ChatTemplateThrown {
199 + message: "Unexpected reasoning effort high.".to_string(),
200 + };
201 + let api_error = chat_submit_error("failed to submit chat request", error);
202 + assert_eq!(api_error.status_code(), StatusCode::BAD_REQUEST);
203 + let response = api_error.to_error_response();
204 + assert_eq!(response.error.error_type, "invalid_request_error");
205 + assert_eq!(response.error.message, "Unexpected reasoning effort high.");
206 + }
207 +
208 + #[test]
209 + fn chat_template_render_failure_stays_internal() {
210 + let error = vllm_chat::Error::ChatTemplate(
211 + "failed to render jinja template: unknown function".to_string(),
212 + );
213 + let api_error = chat_submit_error("failed to submit chat request", error);
214 + assert_eq!(api_error.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
215 + }
216 +
217 #[test]
218 fn llm_wrapped_empty_prompt_maps_to_invalid_request() {
219 let error = vllm_text::Error::Llm(vllm_llm::Error::EmptyPromptTokenIds {