[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 {