[Rust Frontend] Accept custom chat roles for HF templates (#58311)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Bugen Zhao committed Sep 24, 2026 at 13:43 UTC a1b6763df65981474ba5c4dc27e16566eddb41e3
14 files changed +215 -20
rust/src/chat/src/error.rs
+4 -1
@@ -21,6 +21,8 @@ pub enum Error {
21 InvalidReasoningEffort(String),
22 #[error("{message}")]
23 InvalidReasoningControl { message: String },
24 + #[error("chat role `{role}` is not supported by this chat renderer")]
25 + UnsupportedChatRole { role: String },
26 #[error("multimodal input is not supported by this chat renderer")]
27 UnsupportedMultimodalRenderer,
28 #[error("unsupported multimodal content: {0}")]
@@ -114,7 +116,8 @@ impl Error {
116 | Self::InvalidReasoningControl { .. }
117 | Self::DuplicateToolName { .. }
118 | Self::ToolChoiceRequiresTools
117 - | Self::ToolChoiceFunctionNotFound { .. } => true,
119 + | Self::ToolChoiceFunctionNotFound { .. }
120 + | Self::UnsupportedChatRole { .. } => true,
121 Self::Text(error) => error.is_request_validation_error(),
122 Self::UnsupportedMultimodalRenderer
123 | Self::UnsupportedMultimodalContent(_)
rust/src/chat/src/multimodal.rs
+4 -2
@@ -599,7 +599,8 @@ fn extract_media_parts(
599 ChatMessage::System { content }
600 | ChatMessage::Developer { content, .. }
601 | ChatMessage::User { content }
602 - | ChatMessage::ToolResponse { content, .. } => content,
602 + | ChatMessage::ToolResponse { content, .. }
603 + | ChatMessage::Custom { content, .. } => content,
604 ChatMessage::Assistant { .. } => {
605 bail_multimodal!("renderer reported multimodal assistant content")
606 }
@@ -622,7 +623,8 @@ fn extract_media_parts(
623 ChatMessage::System { content }
624 | ChatMessage::Developer { content, .. }
625 | ChatMessage::User { content }
625 - | ChatMessage::ToolResponse { content, .. } => Some(content),
626 + | ChatMessage::ToolResponse { content, .. }
627 + | ChatMessage::Custom { content, .. } => Some(content),
628 ChatMessage::Assistant { .. } => None,
629 };
630 match content {
rust/src/chat/src/renderer/deepseek.rs
+3
@@ -227,6 +227,9 @@ pub(super) fn render_request_with_media_order(
227 );
228 }
229 }
230 + ChatMessage::Custom { role, .. } => {
231 + return Err(Error::UnsupportedChatRole { role: role.clone() });
232 + }
233 }
234
235 if is_user_like_entry(message, current_render_index as usize, dialect)
rust/src/chat/src/renderer/deepseek_v32/encoding.rs
+1
@@ -158,6 +158,7 @@ fn render_message(
158 thinking_mode == ThinkingMode::Thinking && after_or_at_last_user_turn,
159 content,
160 ),
161 + ChatMessage::Custom { role, .. } => Err(Error::UnsupportedChatRole { role: role.clone() }),
162 }
163 }
164
rust/src/chat/src/renderer/deepseek_v4/tests.rs
+18
@@ -5,6 +5,7 @@ use std::path::PathBuf;
5
6 use expect_test::{ExpectFile, expect, expect_file};
7 use serde_json::Value;
8 +use thiserror_ext::AsReport as _;
9
10 use super::DeepSeekV4ChatRenderer;
11 use crate::ChatRenderer;
@@ -764,3 +765,20 @@ fn last_developer_omits_generation_prompt_when_disabled() {
765
766 expect!["<|begin▁of▁sentence|><|User|>latest instruction"].assert_eq(&rendered);
767 }
768 +
769 +#[test]
770 +fn custom_roles_are_rejected_as_request_errors() {
771 + let request = ChatRequest {
772 + messages: vec![
773 + ChatMessage::custom("root", "identity"),
774 + ChatMessage::user("hello"),
775 + ],
776 + ..ChatRequest::for_test()
777 + };
778 +
779 + let error = DeepSeekV4ChatRenderer::default().render(&request).unwrap_err();
780 +
781 + assert!(error.is_request_validation_error());
782 + expect!["chat role `root` is not supported by this chat renderer"]
783 + .assert_eq(&error.to_report_string());
784 +}
rust/src/chat/src/renderer/harmony/mod.rs
+5 -1
@@ -188,7 +188,8 @@ fn peel_leading_instructions(
188 )),
189 ChatMessage::User { .. }
190 | ChatMessage::Assistant { .. }
191 - | ChatMessage::ToolResponse { .. } => Ok((None, None, messages)),
191 + | ChatMessage::ToolResponse { .. }
192 + | ChatMessage::Custom { .. } => Ok((None, None, messages)),
193 }
194 }
195
@@ -328,6 +329,9 @@ fn to_harmony_message(
329 .with_recipient("assistant"),
330 ]
331 }
332 + ChatMessage::Custom { role, .. } => {
333 + return Err(Error::UnsupportedChatRole { role: role.clone() });
334 + }
335 })
336 }
337
rust/src/chat/src/renderer/hf/mod.rs
+35 -9
@@ -258,8 +258,8 @@ impl ChatRenderer for HfChatRenderer {
258 // TODO: borrow more fields directly from the original `ChatMessage`.
259 #[serde_with::skip_serializing_none]
260 #[derive(Debug, Serialize)]
261 -struct TemplateMessage {
262 - role: &'static str,
261 +struct TemplateMessage<'a> {
262 + role: &'a str,
263 content: TemplateContent,
264 // Developer-role messages may provide message-local tools in the same shape
265 // as top-level request tools.
@@ -322,22 +322,22 @@ struct TemplateToolDefinition {
322 }
323
324 /// Convert chat messages into the JSON shape expected by Jinja chat templates.
325 -fn to_template_messages(
326 - messages: &[ChatMessage],
325 +fn to_template_messages<'a>(
326 + messages: &'a [ChatMessage],
327 content_format: ChatTemplateContentFormat,
328 multimodal: Option<&MultimodalRenderInfo>,
329 -) -> Result<Vec<TemplateMessage>> {
329 +) -> Result<Vec<TemplateMessage<'a>>> {
330 messages
331 .iter()
332 .map(|message| to_template_message(message, content_format, multimodal))
333 .collect()
334 }
335
336 -fn to_template_message(
337 - message: &ChatMessage,
336 +fn to_template_message<'a>(
337 + message: &'a ChatMessage,
338 content_format: ChatTemplateContentFormat,
339 multimodal: Option<&MultimodalRenderInfo>,
340 -) -> Result<TemplateMessage> {
340 +) -> Result<TemplateMessage<'a>> {
341 Ok(match message {
342 ChatMessage::System { content } => TemplateMessage {
343 role: "system",
@@ -394,6 +394,15 @@ fn to_template_message(
394 tool_calls: None,
395 tool_call_id: Some(tool_call_id.clone()),
396 },
397 + ChatMessage::Custom { role, content } => TemplateMessage {
398 + role,
399 + content: to_template_content(content, content_format, multimodal)?,
400 + tools: None,
401 + reasoning: None,
402 + reasoning_content: None,
403 + tool_calls: None,
404 + tool_call_id: None,
405 + },
406 })
407 }
408
@@ -524,7 +533,7 @@ const CONTINUE_FINAL_MESSAGE_TAG: &str = "CONTINUE_FINAL_MESSAGE_TAG ";
533 // TODO: transformers v5 also allows continuing a non-`content` field (e.g.
534 // `reasoning_content`) by passing a field name; only the boolean form is
535 // supported here.
527 -fn append_continue_final_message_tag(message: &mut TemplateMessage) -> Result<String> {
536 +fn append_continue_final_message_tag(message: &mut TemplateMessage<'_>) -> Result<String> {
537 let text = match &mut message.content {
538 TemplateContent::String(text) => Some(text),
539 // Pick the last text part in the message.
@@ -1001,6 +1010,23 @@ mod tests {
1010 assert_eq!(rendered, "developer|policy|get_weather|city");
1011 }
1012
1013 + #[test]
1014 + fn chat_template_passes_custom_roles_unchanged() {
1015 + let request = sample_request(vec![
1016 + ChatMessage::custom("root", "identity"),
1017 + ChatMessage::system("policy"),
1018 + ChatMessage::user("hello"),
1019 + ]);
1020 +
1021 + let rendered = render(
1022 + Some("{% for m in messages %}{{ m.role }}={{ m.content }};{% endfor %}"),
1023 + &request,
1024 + )
1025 + .unwrap();
1026 +
1027 + assert_eq!(rendered, "root=identity;system=policy;user=hello;");
1028 + }
1029 +
1030 #[test]
1031 fn chat_template_keeps_string_text_for_openai_detected_templates() {
1032 let request = sample_request(vec![ChatMessage::text(ChatRole::User, "hello")]);
rust/src/chat/src/renderer/hf/template.rs
+1 -1
@@ -45,7 +45,7 @@ fn build_environment(template: String) -> Result<Environment<'static>> {
45 #[serde_with::skip_serializing_none]
46 #[derive(Default, Serialize)]
47 pub(super) struct TemplateContext<'a> {
48 - pub(super) messages: &'a [TemplateMessage],
48 + pub(super) messages: &'a [TemplateMessage<'a>],
49 pub(super) add_generation_prompt: bool,
50 pub(super) continue_final_message: bool,
51 pub(super) tools: Option<&'a [TemplateTool]>,
rust/src/chat/src/renderer/inkling/mod.rs
+3
@@ -345,6 +345,9 @@ impl ChatRenderer for InklingChatRenderer {
345 &tool_call_id_to_name,
346 )?;
347 }
348 + ChatMessage::Custom { role, .. } => {
349 + return Err(Error::UnsupportedChatRole { role: role.clone() });
350 + }
351 }
352 }
353
rust/src/chat/src/renderer/kimi_k3/encoding.rs
+3
@@ -205,6 +205,9 @@ pub(super) fn render_request_with_media_order(
205 write_assistant_message(&mut out, content, thinking)?;
206 }
207 ChatMessage::ToolResponse { .. } => unreachable!("handled above"),
208 + ChatMessage::Custom { role, .. } => {
209 + return Err(Error::UnsupportedChatRole { role: role.clone() });
210 + }
211 }
212 }
213 flush_tool_run(&mut out, &mut pending_tool_run, &tool_call_id_index)?;
rust/src/chat/src/request.rs
+25 -4
@@ -24,6 +24,9 @@ pub enum ChatRole {
24 User,
25 Assistant,
26 ToolResponse,
27 + /// Role outside the standard set, passed to chat templates unchanged.
28 + #[serde(untagged)]
29 + Custom(String),
30 }
31
32 /// One text-only chat content part in OpenAI-style block format.
@@ -212,6 +215,10 @@ pub enum ChatMessage {
215 content: ChatContent,
216 tool_call_id: String,
217 },
218 + /// Message content under a role outside the standard set, passed to chat
219 + /// templates unchanged.
220 + #[serde(untagged)]
221 + Custom { role: String, content: ChatContent },
222 }
223
224 impl ChatMessage {
@@ -230,6 +237,7 @@ impl ChatMessage {
237 use ChatMessage::tool_response() instead"
238 )
239 }
240 + ChatRole::Custom(role) => Self::custom(role, content),
241 }
242 }
243
@@ -286,6 +294,14 @@ impl ChatMessage {
294 }
295 }
296
297 + /// Construct one chat message with a role outside the standard set.
298 + pub fn custom(role: impl Into<String>, content: impl Into<ChatContent>) -> Self {
299 + Self::Custom {
300 + role: role.into(),
301 + content: content.into(),
302 + }
303 + }
304 +
305 /// Return the chat role of this message.
306 pub fn role(&self) -> ChatRole {
307 match self {
@@ -294,6 +310,7 @@ impl ChatMessage {
310 Self::User { .. } => ChatRole::User,
311 Self::Assistant { .. } => ChatRole::Assistant,
312 Self::ToolResponse { .. } => ChatRole::ToolResponse,
313 + Self::Custom { role, .. } => ChatRole::Custom(role.clone()),
314 }
315 }
316
@@ -303,7 +320,8 @@ impl ChatMessage {
320 Self::System { content }
321 | Self::Developer { content, .. }
322 | Self::User { content }
306 - | Self::ToolResponse { content, .. } => content.try_flatten_to_text(),
323 + | Self::ToolResponse { content, .. }
324 + | Self::Custom { content, .. } => content.try_flatten_to_text(),
325 Self::Assistant { content } => Ok(content.text()),
326 }
327 }
@@ -315,7 +333,8 @@ impl ChatMessage {
333 Self::System { .. }
334 | Self::Developer { .. }
335 | Self::User { .. }
318 - | Self::ToolResponse { .. } => None,
336 + | Self::ToolResponse { .. }
337 + | Self::Custom { .. } => None,
338 }
339 }
340
@@ -325,7 +344,8 @@ impl ChatMessage {
344 Self::System { content }
345 | Self::Developer { content, .. }
346 | Self::User { content }
328 - | Self::ToolResponse { content, .. } => content.has_multimodal(),
347 + | Self::ToolResponse { content, .. }
348 + | Self::Custom { content, .. } => content.has_multimodal(),
349 Self::Assistant { .. } => false,
350 }
351 }
@@ -673,13 +693,14 @@ impl ChatRequest {
693 impl ChatRole {
694 /// Return the chat-template role string used by the current text-only chat
695 /// backend.
676 - pub fn as_str(&self) -> &'static str {
696 + pub fn as_str(&self) -> &str {
697 match self {
698 Self::System => "system",
699 Self::Developer => "developer",
700 Self::User => "user",
701 Self::Assistant => "assistant",
702 Self::ToolResponse => "tool_response",
703 + Self::Custom(role) => role,
704 }
705 }
706 }
rust/src/server/src/routes/openai/chat_completions/convert.rs
+41
@@ -317,6 +317,9 @@ pub(crate) fn convert_message(message: ChatMessage) -> Result<VllmChatMessage, A
317 convert_content(content)?,
318 convert_message_tools(tools)?,
319 )),
320 + ChatMessage::Custom { role, content } => {
321 + Ok(VllmChatMessage::custom(role, convert_content(content)?))
322 + }
323 }
324 }
325
@@ -442,6 +445,7 @@ mod tests {
445 use expect_test::expect;
446 use llm_multimodal::ImageDetail;
447 use serde_json::json;
448 + use thiserror_ext::AsReport as _;
449 use validator::Validate;
450 use vllm_chat::{
451 AssistantContentBlock, AssistantToolCall, ChatContentPart, ChatMessage as VllmChatMessage,
@@ -1047,6 +1051,43 @@ mod tests {
1051 );
1052 }
1053
1054 + #[test]
1055 + fn prepare_chat_request_accepts_custom_role_messages() {
1056 + let request: ChatCompletionRequest = serde_json::from_value(json!({
1057 + "messages": [
1058 + {"role": "root", "content": "Custom identity."},
1059 + {"role": "user", "content": "hello"},
1060 + ],
1061 + }))
1062 + .expect("parse custom role message");
1063 +
1064 + let prepared = prepare_chat_request(
1065 + request,
1066 + &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
1067 + ResolvedRequestContext::default(),
1068 + )
1069 + .expect("request is valid");
1070 +
1071 + assert_eq!(
1072 + prepared.chat_request.messages,
1073 + vec![
1074 + VllmChatMessage::custom("root", "Custom identity."),
1075 + VllmChatMessage::user("hello"),
1076 + ]
1077 + );
1078 + }
1079 +
1080 + #[test]
1081 + fn chat_http_message_keeps_standard_role_errors() {
1082 + let error = serde_json::from_value::<ChatMessage>(json!({
1083 + "role": "tool",
1084 + "content": "Sunny",
1085 + }))
1086 + .unwrap_err();
1087 +
1088 + expect!["missing field `tool_call_id`"].assert_eq(&error.to_report_string());
1089 + }
1090 +
1091 #[test]
1092 fn prepare_chat_request_maps_image_url_content_parts() {
1093 let request = ChatCompletionRequest {
rust/src/server/src/routes/openai/utils/types.rs
+70 -1
@@ -410,9 +410,17 @@ impl ToolReference {
410 // Chat Messages
411 // ============================================================================
412
413 +/// One chat message, tagged by `role`.
414 +///
415 +/// The derived serde impls use `remote = "Self"` and cover only the standard
416 +/// roles; the trait impls below dispatch on the role tag and handle
417 +/// [`ChatMessage::Custom`] separately. A `#[serde(untagged)]` fallback variant
418 +/// would also accept malformed standard messages, such as a `tool` message
419 +/// without `tool_call_id`, and replace their field errors with serde's generic
420 +/// untagged-enum error.
421 #[serde_with::skip_serializing_none]
422 #[derive(Debug, Clone, Deserialize, Serialize)]
415 -#[serde(tag = "role")]
423 +#[serde(tag = "role", remote = "Self")]
424 pub enum ChatMessage {
425 #[serde(rename = "system")]
426 System {
@@ -447,6 +455,67 @@ pub enum ChatMessage {
455 tools: Option<Vec<Tool>>,
456 name: Option<String>,
457 },
458 + /// Message with a role outside the OpenAI set, such as `root`. Chat
459 + /// templates receive the role string unchanged.
460 + #[serde(skip)]
461 + Custom {
462 + role: String,
463 + content: MessageContent,
464 + },
465 +}
466 +
467 +/// Role tags of the standard [`ChatMessage`] variants.
468 +const STANDARD_CHAT_ROLES: &[&str] = &[
469 + "system",
470 + "user",
471 + "assistant",
472 + "tool",
473 + "function",
474 + "developer",
475 +];
476 +
477 +/// Wire shape of [`ChatMessage::Custom`].
478 +#[derive(Deserialize, Serialize)]
479 +struct CustomChatMessage<R, C> {
480 + role: R,
481 + content: C,
482 +}
483 +
484 +impl Serialize for ChatMessage {
485 + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
486 + where
487 + S: serde::Serializer,
488 + {
489 + match self {
490 + Self::Custom { role, content } => {
491 + CustomChatMessage { role, content }.serialize(serializer)
492 + }
493 + _ => Self::serialize(self, serializer),
494 + }
495 + }
496 +}
497 +
498 +impl<'de> Deserialize<'de> for ChatMessage {
499 + /// Dispatch on the role tag: standard roles keep their derived errors,
500 + /// and any other string role becomes [`ChatMessage::Custom`].
501 + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
502 + where
503 + D: Deserializer<'de>,
504 + {
505 + let value = Value::deserialize(deserializer)?;
506 + let is_custom = value
507 + .get("role")
508 + .and_then(Value::as_str)
509 + .is_some_and(|role| !STANDARD_CHAT_ROLES.contains(&role));
510 +
511 + if is_custom {
512 + let CustomChatMessage { role, content } =
513 + CustomChatMessage::deserialize(value).map_err(serde::de::Error::custom)?;
514 + Ok(Self::Custom { role, content })
515 + } else {
516 + Self::deserialize(value).map_err(serde::de::Error::custom)
517 + }
518 + }
519 }
520
521 #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
rust/src/server/src/routes/tests.rs
+2 -1
@@ -555,7 +555,8 @@ fn render_fake_message_content(
555 ChatMessage::System { content }
556 | ChatMessage::Developer { content, .. }
557 | ChatMessage::User { content }
558 - | ChatMessage::ToolResponse { content, .. } => render_fake_content(content, placeholder),
558 + | ChatMessage::ToolResponse { content, .. }
559 + | ChatMessage::Custom { content, .. } => render_fake_content(content, placeholder),
560 ChatMessage::Assistant { .. } => message.text_content(),
561 }
562 }