[Bugfix][Rust Frontend][Multimodal] Align DeepSeek V4.1 and Kimi K3 media with rendered placeholders (#56366)

Co-authored-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: Codex <noreply@openai.com> Signed-off-by: reidliu41 <reid201711@gmail.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Reid committed Sep 14, 2026 at 18:37 UTC 7b1ea3f524445be0545ed962cb06ce21a93fcc33
18 files changed +415 -85
rust/src/chat/src/lib.rs
+7 -3
@@ -34,8 +34,8 @@ pub use parser::{ParserSelection, validate_parser_overrides};
34 pub use renderer::hf::ChatTemplateContentFormatOption;
35 pub use renderer::{
36 ChatRenderer, DeepSeekV4ChatRenderer, DeepSeekV32ChatRenderer, DeepSeekV41ChatRenderer,
37 - DynChatRenderer, HarmonyChatRenderer, InklingChatRenderer, KimiK3ChatRenderer, RenderedPrompt,
38 - RendererSelection,
37 + DynChatRenderer, HarmonyChatRenderer, InklingChatRenderer, KimiK3ChatRenderer, MediaPartSource,
38 + RenderedPrompt, RendererSelection,
39 };
40 pub use request::{
41 ChatContent, ChatContentPart, ChatMessage, ChatOptions, ChatRequest, ChatRole, ChatTool,
@@ -112,6 +112,10 @@ impl ChatRequestProcessor {
112 request: &ChatRequest,
113 rendered: RenderedPrompt,
114 ) -> Result<(Prompt, Option<MmFeatures>)> {
115 + let media_is_empty = match &rendered.media_order {
116 + Some(media_order) => media_order.is_empty(),
117 + None => !request.has_multimodal(),
118 + };
119 match self.model_dtype {
120 Some(model_dtype) => {
121 multimodal::finalize_rendered_prompt(
@@ -122,7 +126,7 @@ impl ChatRequestProcessor {
126 )
127 .await
128 }
125 - None if !request.has_multimodal() => Ok((rendered.prompt, None)),
129 + None if media_is_empty => Ok((rendered.prompt, None)),
130 None => Err(Error::UnsupportedMultimodalRenderer),
131 }
132 }
rust/src/chat/src/multimodal.rs
+152 -53
@@ -16,7 +16,7 @@ use std::fs;
16 use std::path::Path;
17 use std::sync::{Arc, LazyLock};
18
19 -use itertools::izip;
19 +use itertools::{Either, izip};
20 use llm_multimodal::{
21 AsyncMultiModalTracker, AudioClip, AudioPreProcessor, EncoderFieldLayouts, FieldLayout,
22 ImageFrame, MediaConnector, MediaConnectorConfig, MediaContentPart, Modality, ModelMetadata,
@@ -33,7 +33,7 @@ use vllm_text::Prompt;
33 use vllm_text::tokenizer::{DynTokenizer, Tokenizer};
34
35 use crate::error::{Error, Result, bail_multimodal, multimodal};
36 -use crate::renderer::RenderedPrompt;
36 +use crate::renderer::{MediaPartSource, RenderedPrompt};
37 use crate::request::{ChatContent, ChatContentPart, ChatMessage, ChatRequest};
38
39 mod audio;
@@ -549,7 +549,8 @@ pub(crate) async fn finalize_rendered_prompt(
549 info: Option<&MultimodalModelInfo>,
550 model_dtype: ModelDtype,
551 ) -> Result<(Prompt, Option<MmFeatures>)> {
552 - if !request.has_multimodal() {
552 + let media_parts = extract_media_parts(request, rendered.media_order.as_deref())?;
553 + if media_parts.is_empty() {
554 return Ok((rendered.prompt, None));
555 }
556 let info = info.ok_or(Error::UnsupportedMultimodalRenderer)?;
@@ -561,63 +562,92 @@ pub(crate) async fn finalize_rendered_prompt(
562 .map_err(|error| multimodal!("{error}"))?,
563 Prompt::TokenIds(token_ids) => token_ids,
564 };
564 - let media_parts = extract_media_parts(request)?;
565 let prepared = info.prepare_multimodal(media_parts, &mut prompt_token_ids, model_dtype).await?;
566
567 Ok((Prompt::TokenIds(prompt_token_ids), Some(prepared)))
568 }
569
570 -/// Extract media parts from chat messages in message/content order.
571 -///
572 -/// Assistant history is skipped because generated assistant blocks are already
573 -/// represented as text for prompt rendering in this crate.
574 -fn extract_media_parts(request: &ChatRequest) -> Result<Vec<MediaContentPart>> {
575 - let mut all_parts = Vec::new();
576 - for message in &request.messages {
577 - let content = match message {
578 - ChatMessage::System { content }
579 - | ChatMessage::Developer { content, .. }
580 - | ChatMessage::User { content }
581 - | ChatMessage::ToolResponse { content, .. } => content,
582 - ChatMessage::Assistant { .. } => continue,
583 - };
584 - let ChatContent::Parts(parts) = content else {
585 - continue;
586 - };
587 - for part in parts {
588 - match part {
589 - ChatContentPart::Text { .. } => {}
590 - ChatContentPart::ImageUrl {
591 - image_url,
592 - detail,
593 - uuid,
594 - } => all_parts.push(MediaContentPart::ImageUrl {
595 - url: image_url.clone(),
596 - detail: *detail,
597 - uuid: uuid.clone(),
598 - }),
599 - ChatContentPart::VideoUrl { video_url, uuid } => {
600 - all_parts.push(MediaContentPart::VideoUrl {
601 - url: video_url.clone(),
602 - uuid: uuid.clone(),
603 - })
604 - }
605 - ChatContentPart::InputAudio { data, format, uuid } => {
606 - all_parts.push(MediaContentPart::AudioUrl {
607 - url: input_audio_data_url(data, format.as_deref())?,
608 - uuid: uuid.clone(),
609 - })
610 - }
611 - ChatContentPart::AudioUrl { audio_url, uuid } => {
612 - all_parts.push(MediaContentPart::AudioUrl {
613 - url: audio_url.clone(),
614 - uuid: uuid.clone(),
615 - })
570 +/// Resolve media parts in the placeholder order reported by the renderer.
571 +fn extract_media_parts(
572 + request: &ChatRequest,
573 + media_order: Option<&[MediaPartSource]>,
574 +) -> Result<Vec<MediaContentPart>> {
575 + let parts = match media_order {
576 + // Resolve exactly the sources selected by the renderer, in placeholder order.
577 + Some(media_order) => Either::Left(media_order.iter().map(|source| -> Result<_> {
578 + let message = request.messages.get(source.message_index).ok_or_else(|| {
579 + multimodal!(
580 + "renderer reported missing chat message {} for multimodal content",
581 + source.message_index
582 + )
583 + })?;
584 + let content = match message {
585 + ChatMessage::System { content }
586 + | ChatMessage::Developer { content, .. }
587 + | ChatMessage::User { content }
588 + | ChatMessage::ToolResponse { content, .. } => content,
589 + ChatMessage::Assistant { .. } => {
590 + bail_multimodal!("renderer reported multimodal assistant content")
591 }
592 + };
593 + let ChatContent::Parts(parts) = content else {
594 + bail_multimodal!("renderer reported multimodal content in a text-only chat message")
595 + };
596 + let part = parts.get(source.content_part_index).ok_or_else(|| {
597 + multimodal!(
598 + "renderer reported missing content part {} in chat message {}",
599 + source.content_part_index,
600 + source.message_index
601 + )
602 + })?;
603 + Ok(part)
604 + })),
605 + // Fall back to all media in request message and content-part order (e.g. Jinja).
606 + None => Either::Right(request.messages.iter().flat_map(|message| {
607 + let content = match message {
608 + ChatMessage::System { content }
609 + | ChatMessage::Developer { content, .. }
610 + | ChatMessage::User { content }
611 + | ChatMessage::ToolResponse { content, .. } => Some(content),
612 + ChatMessage::Assistant { .. } => None,
613 + };
614 + match content {
615 + Some(ChatContent::Parts(parts)) => parts.as_slice(),
616 + _ => &[],
617 }
618 - }
619 - }
620 - Ok(all_parts)
618 + .iter()
619 + .filter(|part| part.is_multimodal())
620 + .map(Ok)
621 + })),
622 + };
623 + parts
624 + .map(|part| match part? {
625 + ChatContentPart::Text { .. } => {
626 + bail_multimodal!("renderer reported a text part as multimodal content")
627 + }
628 + ChatContentPart::ImageUrl {
629 + image_url,
630 + detail,
631 + uuid,
632 + } => Ok(MediaContentPart::ImageUrl {
633 + url: image_url.clone(),
634 + detail: *detail,
635 + uuid: uuid.clone(),
636 + }),
637 + ChatContentPart::VideoUrl { video_url, uuid } => Ok(MediaContentPart::VideoUrl {
638 + url: video_url.clone(),
639 + uuid: uuid.clone(),
640 + }),
641 + ChatContentPart::InputAudio { data, format, uuid } => Ok(MediaContentPart::AudioUrl {
642 + url: input_audio_data_url(data, format.as_deref())?,
643 + uuid: uuid.clone(),
644 + }),
645 + ChatContentPart::AudioUrl { audio_url, uuid } => Ok(MediaContentPart::AudioUrl {
646 + url: audio_url.clone(),
647 + uuid: uuid.clone(),
648 + }),
649 + })
650 + .collect()
651 }
652
653 /// Wrap OpenAI base64 audio in a data URL consumed by `MediaConnector`.
@@ -1043,6 +1073,75 @@ mod tests {
1073 assert!(input_audio_data_url("AAAA", Some("flac")).is_err());
1074 }
1075
1076 + #[test]
1077 + fn extract_media_parts_uses_request_order_or_explicit_selection() {
1078 + let request = ChatRequest {
1079 + messages: vec![
1080 + ChatMessage::user(vec![
1081 + ChatContentPart::text("before image"),
1082 + ChatContentPart::ImageUrl {
1083 + image_url: "data:image/png;base64,image-b".to_string(),
1084 + detail: None,
1085 + uuid: Some("image-b".to_string()),
1086 + },
1087 + ]),
1088 + ChatMessage::user(vec![ChatContentPart::ImageUrl {
1089 + image_url: "data:image/png;base64,image-a".to_string(),
1090 + detail: None,
1091 + uuid: Some("image-a".to_string()),
1092 + }]),
1093 + ChatMessage::assistant_text("answer"),
1094 + ChatMessage::user("follow-up"),
1095 + ],
1096 + ..ChatRequest::for_test()
1097 + };
1098 + let media_order = [
1099 + MediaPartSource {
1100 + message_index: 1,
1101 + content_part_index: 0,
1102 + },
1103 + MediaPartSource {
1104 + message_index: 0,
1105 + content_part_index: 1,
1106 + },
1107 + ];
1108 +
1109 + let selections = [None, Some(&media_order[..]), Some(&[][..])];
1110 + let uuids = selections.map(|order| {
1111 + extract_media_parts(&request, order)
1112 + .unwrap()
1113 + .into_iter()
1114 + .map(|part| match part {
1115 + MediaContentPart::ImageUrl { uuid, .. } => uuid,
1116 + _ => panic!("expected image URL"),
1117 + })
1118 + .collect::<Vec<_>>()
1119 + });
1120 +
1121 + expect_test::expect![[r#"
1122 + [
1123 + [
1124 + Some(
1125 + "image-b",
1126 + ),
1127 + Some(
1128 + "image-a",
1129 + ),
1130 + ],
1131 + [
1132 + Some(
1133 + "image-a",
1134 + ),
1135 + Some(
1136 + "image-b",
1137 + ),
1138 + ],
1139 + [],
1140 + ]
1141 + "#]]
1142 + .assert_debug_eq(&uuids);
1143 + }
1144 +
1145 fn image_url_part() -> MediaContentPart {
1146 MediaContentPart::ImageUrl {
1147 url: "https://example.com/image.png".to_string(),
rust/src/chat/src/renderer/deepseek.rs
+53 -12
@@ -16,6 +16,7 @@ use serde_json_fmt::JsonFormat;
16
17 use llm_multimodal::DEEPSEEK_V41_IMAGE_PLACEHOLDER;
18
19 +use super::MediaPartSource;
20 use crate::error::{Error, Result};
21 use crate::request::{
22 ChatContent, ChatContentPart, ChatMessage, ChatRequest, ChatTool, ReasoningEffort,
@@ -87,6 +88,14 @@ struct RenderedToolSchema<'a> {
88
89 /// Render one chat request into the final prompt string.
90 pub(super) fn render_request(request: &ChatRequest, dialect: DsDialect) -> Result<String> {
91 + render_request_with_media_order(request, dialect).map(|(prompt, _)| prompt)
92 +}
93 +
94 +/// Render one chat request and record media in placeholder order.
95 +pub(super) fn render_request_with_media_order(
96 + request: &ChatRequest,
97 + dialect: DsDialect,
98 +) -> Result<(String, Vec<MediaPartSource>)> {
99 let (thinking_mode, reasoning_effort_prompt) = match dialect {
100 DsDialect::V4 => {
101 resolve_thinking_options(request).map(|(mode, prompt)| (mode, Cow::Borrowed(prompt)))?
@@ -112,6 +121,7 @@ pub(super) fn render_request(request: &ChatRequest, dialect: DsDialect) -> Resul
121 dialect,
122 );
123 let mut out = String::from(BOS_TOKEN);
124 + let mut media_order = Vec::new();
125 if dialect == DsDialect::V41
126 && (thinking_mode == ThinkingMode::Thinking
127 || synthetic_tool_system
@@ -126,7 +136,7 @@ pub(super) fn render_request(request: &ChatRequest, dialect: DsDialect) -> Resul
136 let mut request_tools_attached = false;
137 let mut render_index = 0isize;
138 if synthetic_tool_system {
129 - render_system_message(&mut out, None, request_tools, dialect)?;
139 + render_system_message(&mut out, None, request_tools, dialect, &mut media_order)?;
140 request_tools_attached = true;
141 render_index += 1;
142 }
@@ -157,14 +167,22 @@ pub(super) fn render_request(request: &ChatRequest, dialect: DsDialect) -> Resul
167 } else {
168 &[]
169 };
160 - render_system_message(&mut out, Some(content), tools, dialect)?;
170 + render_system_message(
171 + &mut out,
172 + Some((message_index, content)),
173 + tools,
174 + dialect,
175 + &mut media_order,
176 + )?;
177 }
178 ChatMessage::Developer { content, tools } => {
179 render_developer_message(
180 &mut out,
181 + message_index,
182 content,
183 tools.as_deref().unwrap_or(&[]),
184 dialect,
185 + &mut media_order,
186 )?;
187 }
188 ChatMessage::User { .. } | ChatMessage::ToolResponse { .. } => {
@@ -174,6 +192,7 @@ pub(super) fn render_request(request: &ChatRequest, dialect: DsDialect) -> Resul
192 message_index,
193 &last_tool_call_order,
194 dialect,
195 + &mut media_order,
196 )?;
197 }
198 ChatMessage::Assistant { content } => {
@@ -223,7 +242,7 @@ pub(super) fn render_request(request: &ChatRequest, dialect: DsDialect) -> Resul
242 }
243 }
244
226 - Ok(out)
245 + Ok((out, media_order))
246 }
247
248 /// Resolve DeepSeek V4's thinking controls. Unlike the Python tokenizer
@@ -488,12 +507,13 @@ fn render_tool_schema(out: &mut String, tool: &ChatTool) -> Result<()> {
507 /// Render a system turn, optionally followed by the V4 tool preamble.
508 fn render_system_message(
509 out: &mut String,
491 - content: Option<&ChatContent>,
510 + content: Option<(usize, &ChatContent)>,
511 tools: &[ChatTool],
512 dialect: DsDialect,
513 + media_order: &mut Vec<MediaPartSource>,
514 ) -> Result<()> {
495 - if let Some(content) = content {
496 - write_chat_content(out, content, dialect)?;
515 + if let Some((message_index, content)) = content {
516 + write_chat_content(out, content, dialect, message_index, media_order)?;
517 }
518 if !tools.is_empty() {
519 out.push_str("\n\n");
@@ -505,9 +525,11 @@ fn render_system_message(
525 /// Developer messages are rendered as user-like turns with optional tools.
526 fn render_developer_message(
527 out: &mut String,
528 + message_index: usize,
529 content: &ChatContent,
530 tools: &[ChatTool],
531 dialect: DsDialect,
532 + media_order: &mut Vec<MediaPartSource>,
533 ) -> Result<()> {
534 if content.is_empty() {
535 return Err(Error::ChatTemplate(
@@ -516,7 +538,7 @@ fn render_developer_message(
538 }
539
540 out.push_str(USER_SP_TOKEN);
519 - write_chat_content(out, content, dialect)?;
541 + write_chat_content(out, content, dialect, message_index, media_order)?;
542 if !tools.is_empty() {
543 out.push_str("\n\n");
544 render_tools(out, tools, dialect)?;
@@ -531,6 +553,7 @@ fn render_user_content_block(
553 message_index: usize,
554 tool_call_order: &HashMap<String, usize>,
555 dialect: DsDialect,
556 + media_order: &mut Vec<MediaPartSource>,
557 ) -> Result<()> {
558 let (block_start, block_end) = user_content_block_bounds(messages, message_index);
559 let mut sorted_tool_indices =
@@ -542,7 +565,9 @@ fn render_user_content_block(
565 out.push_str("\n\n");
566 }
567 match &messages[message_index] {
545 - ChatMessage::User { content } => write_chat_content(out, content, dialect)?,
568 + ChatMessage::User { content } => {
569 + write_chat_content(out, content, dialect, message_index, media_order)?;
570 + }
571 ChatMessage::ToolResponse { .. } => {
572 let sorted_index = sorted_tool_indices
573 .next()
@@ -550,7 +575,7 @@ fn render_user_content_block(
575 let ChatMessage::ToolResponse { content, .. } = &messages[sorted_index] else {
576 unreachable!("sorted tool response index should reference a tool message");
577 };
553 - write_tool_result(out, content, dialect)?;
578 + write_tool_result(out, content, dialect, sorted_index, media_order)?;
579 }
580 _ => unreachable!("user content block should only contain user content messages"),
581 }
@@ -597,9 +622,15 @@ fn sorted_tool_response_indices(
622 }
623
624 /// Render one tool response payload inside a V4 `<tool_result>` block.
600 -fn write_tool_result(out: &mut String, content: &ChatContent, dialect: DsDialect) -> Result<()> {
625 +fn write_tool_result(
626 + out: &mut String,
627 + content: &ChatContent,
628 + dialect: DsDialect,
629 + message_index: usize,
630 + media_order: &mut Vec<MediaPartSource>,
631 +) -> Result<()> {
632 out.push_str("<tool_result>");
602 - write_chat_content(out, content, dialect)?;
633 + write_chat_content(out, content, dialect, message_index, media_order)?;
634 out.push_str("</tool_result>");
635 Ok(())
636 }
@@ -727,7 +758,13 @@ fn encode_arguments_to_dsml(
758 /// The V4.1 dialect inlines the image placeholder at each image part's
759 /// position unconditionally (matching the Python encoding's
760 /// `IMAGE_PLACEHOLDER`); other dialects reject multimodal parts.
730 -fn write_chat_content(out: &mut String, content: &ChatContent, dialect: DsDialect) -> Result<()> {
761 +fn write_chat_content(
762 + out: &mut String,
763 + content: &ChatContent,
764 + dialect: DsDialect,
765 + message_index: usize,
766 + media_order: &mut Vec<MediaPartSource>,
767 +) -> Result<()> {
768 match content {
769 ChatContent::Text(text) => out.push_str(text),
770 ChatContent::Parts(parts) => {
@@ -738,6 +775,10 @@ fn write_chat_content(out: &mut String, content: &ChatContent, dialect: DsDialec
775 match part {
776 ChatContentPart::ImageUrl { .. } if dialect == DsDialect::V41 => {
777 out.push_str(DEEPSEEK_V41_IMAGE_PLACEHOLDER);
778 + media_order.push(MediaPartSource {
779 + message_index,
780 + content_part_index: index,
781 + });
782 }
783 _ => out.push_str(part.as_text()?),
784 }
rust/src/chat/src/renderer/deepseek_v32/mod.rs
+1
@@ -26,6 +26,7 @@ impl ChatRenderer for DeepSeekV32ChatRenderer {
26
27 Ok(RenderedPrompt {
28 prompt: Prompt::Text(encoding::render_request(request)?),
29 + media_order: None,
30 effective_template_kwargs: request_template_kwargs(request),
31 })
32 }
rust/src/chat/src/renderer/deepseek_v4/mod.rs
+1
@@ -24,6 +24,7 @@ impl ChatRenderer for DeepSeekV4ChatRenderer {
24
25 Ok(RenderedPrompt {
26 prompt: Prompt::Text(deepseek::render_request(request, DsDialect::V4)?),
27 + media_order: None,
28 effective_template_kwargs: request_template_kwargs(request),
29 })
30 }
rust/src/chat/src/renderer/deepseek_v41/mod.rs
+4 -1
@@ -21,9 +21,12 @@ impl DeepSeekV41ChatRenderer {
21 impl ChatRenderer for DeepSeekV41ChatRenderer {
22 fn render(&self, request: &ChatRequest) -> Result<RenderedPrompt> {
23 request.validate()?;
24 + let (prompt, media_order) =
25 + deepseek::render_request_with_media_order(request, DsDialect::V41)?;
26
27 Ok(RenderedPrompt {
26 - prompt: Prompt::Text(deepseek::render_request(request, DsDialect::V41)?),
28 + prompt: Prompt::Text(prompt),
29 + media_order: Some(media_order),
30 effective_template_kwargs: request_template_kwargs(request),
31 })
32 }
rust/src/chat/src/renderer/deepseek_v41/tests.rs
+77 -1
@@ -8,7 +8,7 @@ use serde_json::{Value, json};
8
9 use super::DeepSeekV41ChatRenderer;
10 use crate::ChatRenderer;
11 -use crate::event::AssistantContentBlock;
11 +use crate::event::{AssistantContentBlock, AssistantToolCall};
12 use crate::renderer::test_utils::{FixtureRequestOptions, fixture_chat_request};
13 use crate::request::{ChatContent, ChatContentPart, ChatMessage, ChatRequest, ReasoningEffort};
14
@@ -83,6 +83,82 @@ fn inlines_image_placeholder_at_image_part_positions() {
83 );
84 }
85
86 +#[test]
87 +fn media_order_follows_reordered_tool_results() {
88 + let renderer = DeepSeekV41ChatRenderer::new();
89 + let request = ChatRequest {
90 + messages: vec![
91 + ChatMessage::assistant_blocks(vec![
92 + AssistantContentBlock::ToolCall(AssistantToolCall {
93 + id: "call-a".to_string(),
94 + name: "tool-a".to_string(),
95 + arguments: "{}".to_string(),
96 + }),
97 + AssistantContentBlock::ToolCall(AssistantToolCall {
98 + id: "call-b".to_string(),
99 + name: "tool-b".to_string(),
100 + arguments: "{}".to_string(),
101 + }),
102 + ]),
103 + ChatMessage::tool_response(
104 + vec![ChatContentPart::text("result-b"), image_part("image-b")],
105 + "call-b",
106 + ),
107 + ChatMessage::tool_response(
108 + vec![ChatContentPart::text("result-a"), image_part("image-a")],
109 + "call-a",
110 + ),
111 + ],
112 + ..ChatRequest::for_test()
113 + };
114 +
115 + let rendered = renderer.render(&request).unwrap();
116 + let media_order = rendered.media_order.unwrap();
117 + let prompt = rendered.prompt.into_text().unwrap();
118 +
119 + assert!(prompt.find("result-a").unwrap() < prompt.find("result-b").unwrap());
120 + expect![[r#"
121 + [
122 + MediaPartSource {
123 + message_index: 2,
124 + content_part_index: 1,
125 + },
126 + MediaPartSource {
127 + message_index: 1,
128 + content_part_index: 1,
129 + },
130 + ]
131 + "#]]
132 + .assert_debug_eq(&media_order);
133 +}
134 +
135 +#[test]
136 +fn media_order_excludes_dropped_historical_developer_content() {
137 + let renderer = DeepSeekV41ChatRenderer::new();
138 + let request = ChatRequest {
139 + messages: vec![
140 + ChatMessage::developer(vec![image_part("historical-image")], None),
141 + ChatMessage::user("question"),
142 + ],
143 + ..ChatRequest::for_test()
144 + };
145 +
146 + let rendered = renderer.render(&request).unwrap();
147 + let media_order = rendered.media_order.unwrap();
148 + let prompt = rendered.prompt.into_text().unwrap();
149 +
150 + assert!(!prompt.contains("<|deepseek_image|>"));
151 + assert!(media_order.is_empty());
152 +}
153 +
154 +fn image_part(uuid: &str) -> ChatContentPart {
155 + ChatContentPart::ImageUrl {
156 + image_url: format!("data:image/png;base64,{uuid}"),
157 + detail: None,
158 + uuid: Some(uuid.to_string()),
159 + }
160 +}
161 +
162 #[test]
163 fn maps_reasoning_effort_to_reference_numeric_budget() {
164 for (effort, budget) in [
rust/src/chat/src/renderer/harmony/mod.rs
+1
@@ -116,6 +116,7 @@ impl ChatRenderer for HarmonyChatRenderer {
116 fn render(&self, request: &ChatRequest) -> Result<RenderedPrompt> {
117 Ok(RenderedPrompt {
118 prompt: Prompt::TokenIds(self.render_token_ids(request)?),
119 + media_order: None,
120 effective_template_kwargs: request_template_kwargs(request),
121 })
122 }
rust/src/chat/src/renderer/hf/mod.rs
+1
@@ -217,6 +217,7 @@ impl HfChatRenderer {
217
218 Ok(RenderedPrompt {
219 prompt: Prompt::Text(prompt),
220 + media_order: None,
221 effective_template_kwargs,
222 })
223 }
rust/src/chat/src/renderer/inkling/mod.rs
+1
@@ -346,6 +346,7 @@ impl ChatRenderer for InklingChatRenderer {
346
347 Ok(RenderedPrompt {
348 prompt: Prompt::TokenIds(out),
349 + media_order: None,
350 effective_template_kwargs,
351 })
352 }
rust/src/chat/src/renderer/kimi_k3/encoding.rs
+37 -14
@@ -10,6 +10,7 @@ use std::collections::HashMap;
10 use serde_json::{Map, Value, json};
11 use vllm_tokenizer::Tokenizer;
12
13 +use super::super::MediaPartSource;
14 use crate::error::{Error, Result};
15 use crate::request::{
16 ChatContent, ChatContentPart, ChatMessage, ChatRequest, ChatTool, ChatToolChoice,
@@ -29,6 +30,7 @@ const VALID_THINKING_EFFORTS: &[&str] = &["low", "high", "max"];
30 pub(super) struct K3TokenWriter<'a> {
31 tokenizer: &'a dyn Tokenizer,
32 token_ids: Vec<u32>,
33 + media_order: Vec<MediaPartSource>,
34 }
35
36 impl<'a> K3TokenWriter<'a> {
@@ -36,6 +38,7 @@ impl<'a> K3TokenWriter<'a> {
38 Self {
39 tokenizer,
40 token_ids: Vec::new(),
41 + media_order: Vec::new(),
42 }
43 }
44
@@ -55,13 +58,25 @@ impl<'a> K3TokenWriter<'a> {
58 Ok(())
59 }
60
58 - pub(super) fn finish(self) -> Vec<u32> {
59 - self.token_ids
61 + fn image(&mut self, message_index: usize, content_part_index: usize) -> Result<()> {
62 + self.control(IMAGE_PLACEHOLDER)?;
63 + self.media_order.push(MediaPartSource {
64 + message_index,
65 + content_part_index,
66 + });
67 + Ok(())
68 + }
69 +
70 + pub(super) fn finish(self) -> (Vec<u32>, Vec<MediaPartSource>) {
71 + (self.token_ids, self.media_order)
72 }
73 }
74
63 -/// Render and tokenize one chat request using K3's segment-aware contract.
64 -pub(super) fn render_request(request: &ChatRequest, tokenizer: &dyn Tokenizer) -> Result<Vec<u32>> {
75 +/// Render and tokenize one chat request while recording media in placeholder order.
76 +pub(super) fn render_request_with_media_order(
77 + request: &ChatRequest,
78 + tokenizer: &dyn Tokenizer,
79 +) -> Result<(Vec<u32>, Vec<MediaPartSource>)> {
80 let thinking = thinking_enabled(request)?;
81 let thinking_effort = thinking.then(|| thinking_effort(request)).transpose()?;
82 let tools = request_tools(request);
@@ -126,7 +141,7 @@ pub(super) fn render_request(request: &ChatRequest, tokenizer: &dyn Tokenizer) -
141 resolved.sort_by_key(|item| (item.0, item.1));
142 }
143
129 - for (position, _, content, name) in resolved {
144 + for (position, message_index, content, name) in resolved {
145 let tool_name = name.as_deref().ok_or_else(|| {
146 Error::ChatTemplate(
147 "Kimi K3 tool messages need a resolvable tool name: \
@@ -135,7 +150,7 @@ pub(super) fn render_request(request: &ChatRequest, tokenizer: &dyn Tokenizer) -
150 .to_string(),
151 )
152 })?;
138 - write_tool_message(out, tool_name, position, &content)?;
153 + write_tool_message(out, tool_name, position, message_index, &content)?;
154 }
155 Ok(())
156 };
@@ -161,14 +176,14 @@ pub(super) fn render_request(request: &ChatRequest, tokenizer: &dyn Tokenizer) -
176 } if !local_tools.is_empty() => {
177 write_tool_declare(&mut out, local_tools, true)?;
178 if !content_is_empty(content) {
164 - write_role_message(&mut out, "system", None, content)?;
179 + write_role_message(&mut out, "system", None, message_index, content)?;
180 }
181 }
182 ChatMessage::System { content } | ChatMessage::Developer { content, .. } => {
168 - write_role_message(&mut out, "system", None, content)?;
183 + write_role_message(&mut out, "system", None, message_index, content)?;
184 }
185 ChatMessage::User { content } => {
171 - write_role_message(&mut out, "user", None, content)?;
186 + write_role_message(&mut out, "user", None, message_index, content)?;
187 }
188 ChatMessage::Assistant { content } => {
189 tool_call_id_index.clear();
@@ -364,6 +379,7 @@ fn write_role_message(
379 out: &mut K3TokenWriter<'_>,
380 role: &str,
381 name: Option<&str>,
382 + message_index: usize,
383 content: &ChatContent,
384 ) -> Result<()> {
385 let mut attrs = vec![("role", role.to_string())];
@@ -372,7 +388,7 @@ fn write_role_message(
388 }
389 let attr_refs: Vec<(&str, &str)> = attrs.iter().map(|(k, v)| (*k, v.as_str())).collect();
390 write_open_tag(out, "message", &attr_refs)?;
375 - write_content(out, content)?;
391 + write_content(out, message_index, content)?;
392 write_close_tag(out, "message")?;
393 out.control(END_OF_MSG)
394 }
@@ -381,6 +397,7 @@ fn write_tool_message(
397 out: &mut K3TokenWriter<'_>,
398 tool_name: &str,
399 index: usize,
400 + message_index: usize,
401 content: &ChatContent,
402 ) -> Result<()> {
403 let index_str = index.to_string();
@@ -389,7 +406,7 @@ fn write_tool_message(
406 "message",
407 &[("role", "tool"), ("tool", tool_name), ("index", &index_str)],
408 )?;
392 - write_content(out, content)?;
409 + write_content(out, message_index, content)?;
410 write_close_tag(out, "message")?;
411 out.control(END_OF_MSG)
412 }
@@ -471,14 +488,20 @@ fn write_assistant_tool_call(
488 write_close_tag(out, "call")
489 }
490
474 -fn write_content(out: &mut K3TokenWriter<'_>, content: &ChatContent) -> Result<()> {
491 +fn write_content(
492 + out: &mut K3TokenWriter<'_>,
493 + message_index: usize,
494 + content: &ChatContent,
495 +) -> Result<()> {
496 match content {
497 ChatContent::Text(text) => write_text_with_images(out, text),
498 ChatContent::Parts(parts) => {
478 - for part in parts {
499 + for (content_part_index, part) in parts.iter().enumerate() {
500 match part {
501 ChatContentPart::Text { text } => write_text_with_images(out, text)?,
481 - ChatContentPart::ImageUrl { .. } => out.control(IMAGE_PLACEHOLDER)?,
502 + ChatContentPart::ImageUrl { .. } => {
503 + out.image(message_index, content_part_index)?;
504 + }
505 ChatContentPart::VideoUrl { .. } => {
506 return Err(Error::UnsupportedMultimodalContent("video_url"));
507 }
rust/src/chat/src/renderer/kimi_k3/mod.rs
+4 -1
@@ -30,9 +30,12 @@ impl KimiK3ChatRenderer {
30 impl ChatRenderer for KimiK3ChatRenderer {
31 fn render(&self, request: &ChatRequest) -> Result<RenderedPrompt> {
32 request.validate()?;
33 + let (token_ids, media_order) =
34 + encoding::render_request_with_media_order(request, self.tokenizer.as_ref())?;
35
36 Ok(RenderedPrompt {
35 - prompt: Prompt::TokenIds(encoding::render_request(request, self.tokenizer.as_ref())?),
37 + prompt: Prompt::TokenIds(token_ids),
38 + media_order: Some(media_order),
39 effective_template_kwargs: request_template_kwargs(request),
40 })
41 }
rust/src/chat/src/renderer/kimi_k3/tests.rs
+59
@@ -284,6 +284,65 @@ fn partial_tool_results_keep_assistant_call_position() {
284 );
285 }
286
287 +#[test]
288 +fn media_order_follows_reordered_tool_results() {
289 + let mut request = crate::request::ChatRequest::for_test();
290 + request.messages = vec![
291 + ChatMessage::assistant_blocks(vec![
292 + AssistantContentBlock::ToolCall(AssistantToolCall {
293 + id: "call-a".to_string(),
294 + name: "tool-a".to_string(),
295 + arguments: "{}".to_string(),
296 + }),
297 + AssistantContentBlock::ToolCall(AssistantToolCall {
298 + id: "call-b".to_string(),
299 + name: "tool-b".to_string(),
300 + arguments: "{}".to_string(),
301 + }),
302 + ]),
303 + ChatMessage::tool_response(
304 + vec![ChatContentPart::text("result-b"), image_part("image-b")],
305 + "call-b",
306 + ),
307 + ChatMessage::tool_response(
308 + vec![ChatContentPart::text("result-a"), image_part("image-a")],
309 + "call-a",
310 + ),
311 + ];
312 + request.chat_options.generation_prompt_mode = GenerationPromptMode::NoGenerationPrompt;
313 + let renderer = KimiK3ChatRenderer::new(Arc::new(test_tokenizer()));
314 +
315 + let rendered = renderer.render(&request).unwrap();
316 + let media_order = rendered.media_order.unwrap();
317 + let Prompt::TokenIds(token_ids) = rendered.prompt else {
318 + panic!("kimi k3 renderer should return token IDs")
319 + };
320 + let prompt = test_tokenizer().decode(&token_ids, false).unwrap();
321 +
322 + assert!(prompt.find("result-a").unwrap() < prompt.find("result-b").unwrap());
323 + expect![[r#"
324 + [
325 + MediaPartSource {
326 + message_index: 2,
327 + content_part_index: 1,
328 + },
329 + MediaPartSource {
330 + message_index: 1,
331 + content_part_index: 1,
332 + },
333 + ]
334 + "#]]
335 + .assert_debug_eq(&media_order);
336 +}
337 +
338 +fn image_part(uuid: &str) -> ChatContentPart {
339 + ChatContentPart::ImageUrl {
340 + image_url: format!("data:image/png;base64,{uuid}"),
341 + detail: None,
342 + uuid: Some(uuid.to_string()),
343 + }
344 +}
345 +
346 #[test]
347 fn defaults_thinking_effort_to_max() {
348 let rendered = render_request(&crate::request::ChatRequest::for_test());
rust/src/chat/src/renderer/mod.rs
+13
@@ -30,11 +30,24 @@ pub use inkling::InklingChatRenderer;
30 pub use kimi_k3::KimiK3ChatRenderer;
31 pub use selection::RendererSelection;
32
33 +/// Location of one multimodal content part in the source chat request.
34 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35 +pub struct MediaPartSource {
36 + /// Zero-based message index in the source request.
37 + pub message_index: usize,
38 + /// Zero-based content-part index within the source message.
39 + pub content_part_index: usize,
40 +}
41 +
42 /// Rendered chat prompt submitted to the text backend.
43 #[derive(Debug, Clone, PartialEq)]
44 pub struct RenderedPrompt {
45 /// The rendered prompt, either as text or already tokenized.
46 pub prompt: Prompt,
47 + /// Media sources in rendered placeholder order.
48 + /// `None` uses the message and content-part order in the request (e.g. Jinja).
49 + /// `Some` uses exactly the listed sources; an empty list omits all media.
50 + pub media_order: Option<Vec<MediaPartSource>>,
51 /// Effective chat-template kwargs visible to the renderer after applying
52 /// server defaults, request overrides, and typed reasoning controls.
53 pub effective_template_kwargs: HashMap<String, Value>,
rust/src/chat/tests/chat.rs
+1
@@ -250,6 +250,7 @@ impl ChatRenderer for FakeChatBackend {
250
251 Ok(RenderedPrompt {
252 prompt: Prompt::Text(prompt),
253 + media_order: None,
254 effective_template_kwargs: request.chat_options.template_kwargs.clone(),
255 })
256 }
rust/src/server/src/grpc/tests.rs
+1
@@ -252,6 +252,7 @@ impl ChatRenderer for FakeTextBackend {
252 fn render(&self, _request: &ChatRequest) -> vllm_chat::Result<RenderedPrompt> {
253 Ok(RenderedPrompt {
254 prompt: Prompt::Text(String::new()),
255 + media_order: None,
256 effective_template_kwargs: Default::default(),
257 })
258 }
rust/src/server/src/routes/http_client_tests.rs
+1
@@ -186,6 +186,7 @@ impl ChatRenderer for FakeChatBackend {
186 }
187 Ok(RenderedPrompt {
188 prompt: Prompt::Text(prompt),
189 + media_order: None,
190 effective_template_kwargs: Default::default(),
191 })
192 }
rust/src/server/src/routes/tests.rs
+1
@@ -540,6 +540,7 @@ impl ChatRenderer for FakeChatBackend {
540 }
541 Ok(vllm_chat::RenderedPrompt {
542 prompt: Prompt::Text(prompt),
543 + media_order: None,
544 effective_template_kwargs: Default::default(),
545 })
546 }