[Rust Frontend] Integrate MM video support (#47959)
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Bugen Zhao committed
Jul 10, 2026 at 16:15 UTC
074bdd0d9961fb7f81c9311e00c00cfd5aff77a3
16 files changed
+1571
-516
rust/Cargo.lock
+3
-1
@@ -2167,7 +2167,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
2167
[[package]]
2168
name = "llm-multimodal"
2169
version = "1.7.1"
2170
-source = "git+https://github.com/smg-project/llm-multimodal?rev=7d74582aeaf0e4086a44964382655d22f1af0686#7d74582aeaf0e4086a44964382655d22f1af0686"
2170
+source = "git+https://github.com/smg-project/llm-multimodal?rev=c8a29dcc755139fdc26185f400ea48c6d6d48273#c8a29dcc755139fdc26185f400ea48c6d6d48273"
2171
dependencies = [
2172
"anyhow",
2173
"base64 0.22.1",
@@ -2473,6 +2473,7 @@ dependencies = [
2473
"portable-atomic",
2474
"portable-atomic-util",
2475
"rawpointer",
2476
+ "serde",
2477
]
2478
2479
[[package]]
@@ -5093,6 +5094,7 @@ dependencies = [
5094
"llm-multimodal",
5095
"minijinja",
5096
"minijinja-contrib",
5097
+ "ndarray 0.17.2",
5098
"oss-harmony",
5099
"paste",
5100
"reqwest",
rust/Cargo.toml
+2
-2
@@ -53,12 +53,12 @@ hyper-util = { version = "0.1.20", features = [
53
indexmap = "2.13.0"
54
itertools = "0.14.0"
55
libc = "0.2.177"
56
-llm-multimodal = { git = "https://github.com/smg-project/llm-multimodal", rev = "7d74582aeaf0e4086a44964382655d22f1af0686" }
56
+llm-multimodal = { git = "https://github.com/smg-project/llm-multimodal", rev = "c8a29dcc755139fdc26185f400ea48c6d6d48273" }
57
mimalloc = "0.1.52"
58
minijinja = { version = "2.0", features = ["unstable_machinery", "json", "builtins", "loader", "loop_controls", "preserve_order"] }
59
minijinja-contrib = { version = "2.0", features = ["pycompat"] }
60
native-tls-vendored = { package = "native-tls", version = "0.2.18", features = ["vendored"] }
61
-ndarray = { version = "0.16.1", features = ["serde"] }
61
+ndarray = { version = "0.17", features = ["serde"] }
62
openai-harmony = { package = "oss-harmony", git = "https://github.com/oss-harmony/harmony", tag = "v0.0.11", default-features = false }
63
openai-protocol = "1.6.0"
64
openssl = "0.10"
rust/src/chat/Cargo.toml
+1
@@ -42,6 +42,7 @@ anyhow.workspace = true
42
bytes.workspace = true
43
clap.workspace = true
44
expect-test.workspace = true
45
+ndarray.workspace = true
46
paste.workspace = true
47
rmp-serde.workspace = true
48
serial_test.workspace = true
rust/src/chat/src/backend/hf.rs
+13
-4
@@ -10,7 +10,7 @@ use crate::backend::{
10
NewChatOutputProcessorOptions,
11
};
12
use crate::error::Result;
13
-use crate::multimodal::MultimodalModelInfo;
13
+use crate::multimodal::{MultimodalConfigFiles, MultimodalModelInfo};
14
use crate::output::{
15
DefaultChatOutputProcessor, HarmonyChatOutputProcessor, validate_harmony_parser_overrides,
16
};
@@ -46,8 +46,12 @@ impl HfChatBackend {
46
MultimodalModelInfo::from_paths(
47
model_id.clone(),
48
(!model_type.is_empty()).then_some(model_type.to_string()),
49
- files.config_path.as_deref(),
50
- files.preprocessor_config_path.as_deref(),
49
+ MultimodalConfigFiles {
50
+ config: files.config_path.as_deref(),
51
+ preprocessor_config: files.preprocessor_config_path.as_deref(),
52
+ video_preprocessor_config: files.video_preprocessor_config_path.as_deref(),
53
+ processor_config: files.processor_config_path.as_deref(),
54
+ },
55
tokenizer.clone(),
56
)?
57
};
@@ -139,8 +143,11 @@ pub(super) async fn load_model_backends(
143
fn resolve_multimodal_render_info(
144
info: Option<&MultimodalModelInfo>,
145
) -> Option<MultimodalRenderInfo> {
146
+ use llm_multimodal::Modality;
147
+
148
info.map(|info| MultimodalRenderInfo {
143
- placeholder_token: info.placeholder_token().to_string(),
149
+ image_token: info.placeholder_token(Modality::Image).map(str::to_string),
150
+ video_token: info.placeholder_token(Modality::Video).map(str::to_string),
151
})
152
}
153
@@ -192,6 +199,8 @@ mod tests {
199
tokenizer_config_path: Some(tokenizer_config_path),
200
generation_config_path: None,
201
preprocessor_config_path: None,
202
+ video_preprocessor_config_path: None,
203
+ processor_config_path: None,
204
chat_template_path: None,
205
config_path: Some(config_path),
206
}
rust/src/chat/src/error.rs
+31
-1
@@ -1,5 +1,5 @@
1
use thiserror::Error;
2
-use thiserror_ext::Macro;
2
+use thiserror_ext::{AsReport as _, Macro};
3
4
type BoxedError = Box<dyn std::error::Error + Send + Sync>;
5
@@ -18,6 +18,8 @@ pub enum Error {
18
UnsupportedMultimodalRenderer,
19
#[error("unsupported multimodal content: {0}")]
20
UnsupportedMultimodalContent(&'static str),
21
+ #[error("`{modality}` input is not supported by this model")]
22
+ UnsupportedModality { modality: String },
23
#[error("multimodal preprocessing error: {0}")]
24
Multimodal(#[message] String),
25
#[error("{kind} parsing is not available for model `{model_id}`")]
@@ -80,11 +82,39 @@ impl Error {
82
match self {
83
Self::PromptTooLong { .. } => true,
84
Self::Text(error) => error.is_request_validation_error(),
85
+ Self::UnsupportedMultimodalRenderer
86
+ | Self::UnsupportedMultimodalContent(_)
87
+ | Self::UnsupportedModality { .. } => true,
88
+
89
_ => false,
90
}
91
}
92
}
93
94
+impl From<llm_multimodal::MediaConnectorError> for Error {
95
+ fn from(error: llm_multimodal::MediaConnectorError) -> Self {
96
+ Self::Multimodal(error.to_report_string())
97
+ }
98
+}
99
+
100
+impl From<llm_multimodal::MultiModalError> for Error {
101
+ fn from(error: llm_multimodal::MultiModalError) -> Self {
102
+ Self::Multimodal(error.to_report_string())
103
+ }
104
+}
105
+
106
+impl From<llm_multimodal::TransformError> for Error {
107
+ fn from(error: llm_multimodal::TransformError) -> Self {
108
+ Self::Multimodal(error.to_report_string())
109
+ }
110
+}
111
+
112
+impl From<llm_multimodal::registry::ModelRegistryError> for Error {
113
+ fn from(error: llm_multimodal::registry::ModelRegistryError) -> Self {
114
+ Self::Multimodal(error.to_report_string())
115
+ }
116
+}
117
+
118
/// Format the available-parser suffix used in user-facing error messages.
119
fn available_parser_hint(available_names: &[String]) -> String {
120
if available_names.is_empty() {
rust/src/chat/src/multimodal.rs
+420
-474
@@ -1,8 +1,8 @@
1
-//! Chat-layer multimodal image preparation.
1
+//! Chat-layer multimodal media preparation.
2
//!
3
-//! This module owns the narrow image-only multimodal path for chat requests:
4
-//! it extracts image parts from structured chat messages, fetches and
5
-//! preprocesses them through `llm-multimodal`, expands rendered prompt
3
+//! This module owns the multimodal path for chat requests: it extracts media
4
+//! parts from structured chat messages, fetches and preprocesses them through
5
+//! `llm-multimodal` one modality at a time, expands rendered prompt
6
//! placeholders after tokenization, and builds the engine-facing
7
//! `MmFeatures` payload.
8
//!
@@ -16,19 +16,15 @@ use std::sync::{Arc, LazyLock};
16
17
use itertools::izip;
18
use llm_multimodal::{
19
- AsyncMultiModalTracker, FieldLayout, MediaConnector, MediaConnectorConfig, MediaContentPart,
20
- Modality, ModelMetadata, ModelProcessorSpec, ModelRegistry, PreProcessorConfig,
21
- PreprocessedEncoderInputs as PreprocessedImages, PromptReplacement, Tokenizer as TokenResolver,
22
- TrackedMedia, VisionPreProcessor as ImagePreProcessor,
23
- VisionProcessorRegistry as ImageProcessorRegistry,
19
+ AsyncMultiModalTracker, FieldLayout, ImageFrame, MediaConnector, MediaConnectorConfig,
20
+ MediaContentPart, Modality, ModelMetadata, ModelProcessorSpec, ModelRegistry,
21
+ PreProcessorConfig, PreprocessedEncoderInputs, PromptReplacement, Tokenizer as TokenResolver,
22
+ TrackedMedia, VideoClip, VisionPreProcessor, VisionProcessorRegistry,
23
};
24
+use thiserror_ext::AsReport as _;
25
use tracing::warn;
26
use vllm_engine_core_client::protocol::dtype::ModelDtype;
27
-use vllm_engine_core_client::protocol::multimodal::{
28
- MmBatchedField, MmFeatureSpec, MmFeatures, MmField, MmFieldElem, MmFlatField, MmKwargsItem,
29
- MmSharedField, MmSlice, PlaceholderRange, SliceSpec,
30
-};
31
-use vllm_engine_core_client::protocol::tensor::WireTensor;
27
+use vllm_engine_core_client::protocol::multimodal::{MmFeatureSpec, MmFeatures, MmKwargsItem};
28
use vllm_text::Prompt;
29
use vllm_text::tokenizer::{DynTokenizer, Tokenizer};
30
@@ -36,14 +32,20 @@ use crate::error::{Error, Result, bail_multimodal, multimodal};
32
use crate::renderer::RenderedPrompt;
33
use crate::request::{ChatContent, ChatContentPart, ChatMessage, ChatRequest};
34
35
+mod expand;
36
+mod image;
37
mod tensor;
38
+mod video;
39
+
40
+use self::expand::expand_prompt_token_ids;
41
42
/// Resolved multimodal support for one loaded model.
43
#[derive(Clone)]
44
pub struct MultimodalModelInfo {
45
context: MultimodalModelContext,
46
spec: ResolvedMultimodalSpec,
46
- image_processor: ResolvedImageProcessor,
47
+ image: Option<ModalitySupport>,
48
+ video: Option<ModalitySupport>,
49
media_connector: Arc<MediaConnector>,
50
}
51
@@ -75,91 +77,171 @@ impl MultimodalModelContext {
77
REGISTRY.lookup(&self.metadata())
78
}
79
78
- /// Resolve a static image preprocessor for one loaded model.
79
- fn resolve_image_processor(&self) -> Option<&'static dyn ImagePreProcessor> {
80
- static REGISTRY: LazyLock<ImageProcessorRegistry> =
81
- LazyLock::new(ImageProcessorRegistry::with_defaults);
80
+ /// Resolve a static vision preprocessor for one loaded model.
81
+ ///
82
+ /// The vision preprocessor serves both the image and video modalities.
83
+ fn resolve_vision_processor(&self) -> Option<&'static dyn VisionPreProcessor> {
84
+ static REGISTRY: LazyLock<VisionProcessorRegistry> =
85
+ LazyLock::new(VisionProcessorRegistry::with_defaults);
86
REGISTRY.find(&self.model_id, self.model_type.as_deref())
87
}
88
}
89
86
-/// Static model-specific prompt and tensor-layout behavior.
90
+/// Static model-specific tensor-layout behavior shared across modalities.
91
#[derive(Clone)]
92
struct ResolvedMultimodalSpec {
93
raw: &'static dyn ModelProcessorSpec,
90
- placeholder_token: String,
91
- placeholder_marker_token_id: u32,
92
- placeholder_embed_token_id: u32,
94
field_layouts: HashMap<String, FieldLayout>,
95
keep_on_cpu_keys: HashSet<String>,
96
}
97
98
impl ResolvedMultimodalSpec {
98
- fn new(raw: &'static dyn ModelProcessorSpec, context: &MultimodalModelContext) -> Result<Self> {
99
- let metadata = context.metadata();
100
- let placeholder_token =
101
- raw.placeholder_token(&metadata).map_err(|error| multimodal!("{error}"))?;
102
- // This is the rendered prompt marker, so resolve it from the token
103
- // string itself. Do not use `ModelProcessorSpec::placeholder_token_id()`:
104
- // for some specs that ID is the replacement vision/patch token,
105
- // not necessarily the token ID of `placeholder_token`.
106
- let placeholder_marker_token_id =
107
- context.tokenizer().token_to_id(&placeholder_token).ok_or_else(|| {
108
- multimodal!(
109
- "placeholder token `{placeholder_token}` is not in the tokenizer vocabulary"
110
- )
111
- })?;
112
- let placeholder_embed_token_id =
113
- raw.placeholder_token_id(&metadata).map_err(|error| multimodal!("{error}"))? as u32;
114
-
115
- Ok(Self {
99
+ fn new(raw: &'static dyn ModelProcessorSpec) -> Self {
100
+ Self {
101
raw,
117
- placeholder_token,
118
- placeholder_marker_token_id,
119
- placeholder_embed_token_id,
102
field_layouts: raw.field_layouts(),
103
keep_on_cpu_keys: raw.keep_on_cpu_keys().into_iter().collect(),
122
- })
104
+ }
105
}
106
125
- fn prompt_replacements(
107
+ fn prompt_replacements_for(
108
&self,
109
context: &MultimodalModelContext,
128
- preprocessed: &PreprocessedImages,
110
+ preprocessed: &PreprocessedEncoderInputs,
111
+ modality: Modality,
112
) -> Result<Vec<PromptReplacement>> {
130
- self.raw
131
- .prompt_replacements(&context.metadata(), preprocessed)
132
- .map_err(|error| multimodal!("{error}"))
113
+ Ok(self.raw.prompt_replacements_for(&context.metadata(), preprocessed, modality)?)
114
}
115
}
116
136
-/// Static image preprocessor plus its loaded config.
117
+/// Resolved placeholder tokens for one modality.
118
#[derive(Clone)]
138
-struct ResolvedImageProcessor {
139
- raw: &'static dyn ImagePreProcessor,
119
+struct ResolvedPlaceholder {
120
+ token: String,
121
+ /// The token ID emitted for `token` in the rendered prompt.
122
+ marker_token_id: u32,
123
+ /// The model-declared embed token ID marked in `is_embed` masks.
124
+ embed_token_id: u32,
125
+}
126
+
127
+impl ResolvedPlaceholder {
128
+ fn resolve(
129
+ raw: &'static dyn ModelProcessorSpec,
130
+ context: &MultimodalModelContext,
131
+ modality: Modality,
132
+ ) -> Result<Self> {
133
+ let metadata = context.metadata();
134
+ let token = raw.placeholder_token_for(&metadata, modality)?;
135
+ // This is the rendered prompt marker, so resolve it from the token
136
+ // string itself. Do not use `ModelProcessorSpec::placeholder_token_id_for()`:
137
+ // for some specs that ID is the replacement vision/patch token,
138
+ // not necessarily the token ID of the placeholder token.
139
+ let marker_token_id = context.tokenizer().token_to_id(&token).ok_or_else(|| {
140
+ multimodal!("placeholder token `{token}` is not in the tokenizer vocabulary")
141
+ })?;
142
+ let embed_token_id = raw.placeholder_token_id_for(&metadata, modality)? as u32;
143
+
144
+ Ok(Self {
145
+ token,
146
+ marker_token_id,
147
+ embed_token_id,
148
+ })
149
+ }
150
+}
151
+
152
+/// Static per-modality vision preprocessor plus its loaded config and
153
+/// resolved placeholder tokens.
154
+#[derive(Clone)]
155
+struct ModalitySupport {
156
+ placeholder: ResolvedPlaceholder,
157
+ processor: &'static dyn VisionPreProcessor,
158
config: PreProcessorConfig,
159
}
160
143
-/// Request-scoped fetched media, kept together with tracker UUID metadata.
144
-struct FetchedImageMedia {
145
- frames: Vec<Arc<llm_multimodal::ImageFrame>>,
146
- uuids: Vec<Option<String>>,
161
+/// Model-repo config file locations consumed by multimodal support.
162
+#[derive(Debug, Default, Clone, Copy)]
163
+pub struct MultimodalConfigFiles<'a> {
164
+ pub config: Option<&'a Path>,
165
+ pub preprocessor_config: Option<&'a Path>,
166
+ /// Video-specific preprocessor config (`video_preprocessor_config.json`).
167
+ pub video_preprocessor_config: Option<&'a Path>,
168
+ /// Combined processor config (`processor_config.json`), whose modality
169
+ /// sections are fallback preprocessor config sources.
170
+ pub processor_config: Option<&'a Path>,
171
+}
172
+
173
+/// Load a modality's dedicated preprocessor config, falling back to its section
174
+/// in the combined processor config.
175
+fn load_preprocessor_config(
176
+ dedicated_path: Option<&Path>,
177
+ dedicated_name: &str,
178
+ processor_config_path: Option<&Path>,
179
+ processor_section: &str,
180
+) -> Result<Option<PreProcessorConfig>> {
181
+ if let Some(path) = dedicated_path {
182
+ let text = fs::read_to_string(path)
183
+ .map_err(|error| multimodal!("failed to read {dedicated_name}: {error}"))?;
184
+ let config = PreProcessorConfig::from_json(&text)
185
+ .map_err(|error| multimodal!("failed to parse {dedicated_name}: {error}"))?;
186
+ return Ok(Some(config));
187
+ }
188
+
189
+ let Some(path) = processor_config_path else {
190
+ return Ok(None);
191
+ };
192
+ let text = fs::read_to_string(path)
193
+ .map_err(|error| multimodal!("failed to read processor_config.json: {error}"))?;
194
+ let value: serde_json::Value = serde_json::from_str(&text)
195
+ .map_err(|error| multimodal!("failed to parse processor_config.json: {error}"))?;
196
+ let Some(processor) = value.get(processor_section) else {
197
+ return Ok(None);
198
+ };
199
+ let config = PreProcessorConfig::from_value(processor.clone()).map_err(|error| {
200
+ multimodal!("failed to parse {processor_section} from processor_config.json: {error}")
201
+ })?;
202
+ Ok(Some(config))
203
+}
204
+
205
+/// Request-scoped fetched media, split per modality with tracker UUID
206
+/// metadata preserved in request order.
207
+struct FetchedMedia {
208
+ images: Vec<Arc<ImageFrame>>,
209
+ image_uuids: Vec<Option<String>>,
210
+ videos: Vec<Arc<VideoClip>>,
211
+ video_uuids: Vec<Option<String>>,
212
+}
213
+
214
+/// One modality's preprocessed output, ready for the shared expansion and
215
+/// feature-assembly tail.
216
+struct PreparedMedia {
217
+ modality: Modality,
218
+ placeholder: ResolvedPlaceholder,
219
+ /// One replacement per media item, in request order.
220
+ replacements: Vec<PromptReplacement>,
221
+ /// One entry per media item, aligned with `replacements`.
222
+ items: Vec<PreparedItem>,
223
+}
224
+
225
+/// One media item's complete engine kwargs plus identity metadata.
226
+struct PreparedItem {
227
+ data: MmKwargsItem,
228
+ hash: String,
229
+ uuid: Option<String>,
230
}
231
232
impl MultimodalModelInfo {
233
/// Load and resolve multimodal support from model files.
234
///
152
- /// Returns `Ok(Some(_))` only when both the model spec and image processor
153
- /// are registered. File read/parse failures are real errors; unsupported
154
- /// model families are logged and returned as `Ok(None)`.
235
+ /// Returns `Ok(Some(_))` only when the model spec is registered and at
236
+ /// least one modality resolves. File read/parse failures are real errors;
237
+ /// unsupported model families are logged and returned as `Ok(None)`.
238
pub fn from_paths(
239
model_id: String,
240
model_type: Option<String>,
158
- config_path: Option<&Path>,
159
- preprocessor_config_path: Option<&Path>,
241
+ files: MultimodalConfigFiles<'_>,
242
tokenizer: DynTokenizer,
243
) -> Result<Option<Self>> {
162
- let config = match config_path {
244
+ let config = match files.config {
245
Some(path) => {
246
let text = fs::read_to_string(path)
247
.map_err(|error| multimodal!("failed to read config.json: {error}"))?;
@@ -168,17 +250,20 @@ impl MultimodalModelInfo {
250
}
251
None => serde_json::Value::Object(Default::default()),
252
};
171
- let preprocessor_config = match preprocessor_config_path {
172
- Some(path) => {
173
- let text = fs::read_to_string(path).map_err(|error| {
174
- multimodal!("failed to read preprocessor_config.json: {error}")
175
- })?;
176
- PreProcessorConfig::from_json(&text).map_err(|error| {
177
- multimodal!("failed to parse preprocessor_config.json: {error}")
178
- })?
179
- }
180
- None => PreProcessorConfig::default(),
181
- };
253
+ let image_preprocessor_config = load_preprocessor_config(
254
+ files.preprocessor_config,
255
+ "preprocessor_config.json",
256
+ files.processor_config,
257
+ "image_processor",
258
+ )?
259
+ .unwrap_or_default();
260
+ let video_preprocessor_config = load_preprocessor_config(
261
+ files.video_preprocessor_config,
262
+ "video_preprocessor_config.json",
263
+ files.processor_config,
264
+ "video_processor",
265
+ )?
266
+ .unwrap_or_else(|| image_preprocessor_config.clone());
267
268
let context = MultimodalModelContext {
269
model_id,
@@ -187,7 +272,21 @@ impl MultimodalModelInfo {
272
tokenizer: TokenizerResolver(tokenizer),
273
};
274
190
- let Some(spec) = context.resolve_model_spec() else {
275
+ Self::from_loaded(
276
+ context,
277
+ image_preprocessor_config,
278
+ video_preprocessor_config,
279
+ )
280
+ }
281
+
282
+ /// Resolve multimodal support from an assembled context and parsed
283
+ /// preprocessor configs.
284
+ fn from_loaded(
285
+ context: MultimodalModelContext,
286
+ image_preprocessor_config: PreProcessorConfig,
287
+ video_preprocessor_config: PreProcessorConfig,
288
+ ) -> Result<Option<Self>> {
289
+ let Some(raw_spec) = context.resolve_model_spec() else {
290
warn!(
291
model_id = context.model_id,
292
model_type = context.model_type,
@@ -195,47 +294,99 @@ impl MultimodalModelInfo {
294
);
295
return Ok(None);
296
};
198
- let spec = ResolvedMultimodalSpec::new(spec, &context)?;
297
200
- let Some(image_processor) = context.resolve_image_processor() else {
298
+ let Some(processor) = context.resolve_vision_processor() else {
299
warn!(
300
model_id = context.model_id,
301
model_type = context.model_type,
204
- "image processor is not registered; disabling multimodal support for this model"
302
+ "vision processor is not registered; disabling multimodal support for this model"
303
);
304
return Ok(None);
305
};
306
209
- let media_connector = Arc::new(
210
- MediaConnector::new(reqwest::Client::new(), MediaConnectorConfig::default())
211
- .map_err(|error| multimodal!("{error}"))?,
212
- );
307
+ // Warn and disable the modality if the placeholder resolution fails.
308
+ let resolve_placeholder =
309
+ |modality: Modality| match ResolvedPlaceholder::resolve(raw_spec, &context, modality) {
310
+ Ok(placeholder) => Some(placeholder),
311
+ Err(error) => {
312
+ warn!(
313
+ model_id = context.model_id,
314
+ %modality,
315
+ error = %error.as_report(),
316
+ "placeholder tokens did not resolve; disabling this modality for this model"
317
+ );
318
+ None
319
+ }
320
+ };
321
+
322
+ let image = resolve_placeholder(Modality::Image).map(|placeholder| ModalitySupport {
323
+ placeholder,
324
+ processor,
325
+ config: image_preprocessor_config,
326
+ });
327
+
328
+ let video = resolve_placeholder(Modality::Video).and_then(|placeholder| {
329
+ // Placeholder expansion attributes markers to modalities by token
330
+ // ID, so a marker shared with the image modality is ambiguous.
331
+ let image_marker = image.as_ref().map(|image| image.placeholder.marker_token_id);
332
+ if image_marker == Some(placeholder.marker_token_id) {
333
+ warn!(
334
+ model_id = context.model_id,
335
+ token = placeholder.token,
336
+ "video placeholder token collides with the image placeholder; disabling video support for this model"
337
+ );
338
+ None
339
+ } else {
340
+ Some(ModalitySupport {
341
+ placeholder,
342
+ processor,
343
+ config: video_preprocessor_config,
344
+ })
345
+ }
346
+ });
347
+
348
+ if image.is_none() && video.is_none() {
349
+ warn!(
350
+ model_id = context.model_id,
351
+ model_type = context.model_type,
352
+ "no multimodal modality resolved; disabling multimodal support for this model"
353
+ );
354
+ return Ok(None);
355
+ }
356
+
357
+ let media_connector = Arc::new(MediaConnector::new(
358
+ reqwest::Client::new(),
359
+ MediaConnectorConfig::default(),
360
+ )?);
361
362
Ok(Some(Self {
363
context,
216
- spec,
217
- image_processor: ResolvedImageProcessor {
218
- raw: image_processor,
219
- config: preprocessor_config,
220
- },
364
+ spec: ResolvedMultimodalSpec::new(raw_spec),
365
+ image,
366
+ video,
367
media_connector,
368
}))
369
}
370
225
- /// Return the template-visible placeholder token for this model.
371
+ /// Return the template-visible placeholder token for one modality, when
372
+ /// this model supports it.
373
///
227
- /// The HF renderer uses this token while flattening image content in string
228
- /// content format.
229
- pub fn placeholder_token(&self) -> &str {
230
- &self.spec.placeholder_token
374
+ /// The HF renderer uses these tokens while flattening media content in
375
+ /// string content format.
376
+ pub fn placeholder_token(&self, modality: Modality) -> Option<&str> {
377
+ match modality {
378
+ Modality::Image => self.image.as_ref()?.placeholder.token.as_str().into(),
379
+ Modality::Video => self.video.as_ref()?.placeholder.token.as_str().into(),
380
+ _ => None,
381
+ }
382
}
383
}
384
385
/// Finalize a rendered chat prompt into text-generation input.
386
///
387
/// Text-only requests pass through unchanged as `Prompt::Text`. Multimodal
237
-/// requests are tokenized in chat, their image placeholders are expanded, and
238
-/// preprocessed image features are attached for engine-core transport.
388
+/// requests are tokenized in chat, their media placeholders are expanded, and
389
+/// preprocessed media features are attached for engine-core transport.
390
pub(crate) async fn finalize_rendered_prompt(
391
request: &ChatRequest,
392
rendered: RenderedPrompt,
@@ -260,7 +411,7 @@ pub(crate) async fn finalize_rendered_prompt(
411
Ok((Prompt::TokenIds(prompt_token_ids), Some(prepared)))
412
}
413
263
-/// Extract image media parts from chat messages in message/content order.
414
+/// Extract media parts from chat messages in message/content order.
415
///
416
/// Assistant history is skipped because generated assistant blocks are already
417
/// represented as text for prompt rendering in this crate.
@@ -289,6 +440,12 @@ fn extract_media_parts(request: &ChatRequest) -> Result<Vec<MediaContentPart>> {
440
detail: *detail,
441
uuid: uuid.clone(),
442
}),
443
+ ChatContentPart::VideoUrl { video_url, uuid } => {
444
+ all_parts.push(MediaContentPart::VideoUrl {
445
+ url: video_url.clone(),
446
+ uuid: uuid.clone(),
447
+ })
448
+ }
449
}
450
}
451
}
@@ -296,8 +453,8 @@ fn extract_media_parts(request: &ChatRequest) -> Result<Vec<MediaContentPart>> {
453
}
454
455
impl MultimodalModelInfo {
299
- /// Run media fetch, image preprocessing, prompt expansion, and feature
300
- /// build.
456
+ /// Run media fetch, per-modality preprocessing, prompt expansion, and
457
+ /// feature build.
458
///
459
/// `prompt_token_ids` is mutated in place because placeholder expansion
460
/// changes both the final prompt and the offsets recorded in
@@ -313,12 +470,47 @@ impl MultimodalModelInfo {
470
}
471
let media_parts_len = media_parts.len();
472
316
- let fetched = self.fetch_images(media_parts).await?;
317
- let preprocessed = self.preprocess_images(&fetched.frames).await?;
318
- let replacements = self.spec.prompt_replacements(&self.context, &preprocessed)?;
319
- let ranges = self.expand_prompt_tokens(prompt_token_ids, replacements)?;
473
+ // TODO: enforce per-modality item-count limits, aligned with the
474
+ // engine's `--limit-mm-per-prompt` semantics.
475
+ let fetched = self.fetch_media(media_parts).await?;
476
+
477
+ let mut prepared = Vec::new();
478
+ if !fetched.images.is_empty() {
479
+ prepared
480
+ .push(self.prepare_images(fetched.images, fetched.image_uuids, model_dtype).await?);
481
+ }
482
+ if !fetched.videos.is_empty() {
483
+ prepared
484
+ .push(self.prepare_videos(fetched.videos, fetched.video_uuids, model_dtype).await?);
485
+ }
486
+
487
+ let mut ranges = expand_prompt_token_ids(prompt_token_ids, &prepared)?;
488
+
489
+ let mut features = Vec::with_capacity(media_parts_len);
490
+ for media in prepared {
491
+ let media_ranges = ranges.remove(&media.modality).unwrap_or_default();
492
+ if media_ranges.len() != media.items.len() {
493
+ bail_multimodal!(
494
+ "number of expanded `{}` placeholders {} does not match number of media items {}",
495
+ media.modality,
496
+ media_ranges.len(),
497
+ media.items.len()
498
+ );
499
+ }
500
+ for (item, range) in izip!(media.items, media_ranges) {
501
+ features.push(MmFeatureSpec {
502
+ data: Some(item.data),
503
+ modality: media.modality.to_string(),
504
+ identifier: item.uuid.unwrap_or_else(|| item.hash.clone()),
505
+ mm_position: range,
506
+ mm_hash: Some(item.hash),
507
+ });
508
+ }
509
+ }
510
+ // Mirror the Python frontend (`argsort_mm_positions`): features are
511
+ // ordered by their placeholder position in the prompt.
512
+ features.sort_by_key(|feature| feature.mm_position.offset);
513
321
- let features = self.build_features(preprocessed, fetched, ranges, model_dtype)?;
514
if features.len() != media_parts_len {
515
bail_multimodal!(
516
"number of built multimodal features {} does not match number of media parts {}",
@@ -329,219 +521,51 @@ impl MultimodalModelInfo {
521
Ok(features)
522
}
523
332
- /// Fetch all image parts and preserve their request-order UUID metadata.
333
- async fn fetch_images(&self, media_parts: Vec<MediaContentPart>) -> Result<FetchedImageMedia> {
524
+ /// Fetch all media parts and split them per modality, preserving their
525
+ /// request-order UUID metadata.
526
+ async fn fetch_media(&self, media_parts: Vec<MediaContentPart>) -> Result<FetchedMedia> {
527
let mut tracker = AsyncMultiModalTracker::new(Arc::clone(&self.media_connector));
528
for part in media_parts {
336
- tracker.push_part(part).map_err(|error| multimodal!("{error}"))?;
529
+ tracker.push_part(part)?;
530
}
531
339
- let tracker_output = tracker.finalize().await.map_err(|error| multimodal!("{error}"))?;
340
- let images = tracker_output.data.get(&Modality::Image).cloned().unwrap_or_default();
341
- let uuids = tracker_output.uuids.get(&Modality::Image).cloned().unwrap_or_default();
532
+ let mut tracker_output = tracker.finalize().await?;
533
343
- let frames = images
534
+ let images = tracker_output
535
+ .data
536
+ .remove(&Modality::Image)
537
+ .unwrap_or_default()
538
.into_iter()
539
.map(|media| match media {
540
TrackedMedia::Image(frame) => Ok(frame),
347
- _ => Err(Error::UnsupportedMultimodalContent("non-image")),
541
+ _ => Err(multimodal!(
542
+ "tracker returned non-image media for the image modality"
543
+ )),
544
})
545
.collect::<Result<Vec<_>>>()?;
546
+ let image_uuids = tracker_output.uuids.remove(&Modality::Image).unwrap_or_default();
547
351
- Ok(FetchedImageMedia { frames, uuids })
352
- }
548
+ let videos = tracker_output
549
+ .data
550
+ .remove(&Modality::Video)
551
+ .unwrap_or_default()
552
+ .into_iter()
553
+ .map(|media| match media {
554
+ TrackedMedia::Video(clip) => Ok(clip),
555
+ _ => Err(multimodal!(
556
+ "tracker returned non-video media for the video modality"
557
+ )),
558
+ })
559
+ .collect::<Result<Vec<_>>>()?;
560
+ let video_uuids = tracker_output.uuids.remove(&Modality::Video).unwrap_or_default();
561
354
- /// Preprocess fetched image frames with the model's resolved image
355
- /// processor.
356
- ///
357
- /// The processor work is CPU-heavy relative to request wiring, so it runs
358
- /// in a blocking task and returns owned tensors ready for wire
359
- /// conversion.
360
- async fn preprocess_images(
361
- &self,
362
- image_frames: &[Arc<llm_multimodal::ImageFrame>],
363
- ) -> Result<PreprocessedImages> {
364
- let config = self.image_processor.config.clone();
365
- let processor = self.image_processor.raw;
366
- let images = image_frames.iter().map(|frame| frame.data().clone()).collect::<Vec<_>>();
367
-
368
- // TODO: is it still necessary given that we've already in a dedicated runtime?
369
- tokio::task::spawn_blocking(move || {
370
- processor.preprocess(&images, &config).map_err(|error| multimodal!("{error}"))
562
+ Ok(FetchedMedia {
563
+ images,
564
+ image_uuids,
565
+ videos,
566
+ video_uuids,
567
})
372
- .await
373
- .map_err(|error| multimodal!("image preprocessing task failed: {error}"))?
374
- }
375
-
376
- /// Replace rendered placeholder markers with model-specific replacement
377
- /// tokens.
378
- ///
379
- /// Replacements are consumed in order, matching the original media-part
380
- /// order. The returned ranges point into the already-expanded prompt.
381
- fn expand_prompt_tokens(
382
- &self,
383
- prompt_token_ids: &mut Vec<u32>,
384
- replacements: Vec<PromptReplacement>,
385
- ) -> Result<Vec<PlaceholderRange>> {
386
- expand_prompt_token_ids(
387
- prompt_token_ids,
388
- replacements,
389
- self.spec.placeholder_marker_token_id,
390
- self.spec.placeholder_embed_token_id,
391
- &self.spec.placeholder_token,
392
- )
568
}
394
-
395
- /// Convert preprocessed image tensors into engine-core multimodal features.
396
- ///
397
- /// One `MmFeatureSpec` is produced per image. Tensor fields are
398
- /// sliced according to the model spec's field layout declarations.
399
- fn build_features(
400
- &self,
401
- preprocessed: PreprocessedImages,
402
- images: FetchedImageMedia,
403
- ranges: Vec<PlaceholderRange>,
404
- model_dtype: ModelDtype,
405
- ) -> Result<MmFeatures> {
406
- let len = images.frames.len();
407
- let tensors = tensor::collect_tensors(preprocessed, model_dtype)?;
408
-
409
- let mut features = Vec::with_capacity(images.frames.len());
410
- for (index, (frame, uuid, range)) in izip!(images.frames, images.uuids, ranges).enumerate()
411
- {
412
- let mut data = MmKwargsItem::new();
413
- for (key, tensor) in &tensors {
414
- let keep_on_cpu = self.spec.keep_on_cpu_keys.contains(key);
415
- let (value, field) = match self.spec.field_layouts.get(key) {
416
- Some(FieldLayout::Batched) => (
417
- tensor.batched_value_at(index)?,
418
- MmField::Batched(MmBatchedField { keep_on_cpu }),
419
- ),
420
- Some(FieldLayout::Flat { sizes_key }) => {
421
- let sizes = tensors.get(sizes_key).ok_or_else(|| {
422
- multimodal!("flat tensor sizes key `{sizes_key}` is missing")
423
- })?;
424
- let (start, end) = tensor::flat_range_for_index(sizes, sizes_key, index)?;
425
- (
426
- tensor.flat_value_range(start, end)?,
427
- MmField::Flat(MmFlatField {
428
- slices: vec![MmSlice::Slice(SliceSpec {
429
- start: Some(0),
430
- stop: Some((end - start) as isize),
431
- step: None,
432
- })],
433
- dim: 0,
434
- keep_on_cpu,
435
- }),
436
- )
437
- }
438
- None => (
439
- tensor.clone(),
440
- MmField::Shared(MmSharedField {
441
- batch_size: len,
442
- keep_on_cpu,
443
- }),
444
- ),
445
- };
446
-
447
- data.insert(
448
- key.clone(),
449
- MmFieldElem {
450
- data: Some(value.try_into()?),
451
- field,
452
- },
453
- );
454
- }
455
-
456
- let hash = frame.hash.clone();
457
- features.push(MmFeatureSpec {
458
- data: Some(data),
459
- modality: "image".to_string(),
460
- identifier: uuid.unwrap_or_else(|| hash.clone()),
461
- mm_position: range,
462
- mm_hash: Some(hash),
463
- });
464
- }
465
-
466
- Ok(features)
467
- }
468
-}
469
-
470
-fn expand_prompt_token_ids(
471
- prompt_token_ids: &mut Vec<u32>,
472
- replacements: Vec<PromptReplacement>,
473
- placeholder_marker_token_id: u32,
474
- placeholder_embed_token_id: u32,
475
- placeholder_token: &str,
476
-) -> Result<Vec<PlaceholderRange>> {
477
- if replacements.is_empty() {
478
- return Ok(Vec::new());
479
- }
480
-
481
- let replacement_growth = replacements.iter().fold(0usize, |total, replacement| {
482
- total.saturating_add(replacement.tokens.len().saturating_sub(1))
483
- });
484
- let mut expanded =
485
- Vec::with_capacity(prompt_token_ids.len().saturating_add(replacement_growth));
486
- let mut ranges = Vec::with_capacity(replacements.len());
487
- let mut cursor = 0usize;
488
-
489
- for replacement in replacements {
490
- if replacement.modality != Modality::Image {
491
- bail_multimodal!(
492
- "unsupported prompt replacement modality `{}`",
493
- replacement.modality
494
- );
495
- }
496
-
497
- let offset = find_next_token(prompt_token_ids, placeholder_marker_token_id, cursor)
498
- .ok_or_else(|| {
499
- multimodal!(
500
- "placeholder token `{placeholder_token}` was not found in tokenized prompt"
501
- )
502
- })?;
503
-
504
- if replacement.tokens.is_empty() {
505
- bail_multimodal!("placeholder token `{placeholder_token}` expanded to no tokens");
506
- }
507
-
508
- let replacement_len = replacement.tokens.len();
509
- let is_embed = {
510
- let mask = replacement
511
- .tokens
512
- .iter()
513
- .map(|&token| token as u32 == placeholder_embed_token_id)
514
- .collect::<Vec<_>>();
515
- WireTensor::from_bool(vec![replacement_len], mask).map_err(Error::Multimodal)?
516
- };
517
-
518
- expanded.extend_from_slice(&prompt_token_ids[cursor..offset]);
519
- let expanded_offset = expanded.len();
520
- expanded.extend(replacement.tokens.into_iter().map(|token| token as u32));
521
- ranges.push(PlaceholderRange {
522
- offset: expanded_offset,
523
- length: replacement_len,
524
- is_embed: Some(is_embed),
525
- });
526
- cursor = offset + 1;
527
- }
528
-
529
- expanded.extend_from_slice(&prompt_token_ids[cursor..]);
530
- *prompt_token_ids = expanded;
531
-
532
- Ok(ranges)
533
-}
534
-
535
-/// Find `needle` in `haystack`, starting at `start`.
536
-///
537
-/// This is intentionally order-preserving rather than a global replace: each
538
-/// image consumes the next placeholder occurrence.
539
-fn find_next_token(haystack: &[u32], needle: u32, start: usize) -> Option<usize> {
540
- haystack
541
- .get(start..)?
542
- .iter()
543
- .position(|token| *token == needle)
544
- .map(|offset| start + offset)
569
}
570
571
/// Adapter from the frontend tokenizer trait to `llm-multimodal`.
@@ -566,18 +590,19 @@ impl TokenResolver for TokenizerResolver {
590
mod tests {
591
use std::sync::Arc;
592
569
- use llm_multimodal::TokenId;
570
- use vllm_engine_core_client::protocol::tensor::WireArrayData;
593
use vllm_tokenizer::test_utils::TestTokenizer;
594
595
use super::*;
596
575
- const LLAMA4_IMAGE_START_ID: u32 = 200088;
576
- const LLAMA4_IMAGE_END_ID: u32 = 200089;
577
- const LLAMA4_IMAGE_ID: u32 = 200090;
578
- const LLAMA4_PATCH_ID: u32 = 200092;
579
- const LLAMA4_TILE_X_SEPARATOR_ID: u32 = 200093;
580
- const LLAMA4_TILE_Y_SEPARATOR_ID: u32 = 200094;
597
+ pub(super) const LLAMA4_IMAGE_START_ID: u32 = 200088;
598
+ pub(super) const LLAMA4_IMAGE_END_ID: u32 = 200089;
599
+ pub(super) const LLAMA4_IMAGE_ID: u32 = 200090;
600
+ pub(super) const LLAMA4_PATCH_ID: u32 = 200092;
601
+ pub(super) const LLAMA4_TILE_X_SEPARATOR_ID: u32 = 200093;
602
+ pub(super) const LLAMA4_TILE_Y_SEPARATOR_ID: u32 = 200094;
603
+
604
+ pub(super) const QWEN3_IMAGE_PAD_ID: u32 = 151655;
605
+ pub(super) const QWEN3_VIDEO_PAD_ID: u32 = 151656;
606
607
fn llama4_tokenizer() -> TestTokenizer {
608
TestTokenizer::new()
@@ -589,33 +614,31 @@ mod tests {
614
.with_regular_token("<|tile_y_separator|>", LLAMA4_TILE_Y_SEPARATOR_ID)
615
}
616
592
- fn test_info(model_type: &str, config: serde_json::Value) -> MultimodalModelInfo {
617
+ pub(super) fn qwen3_vl_tokenizer() -> TestTokenizer {
618
+ TestTokenizer::new()
619
+ .with_regular_token("<|image_pad|>", QWEN3_IMAGE_PAD_ID)
620
+ .with_regular_token("<|video_pad|>", QWEN3_VIDEO_PAD_ID)
621
+ }
622
+
623
+ fn test_info(
624
+ model_type: &str,
625
+ config: serde_json::Value,
626
+ tokenizer: TestTokenizer,
627
+ ) -> MultimodalModelInfo {
628
let context = MultimodalModelContext {
629
model_id: format!("{model_type}-test"),
630
model_type: Some(model_type.to_string()),
631
config,
597
- tokenizer: TokenizerResolver(Arc::new(llama4_tokenizer())),
632
+ tokenizer: TokenizerResolver(Arc::new(tokenizer)),
633
};
599
- let spec = context
600
- .resolve_model_spec()
601
- .unwrap_or_else(|| panic!("{model_type} spec should match"));
602
- let spec = ResolvedMultimodalSpec::new(spec, &context).unwrap();
603
- let raw_image_processor = context
604
- .resolve_image_processor()
605
- .unwrap_or_else(|| panic!("{model_type} image processor should match"));
606
- let media_connector = Arc::new(
607
- MediaConnector::new(reqwest::Client::new(), MediaConnectorConfig::default()).unwrap(),
608
- );
634
610
- MultimodalModelInfo {
635
+ MultimodalModelInfo::from_loaded(
636
context,
612
- spec,
613
- image_processor: ResolvedImageProcessor {
614
- raw: raw_image_processor,
615
- config: PreProcessorConfig::default(),
616
- },
617
- media_connector,
618
- }
637
+ PreProcessorConfig::default(),
638
+ PreProcessorConfig::default(),
639
+ )
640
+ .unwrap()
641
+ .unwrap_or_else(|| panic!("{model_type} multimodal support should resolve"))
642
}
643
644
fn llama4_info() -> MultimodalModelInfo {
@@ -624,173 +647,96 @@ mod tests {
647
"image_token_index": LLAMA4_PATCH_ID,
648
"vision_config": {"image_size": 336, "patch_size": 14}
649
});
627
- test_info("llama4", config)
650
+ test_info("llama4", config, llama4_tokenizer())
651
}
652
630
- fn llama4_single_tile_replacement() -> PromptReplacement {
631
- PromptReplacement::sequence(
632
- Modality::Image,
633
- "<|image|>",
634
- vec![
635
- LLAMA4_IMAGE_START_ID as TokenId,
636
- LLAMA4_IMAGE_ID as TokenId,
637
- LLAMA4_PATCH_ID as TokenId,
638
- LLAMA4_PATCH_ID as TokenId,
639
- LLAMA4_IMAGE_END_ID as TokenId,
640
- ],
641
- )
653
+ pub(super) fn qwen3_vl_info() -> MultimodalModelInfo {
654
+ let config = serde_json::json!({
655
+ "model_type": "qwen3_vl",
656
+ "image_token_id": QWEN3_IMAGE_PAD_ID,
657
+ "video_token_id": QWEN3_VIDEO_PAD_ID,
658
+ "vision_start_token_id": 151652,
659
+ "vision_end_token_id": 151653,
660
+ "vision_config": {"patch_size": 16}
661
+ });
662
+ test_info("qwen3_vl", config, qwen3_vl_tokenizer())
663
}
664
644
- fn llama4_multi_tile_replacement() -> PromptReplacement {
645
- PromptReplacement::sequence(
646
- Modality::Image,
647
- "<|image|>",
648
- vec![
649
- LLAMA4_IMAGE_START_ID as TokenId,
650
- LLAMA4_PATCH_ID as TokenId,
651
- LLAMA4_TILE_X_SEPARATOR_ID as TokenId,
652
- LLAMA4_PATCH_ID as TokenId,
653
- LLAMA4_TILE_Y_SEPARATOR_ID as TokenId,
654
- LLAMA4_IMAGE_ID as TokenId,
655
- LLAMA4_PATCH_ID as TokenId,
656
- LLAMA4_IMAGE_END_ID as TokenId,
657
- ],
665
+ #[test]
666
+ fn from_paths_resolves_image_config_from_processor_config() {
667
+ let dir = tempfile::tempdir().unwrap();
668
+ let config_path = dir.path().join("config.json");
669
+ std::fs::write(
670
+ &config_path,
671
+ serde_json::json!({
672
+ "model_type": "qwen3_vl",
673
+ "image_token_id": QWEN3_IMAGE_PAD_ID,
674
+ })
675
+ .to_string(),
676
)
659
- }
677
+ .unwrap();
678
+ let processor_config_path = dir.path().join("processor_config.json");
679
+ std::fs::write(
680
+ &processor_config_path,
681
+ r#"{"image_processor":{"size":{"shortest_edge":64}}}"#,
682
+ )
683
+ .unwrap();
684
+
685
+ let info = MultimodalModelInfo::from_paths(
686
+ "qwen3-vl-test".to_string(),
687
+ Some("qwen3_vl".to_string()),
688
+ MultimodalConfigFiles {
689
+ config: Some(&config_path),
690
+ processor_config: Some(&processor_config_path),
691
+ ..Default::default()
692
+ },
693
+ Arc::new(qwen3_vl_tokenizer()),
694
+ )
695
+ .unwrap()
696
+ .unwrap();
697
661
- fn assert_bool_mask(range: &PlaceholderRange, expected: &[bool]) {
662
- let tensor = range.is_embed.as_ref().expect("is_embed mask");
663
- assert_eq!(tensor.dtype, "bool");
664
- assert_eq!(tensor.shape, vec![expected.len()]);
665
- assert_eq!(
666
- tensor.data,
667
- WireArrayData::RawView(expected.iter().map(|value| u8::from(*value)).collect())
668
- );
698
+ assert_eq!(info.image.unwrap().config.get_shortest_edge(), Some(64));
699
}
700
701
#[test]
672
- fn expand_prompt_tokens_marks_only_llama4_patch_tokens_as_embed() {
673
- let info = llama4_info();
674
- let mut prompt_token_ids = vec![1, LLAMA4_IMAGE_ID, 2];
675
- let replacements = vec![llama4_multi_tile_replacement()];
676
-
677
- let ranges = info.expand_prompt_tokens(&mut prompt_token_ids, replacements).unwrap();
702
+ fn qwen3_vl_resolves_image_and_video_support() {
703
+ let info = qwen3_vl_info();
704
705
assert_eq!(
680
- prompt_token_ids,
681
- vec![
682
- 1,
683
- LLAMA4_IMAGE_START_ID,
684
- LLAMA4_PATCH_ID,
685
- LLAMA4_TILE_X_SEPARATOR_ID,
686
- LLAMA4_PATCH_ID,
687
- LLAMA4_TILE_Y_SEPARATOR_ID,
688
- LLAMA4_IMAGE_ID,
689
- LLAMA4_PATCH_ID,
690
- LLAMA4_IMAGE_END_ID,
691
- 2,
692
- ]
706
+ info.placeholder_token(Modality::Image),
707
+ Some("<|image_pad|>")
708
);
694
- assert_eq!(ranges[0].offset, 1);
695
- assert_eq!(ranges[0].length, 8);
696
- assert_bool_mask(
697
- &ranges[0],
698
- &[false, true, false, true, false, false, true, false],
709
+ assert_eq!(
710
+ info.placeholder_token(Modality::Video),
711
+ Some("<|video_pad|>")
712
+ );
713
+ assert_ne!(
714
+ info.image.as_ref().unwrap().placeholder.marker_token_id,
715
+ info.video.as_ref().unwrap().placeholder.marker_token_id,
716
);
717
}
718
719
#[test]
703
- fn expand_prompt_tokens_errors_when_placeholder_missing() {
704
- let info = llama4_info();
705
- let mut prompt_token_ids = vec![1, 2, 3];
706
- let replacements = vec![llama4_single_tile_replacement()];
707
-
708
- let error = info.expand_prompt_tokens(&mut prompt_token_ids, replacements).unwrap_err();
709
-
710
- assert!(matches!(error, Error::Multimodal(message) if message.contains("not found")));
711
- }
712
-
713
- #[test]
714
- fn expand_prompt_tokens_ignores_empty_replacements() {
715
- let info = llama4_info();
716
- let mut prompt_token_ids = vec![1, LLAMA4_IMAGE_ID, 2];
717
- let original_prompt_token_ids = prompt_token_ids.clone();
718
-
719
- let ranges = info.expand_prompt_tokens(&mut prompt_token_ids, Vec::new()).unwrap();
720
-
721
- assert!(ranges.is_empty());
722
- assert_eq!(prompt_token_ids, original_prompt_token_ids);
723
- }
724
-
725
- #[test]
726
- fn expand_prompt_tokens_leaves_prompt_unchanged_when_later_placeholder_missing() {
727
- let info = llama4_info();
728
- let mut prompt_token_ids = vec![1, LLAMA4_IMAGE_ID, 2];
729
- let original_prompt_token_ids = prompt_token_ids.clone();
730
- let replacements = vec![
731
- llama4_single_tile_replacement(),
732
- llama4_single_tile_replacement(),
733
- ];
734
-
735
- let error = info.expand_prompt_tokens(&mut prompt_token_ids, replacements).unwrap_err();
736
-
737
- assert!(matches!(error, Error::Multimodal(message) if message.contains("not found")));
738
- assert_eq!(prompt_token_ids, original_prompt_token_ids);
739
- }
720
+ fn qwen3_vl_without_video_token_id_disables_video_support_only() {
721
+ let config = serde_json::json!({
722
+ "model_type": "qwen3_vl",
723
+ "image_token_id": QWEN3_IMAGE_PAD_ID,
724
+ "vision_config": {"patch_size": 16}
725
+ });
726
+ let info = test_info("qwen3_vl", config, qwen3_vl_tokenizer());
727
741
- #[test]
742
- fn expand_prompt_tokens_errors_when_replacement_is_empty() {
743
- let info = llama4_info();
744
- let mut prompt_token_ids = vec![1, LLAMA4_IMAGE_ID, 2];
745
- let original_prompt_token_ids = prompt_token_ids.clone();
746
- let replacements = vec![PromptReplacement::sequence(
747
- Modality::Image,
748
- "<|image|>",
749
- Vec::new(),
750
- )];
751
-
752
- let error = info.expand_prompt_tokens(&mut prompt_token_ids, replacements).unwrap_err();
753
-
754
- assert!(
755
- matches!(error, Error::Multimodal(message) if message.contains("expanded to no tokens"))
728
+ assert_eq!(
729
+ info.placeholder_token(Modality::Image),
730
+ Some("<|image_pad|>")
731
);
757
- assert_eq!(prompt_token_ids, original_prompt_token_ids);
732
+ assert_eq!(info.placeholder_token(Modality::Video), None);
733
}
734
735
#[test]
761
- fn expand_prompt_tokens_skips_llama4_image_marker_inside_replacement() {
736
+ fn llama4_resolves_image_support_only() {
737
let info = llama4_info();
763
- let mut prompt_token_ids = vec![1, LLAMA4_IMAGE_ID, 2, LLAMA4_IMAGE_ID, 3];
764
- let replacements = vec![
765
- llama4_single_tile_replacement(),
766
- llama4_single_tile_replacement(),
767
- ];
768
-
769
- let ranges = info.expand_prompt_tokens(&mut prompt_token_ids, replacements).unwrap();
738
771
- assert_eq!(
772
- prompt_token_ids,
773
- vec![
774
- 1,
775
- LLAMA4_IMAGE_START_ID,
776
- LLAMA4_IMAGE_ID,
777
- LLAMA4_PATCH_ID,
778
- LLAMA4_PATCH_ID,
779
- LLAMA4_IMAGE_END_ID,
780
- 2,
781
- LLAMA4_IMAGE_START_ID,
782
- LLAMA4_IMAGE_ID,
783
- LLAMA4_PATCH_ID,
784
- LLAMA4_PATCH_ID,
785
- LLAMA4_IMAGE_END_ID,
786
- 3,
787
- ]
788
- );
789
- assert_eq!(ranges[0].offset, 1);
790
- assert_eq!(ranges[0].length, 5);
791
- assert_bool_mask(&ranges[0], &[false, false, true, true, false]);
792
- assert_eq!(ranges[1].offset, 7);
793
- assert_eq!(ranges[1].length, 5);
794
- assert_bool_mask(&ranges[1], &[false, false, true, true, false]);
739
+ assert_eq!(info.placeholder_token(Modality::Image), Some("<|image|>"));
740
+ assert_eq!(info.placeholder_token(Modality::Video), None);
741
}
742
}
rust/src/chat/src/multimodal/expand.rs
new
+446
@@ -0,0 +1,446 @@
1
+//! Prompt placeholder expansion shared across modalities.
2
+
3
+use std::collections::{HashMap, VecDeque};
4
+
5
+use llm_multimodal::{Modality, PromptReplacement};
6
+use vllm_engine_core_client::protocol::multimodal::PlaceholderRange;
7
+use vllm_engine_core_client::protocol::tensor::WireTensor;
8
+
9
+use super::PreparedMedia;
10
+use crate::error::{Error, Result, bail_multimodal};
11
+
12
+/// One modality's queue of pending placeholder replacements for prompt
13
+/// expansion.
14
+struct ExpansionLane<'a> {
15
+ modality: Modality,
16
+ marker_token_id: u32,
17
+ embed_token_id: u32,
18
+ placeholder_token: String,
19
+ replacements: VecDeque<&'a PromptReplacement>,
20
+}
21
+
22
+impl<'a> ExpansionLane<'a> {
23
+ fn from_prepared(media: &'a PreparedMedia) -> Option<Self> {
24
+ if media.replacements.is_empty() {
25
+ return None;
26
+ }
27
+
28
+ Some(Self {
29
+ modality: media.modality,
30
+ marker_token_id: media.placeholder.marker_token_id,
31
+ embed_token_id: media.placeholder.embed_token_id,
32
+ placeholder_token: media.placeholder.token.clone(),
33
+ replacements: media.replacements.iter().collect(),
34
+ })
35
+ }
36
+}
37
+
38
+/// Replace rendered placeholder markers with model-specific replacement
39
+/// tokens across all modalities in one left-to-right pass.
40
+///
41
+/// Each prepared modality consumes its own marker occurrences in order,
42
+/// matching the original media-part order within that modality; markers of
43
+/// different modalities may interleave freely.
44
+///
45
+/// The returned ranges point into the already-expanded prompt, grouped per
46
+/// modality in item order.
47
+pub(super) fn expand_prompt_token_ids(
48
+ prompt_token_ids: &mut Vec<u32>,
49
+ prepared: &[PreparedMedia],
50
+) -> Result<HashMap<Modality, Vec<PlaceholderRange>>> {
51
+ let mut lanes = prepared.iter().filter_map(ExpansionLane::from_prepared).collect::<Vec<_>>();
52
+ if lanes.is_empty() {
53
+ return Ok(HashMap::new());
54
+ }
55
+
56
+ let replacement_growth = lanes
57
+ .iter()
58
+ .flat_map(|lane| lane.replacements.iter())
59
+ .fold(0usize, |total, replacement| {
60
+ total.saturating_add(replacement.tokens.len().saturating_sub(1))
61
+ });
62
+ let expanded_len = prompt_token_ids.len().saturating_add(replacement_growth);
63
+
64
+ let mut expanded = Vec::with_capacity(expanded_len);
65
+ let mut ranges = HashMap::<Modality, Vec<PlaceholderRange>>::new();
66
+
67
+ for &token in prompt_token_ids.iter() {
68
+ let lane = lanes
69
+ .iter_mut()
70
+ .find(|lane| lane.marker_token_id == token && !lane.replacements.is_empty());
71
+ let Some(lane) = lane else {
72
+ expanded.push(token);
73
+ continue;
74
+ };
75
+
76
+ let replacement = lane.replacements.pop_front().expect("lane queue is non-empty");
77
+ debug_assert_eq!(replacement.modality, lane.modality);
78
+ if replacement.tokens.is_empty() {
79
+ bail_multimodal!(
80
+ "placeholder token `{}` expanded to no tokens",
81
+ lane.placeholder_token
82
+ );
83
+ }
84
+
85
+ let replacement_len = replacement.tokens.len();
86
+ let is_embed = {
87
+ let mask = replacement
88
+ .tokens
89
+ .iter()
90
+ .map(|&token| token as u32 == lane.embed_token_id)
91
+ .collect::<Vec<_>>();
92
+ WireTensor::from_bool(vec![replacement_len], mask).map_err(Error::Multimodal)?
93
+ };
94
+
95
+ let expanded_offset = expanded.len();
96
+ expanded.extend(replacement.tokens.iter().map(|&token| token as u32));
97
+ ranges.entry(lane.modality).or_default().push(PlaceholderRange {
98
+ offset: expanded_offset,
99
+ length: replacement_len,
100
+ is_embed: Some(is_embed),
101
+ });
102
+ }
103
+
104
+ for lane in &lanes {
105
+ if !lane.replacements.is_empty() {
106
+ bail_multimodal!(
107
+ "placeholder token `{}` was not found in tokenized prompt for {} remaining `{}` item(s)",
108
+ lane.placeholder_token,
109
+ lane.replacements.len(),
110
+ lane.modality
111
+ );
112
+ }
113
+ }
114
+
115
+ *prompt_token_ids = expanded;
116
+
117
+ Ok(ranges)
118
+}
119
+
120
+#[cfg(test)]
121
+mod tests {
122
+ use llm_multimodal::TokenId;
123
+ use vllm_engine_core_client::protocol::tensor::WireArrayData;
124
+
125
+ use super::super::tests::{
126
+ LLAMA4_IMAGE_END_ID, LLAMA4_IMAGE_ID, LLAMA4_IMAGE_START_ID, LLAMA4_PATCH_ID,
127
+ LLAMA4_TILE_X_SEPARATOR_ID, LLAMA4_TILE_Y_SEPARATOR_ID, QWEN3_IMAGE_PAD_ID,
128
+ QWEN3_VIDEO_PAD_ID,
129
+ };
130
+ use super::super::{PreparedMedia, ResolvedPlaceholder};
131
+ use super::*;
132
+
133
+ /// Build prepared media directly from placeholder token IDs.
134
+ fn prepared_media(
135
+ modality: Modality,
136
+ placeholder_token: &str,
137
+ marker_token_id: u32,
138
+ embed_token_id: u32,
139
+ replacements: Vec<PromptReplacement>,
140
+ ) -> PreparedMedia {
141
+ PreparedMedia {
142
+ modality,
143
+ placeholder: ResolvedPlaceholder {
144
+ token: placeholder_token.to_string(),
145
+ marker_token_id,
146
+ embed_token_id,
147
+ },
148
+ replacements,
149
+ items: Vec::new(),
150
+ }
151
+ }
152
+
153
+ /// Llama4 image prepared media: the `<|image|>` marker expands to
154
+ /// sequences whose embed positions are the `<|patch|>` tokens.
155
+ fn llama4_prepared(replacements: Vec<PromptReplacement>) -> PreparedMedia {
156
+ prepared_media(
157
+ Modality::Image,
158
+ "<|image|>",
159
+ LLAMA4_IMAGE_ID,
160
+ LLAMA4_PATCH_ID,
161
+ replacements,
162
+ )
163
+ }
164
+
165
+ fn qwen3_image_prepared(replacements: Vec<PromptReplacement>) -> PreparedMedia {
166
+ prepared_media(
167
+ Modality::Image,
168
+ "<|image_pad|>",
169
+ QWEN3_IMAGE_PAD_ID,
170
+ QWEN3_IMAGE_PAD_ID,
171
+ replacements,
172
+ )
173
+ }
174
+
175
+ fn qwen3_video_prepared(replacements: Vec<PromptReplacement>) -> PreparedMedia {
176
+ prepared_media(
177
+ Modality::Video,
178
+ "<|video_pad|>",
179
+ QWEN3_VIDEO_PAD_ID,
180
+ QWEN3_VIDEO_PAD_ID,
181
+ replacements,
182
+ )
183
+ }
184
+
185
+ fn llama4_single_tile_replacement() -> PromptReplacement {
186
+ PromptReplacement::sequence(
187
+ Modality::Image,
188
+ "<|image|>",
189
+ vec![
190
+ LLAMA4_IMAGE_START_ID as TokenId,
191
+ LLAMA4_IMAGE_ID as TokenId,
192
+ LLAMA4_PATCH_ID as TokenId,
193
+ LLAMA4_PATCH_ID as TokenId,
194
+ LLAMA4_IMAGE_END_ID as TokenId,
195
+ ],
196
+ )
197
+ }
198
+
199
+ fn llama4_multi_tile_replacement() -> PromptReplacement {
200
+ PromptReplacement::sequence(
201
+ Modality::Image,
202
+ "<|image|>",
203
+ vec![
204
+ LLAMA4_IMAGE_START_ID as TokenId,
205
+ LLAMA4_PATCH_ID as TokenId,
206
+ LLAMA4_TILE_X_SEPARATOR_ID as TokenId,
207
+ LLAMA4_PATCH_ID as TokenId,
208
+ LLAMA4_TILE_Y_SEPARATOR_ID as TokenId,
209
+ LLAMA4_IMAGE_ID as TokenId,
210
+ LLAMA4_PATCH_ID as TokenId,
211
+ LLAMA4_IMAGE_END_ID as TokenId,
212
+ ],
213
+ )
214
+ }
215
+
216
+ fn assert_bool_mask(range: &PlaceholderRange, expected: &[bool]) {
217
+ let tensor = range.is_embed.as_ref().expect("is_embed mask");
218
+ assert_eq!(tensor.dtype, "bool");
219
+ assert_eq!(tensor.shape, vec![expected.len()]);
220
+ assert_eq!(
221
+ tensor.data,
222
+ WireArrayData::RawView(expected.iter().map(|value| u8::from(*value)).collect())
223
+ );
224
+ }
225
+
226
+ #[test]
227
+ fn expand_prompt_tokens_marks_only_llama4_patch_tokens_as_embed() {
228
+ let mut prompt_token_ids = vec![1, LLAMA4_IMAGE_ID, 2];
229
+ let prepared = vec![llama4_prepared(vec![llama4_multi_tile_replacement()])];
230
+
231
+ let ranges = expand_prompt_token_ids(&mut prompt_token_ids, &prepared).unwrap();
232
+ let ranges = &ranges[&Modality::Image];
233
+
234
+ assert_eq!(
235
+ prompt_token_ids,
236
+ vec![
237
+ 1,
238
+ LLAMA4_IMAGE_START_ID,
239
+ LLAMA4_PATCH_ID,
240
+ LLAMA4_TILE_X_SEPARATOR_ID,
241
+ LLAMA4_PATCH_ID,
242
+ LLAMA4_TILE_Y_SEPARATOR_ID,
243
+ LLAMA4_IMAGE_ID,
244
+ LLAMA4_PATCH_ID,
245
+ LLAMA4_IMAGE_END_ID,
246
+ 2,
247
+ ]
248
+ );
249
+ assert_eq!(ranges[0].offset, 1);
250
+ assert_eq!(ranges[0].length, 8);
251
+ assert_bool_mask(
252
+ &ranges[0],
253
+ &[false, true, false, true, false, false, true, false],
254
+ );
255
+ }
256
+
257
+ #[test]
258
+ fn expand_prompt_tokens_errors_when_placeholder_missing() {
259
+ let mut prompt_token_ids = vec![1, 2, 3];
260
+ let prepared = vec![llama4_prepared(vec![llama4_single_tile_replacement()])];
261
+
262
+ let error = expand_prompt_token_ids(&mut prompt_token_ids, &prepared).unwrap_err();
263
+
264
+ assert!(matches!(error, Error::Multimodal(message) if message.contains("not found")));
265
+ }
266
+
267
+ #[test]
268
+ fn expand_prompt_tokens_ignores_empty_replacements() {
269
+ let mut prompt_token_ids = vec![1, LLAMA4_IMAGE_ID, 2];
270
+ let original_prompt_token_ids = prompt_token_ids.clone();
271
+ let prepared = vec![llama4_prepared(Vec::new())];
272
+
273
+ let ranges = expand_prompt_token_ids(&mut prompt_token_ids, &prepared).unwrap();
274
+
275
+ assert!(ranges.is_empty());
276
+ assert_eq!(prompt_token_ids, original_prompt_token_ids);
277
+ }
278
+
279
+ #[test]
280
+ fn expand_prompt_tokens_leaves_prompt_unchanged_when_later_placeholder_missing() {
281
+ let mut prompt_token_ids = vec![1, LLAMA4_IMAGE_ID, 2];
282
+ let original_prompt_token_ids = prompt_token_ids.clone();
283
+ let prepared = vec![llama4_prepared(vec![
284
+ llama4_single_tile_replacement(),
285
+ llama4_single_tile_replacement(),
286
+ ])];
287
+
288
+ let error = expand_prompt_token_ids(&mut prompt_token_ids, &prepared).unwrap_err();
289
+
290
+ assert!(matches!(error, Error::Multimodal(message) if message.contains("not found")));
291
+ assert_eq!(prompt_token_ids, original_prompt_token_ids);
292
+ }
293
+
294
+ #[test]
295
+ fn expand_prompt_tokens_errors_when_replacement_is_empty() {
296
+ let mut prompt_token_ids = vec![1, LLAMA4_IMAGE_ID, 2];
297
+ let original_prompt_token_ids = prompt_token_ids.clone();
298
+ let prepared = vec![llama4_prepared(vec![PromptReplacement::sequence(
299
+ Modality::Image,
300
+ "<|image|>",
301
+ Vec::new(),
302
+ )])];
303
+
304
+ let error = expand_prompt_token_ids(&mut prompt_token_ids, &prepared).unwrap_err();
305
+
306
+ assert!(
307
+ matches!(error, Error::Multimodal(message) if message.contains("expanded to no tokens"))
308
+ );
309
+ assert_eq!(prompt_token_ids, original_prompt_token_ids);
310
+ }
311
+
312
+ #[test]
313
+ fn expand_prompt_tokens_skips_llama4_image_marker_inside_replacement() {
314
+ let mut prompt_token_ids = vec![1, LLAMA4_IMAGE_ID, 2, LLAMA4_IMAGE_ID, 3];
315
+ let prepared = vec![llama4_prepared(vec![
316
+ llama4_single_tile_replacement(),
317
+ llama4_single_tile_replacement(),
318
+ ])];
319
+
320
+ let ranges = expand_prompt_token_ids(&mut prompt_token_ids, &prepared).unwrap();
321
+ let ranges = &ranges[&Modality::Image];
322
+
323
+ assert_eq!(
324
+ prompt_token_ids,
325
+ vec![
326
+ 1,
327
+ LLAMA4_IMAGE_START_ID,
328
+ LLAMA4_IMAGE_ID,
329
+ LLAMA4_PATCH_ID,
330
+ LLAMA4_PATCH_ID,
331
+ LLAMA4_IMAGE_END_ID,
332
+ 2,
333
+ LLAMA4_IMAGE_START_ID,
334
+ LLAMA4_IMAGE_ID,
335
+ LLAMA4_PATCH_ID,
336
+ LLAMA4_PATCH_ID,
337
+ LLAMA4_IMAGE_END_ID,
338
+ 3,
339
+ ]
340
+ );
341
+ assert_eq!(ranges[0].offset, 1);
342
+ assert_eq!(ranges[0].length, 5);
343
+ assert_bool_mask(&ranges[0], &[false, false, true, true, false]);
344
+ assert_eq!(ranges[1].offset, 7);
345
+ assert_eq!(ranges[1].length, 5);
346
+ assert_bool_mask(&ranges[1], &[false, false, true, true, false]);
347
+ }
348
+
349
+ #[test]
350
+ fn expand_prompt_tokens_interleaves_image_and_video_prepared_media() {
351
+ let mut prompt_token_ids = vec![
352
+ 1,
353
+ QWEN3_IMAGE_PAD_ID,
354
+ 2,
355
+ QWEN3_VIDEO_PAD_ID,
356
+ 3,
357
+ QWEN3_IMAGE_PAD_ID,
358
+ 4,
359
+ ];
360
+ let prepared = vec![
361
+ qwen3_image_prepared(vec![
362
+ PromptReplacement::repeated(
363
+ Modality::Image,
364
+ "<|image_pad|>",
365
+ QWEN3_IMAGE_PAD_ID as TokenId,
366
+ 2,
367
+ ),
368
+ PromptReplacement::repeated(
369
+ Modality::Image,
370
+ "<|image_pad|>",
371
+ QWEN3_IMAGE_PAD_ID as TokenId,
372
+ 3,
373
+ ),
374
+ ]),
375
+ qwen3_video_prepared(vec![PromptReplacement::repeated(
376
+ Modality::Video,
377
+ "<|video_pad|>",
378
+ QWEN3_VIDEO_PAD_ID as TokenId,
379
+ 4,
380
+ )]),
381
+ ];
382
+
383
+ let ranges = expand_prompt_token_ids(&mut prompt_token_ids, &prepared).unwrap();
384
+
385
+ assert_eq!(
386
+ prompt_token_ids,
387
+ vec![
388
+ 1,
389
+ QWEN3_IMAGE_PAD_ID,
390
+ QWEN3_IMAGE_PAD_ID,
391
+ 2,
392
+ QWEN3_VIDEO_PAD_ID,
393
+ QWEN3_VIDEO_PAD_ID,
394
+ QWEN3_VIDEO_PAD_ID,
395
+ QWEN3_VIDEO_PAD_ID,
396
+ 3,
397
+ QWEN3_IMAGE_PAD_ID,
398
+ QWEN3_IMAGE_PAD_ID,
399
+ QWEN3_IMAGE_PAD_ID,
400
+ 4,
401
+ ]
402
+ );
403
+
404
+ let image_ranges = &ranges[&Modality::Image];
405
+ assert_eq!(image_ranges[0].offset, 1);
406
+ assert_eq!(image_ranges[0].length, 2);
407
+ assert_bool_mask(&image_ranges[0], &[true, true]);
408
+ assert_eq!(image_ranges[1].offset, 9);
409
+ assert_eq!(image_ranges[1].length, 3);
410
+ assert_bool_mask(&image_ranges[1], &[true, true, true]);
411
+
412
+ let video_ranges = &ranges[&Modality::Video];
413
+ assert_eq!(video_ranges[0].offset, 4);
414
+ assert_eq!(video_ranges[0].length, 4);
415
+ assert_bool_mask(&video_ranges[0], &[true, true, true, true]);
416
+ }
417
+
418
+ #[test]
419
+ fn expand_prompt_tokens_error_names_modality_with_leftover_replacements() {
420
+ let mut prompt_token_ids = vec![1, QWEN3_IMAGE_PAD_ID, 2];
421
+ let original_prompt_token_ids = prompt_token_ids.clone();
422
+ let prepared = vec![
423
+ qwen3_image_prepared(vec![PromptReplacement::repeated(
424
+ Modality::Image,
425
+ "<|image_pad|>",
426
+ QWEN3_IMAGE_PAD_ID as TokenId,
427
+ 2,
428
+ )]),
429
+ qwen3_video_prepared(vec![PromptReplacement::repeated(
430
+ Modality::Video,
431
+ "<|video_pad|>",
432
+ QWEN3_VIDEO_PAD_ID as TokenId,
433
+ 4,
434
+ )]),
435
+ ];
436
+
437
+ let error = expand_prompt_token_ids(&mut prompt_token_ids, &prepared).unwrap_err();
438
+
439
+ assert!(matches!(
440
+ error,
441
+ Error::Multimodal(message)
442
+ if message.contains("<|video_pad|>") && message.contains("`video`")
443
+ ));
444
+ assert_eq!(prompt_token_ids, original_prompt_token_ids);
445
+ }
446
+}
rust/src/chat/src/multimodal/image.rs
new
+141
@@ -0,0 +1,141 @@
1
+//! Image-modality preparation: batch preprocessing and per-item feature
2
+//! build.
3
+
4
+use std::sync::Arc;
5
+
6
+use itertools::izip;
7
+use llm_multimodal::{FieldLayout, ImageFrame, Modality, PreprocessedEncoderInputs};
8
+use vllm_engine_core_client::protocol::dtype::ModelDtype;
9
+use vllm_engine_core_client::protocol::multimodal::{
10
+ MmBatchedField, MmField, MmFieldElem, MmFlatField, MmKwargsItem, MmSharedField, MmSlice,
11
+ SliceSpec,
12
+};
13
+
14
+use super::{ModalitySupport, MultimodalModelInfo, PreparedItem, PreparedMedia, tensor};
15
+use crate::error::{Error, Result, bail_multimodal, multimodal};
16
+
17
+impl MultimodalModelInfo {
18
+ /// Preprocess all fetched image frames as one batch and build per-item
19
+ /// features.
20
+ pub(super) async fn prepare_images(
21
+ &self,
22
+ frames: Vec<Arc<ImageFrame>>,
23
+ uuids: Vec<Option<String>>,
24
+ model_dtype: ModelDtype,
25
+ ) -> Result<PreparedMedia> {
26
+ let support = self.image.as_ref().ok_or_else(|| Error::UnsupportedModality {
27
+ modality: Modality::Image.to_string(),
28
+ })?;
29
+ let preprocessed = self.preprocess_images(support, &frames).await?;
30
+ let replacements =
31
+ self.spec
32
+ .prompt_replacements_for(&self.context, &preprocessed, Modality::Image)?;
33
+ if replacements.len() != frames.len() {
34
+ bail_multimodal!(
35
+ "number of image prompt replacements {} does not match number of images {}",
36
+ replacements.len(),
37
+ frames.len()
38
+ );
39
+ }
40
+ let items = self.build_image_items(preprocessed, &frames, uuids, model_dtype)?;
41
+
42
+ Ok(PreparedMedia {
43
+ modality: Modality::Image,
44
+ placeholder: support.placeholder.clone(),
45
+ replacements,
46
+ items,
47
+ })
48
+ }
49
+
50
+ /// Preprocess fetched image frames with the model's resolved vision
51
+ /// processor.
52
+ ///
53
+ /// The processor work is CPU-heavy relative to request wiring, so it runs
54
+ /// in a blocking task and returns owned tensors ready for wire
55
+ /// conversion.
56
+ async fn preprocess_images(
57
+ &self,
58
+ support: &ModalitySupport,
59
+ image_frames: &[Arc<ImageFrame>],
60
+ ) -> Result<PreprocessedEncoderInputs> {
61
+ let config = support.config.clone();
62
+ let processor = support.processor;
63
+ let images = image_frames.iter().map(|frame| frame.data().clone()).collect::<Vec<_>>();
64
+
65
+ // TODO: is it still necessary given that we've already in a dedicated runtime?
66
+ tokio::task::spawn_blocking(move || Ok(processor.preprocess(&images, &config)?))
67
+ .await
68
+ .map_err(|error| multimodal!("image preprocessing task failed: {error}"))?
69
+ }
70
+
71
+ /// Convert one batch of preprocessed image tensors into per-item engine
72
+ /// kwargs.
73
+ ///
74
+ /// Tensor fields are sliced per item according to the model spec's field
75
+ /// layout declarations.
76
+ fn build_image_items(
77
+ &self,
78
+ preprocessed: PreprocessedEncoderInputs,
79
+ frames: &[Arc<ImageFrame>],
80
+ uuids: Vec<Option<String>>,
81
+ model_dtype: ModelDtype,
82
+ ) -> Result<Vec<PreparedItem>> {
83
+ let len = frames.len();
84
+ let tensors = tensor::collect_tensors(preprocessed, "pixel_values", model_dtype)?;
85
+
86
+ let mut items = Vec::with_capacity(len);
87
+ for (index, (frame, uuid)) in izip!(frames, uuids).enumerate() {
88
+ let mut data = MmKwargsItem::new();
89
+ for (key, tensor) in &tensors {
90
+ let keep_on_cpu = self.spec.keep_on_cpu_keys.contains(key);
91
+ let (value, field) = match self.spec.field_layouts.get(key) {
92
+ Some(FieldLayout::Batched) => (
93
+ tensor.batched_value_at(index)?,
94
+ MmField::Batched(MmBatchedField { keep_on_cpu }),
95
+ ),
96
+ Some(FieldLayout::Flat { sizes_key }) => {
97
+ let sizes = tensors.get(sizes_key).ok_or_else(|| {
98
+ multimodal!("flat tensor sizes key `{sizes_key}` is missing")
99
+ })?;
100
+ let (start, end) = tensor::flat_range_for_index(sizes, sizes_key, index)?;
101
+ (
102
+ tensor.flat_value_range(start, end)?,
103
+ MmField::Flat(MmFlatField {
104
+ slices: vec![MmSlice::Slice(SliceSpec {
105
+ start: Some(0),
106
+ stop: Some((end - start) as isize),
107
+ step: None,
108
+ })],
109
+ dim: 0,
110
+ keep_on_cpu,
111
+ }),
112
+ )
113
+ }
114
+ None => (
115
+ tensor.clone(),
116
+ MmField::Shared(MmSharedField {
117
+ batch_size: len,
118
+ keep_on_cpu,
119
+ }),
120
+ ),
121
+ };
122
+
123
+ data.insert(
124
+ key.clone(),
125
+ MmFieldElem {
126
+ data: Some(value.try_into()?),
127
+ field,
128
+ },
129
+ );
130
+ }
131
+
132
+ items.push(PreparedItem {
133
+ data,
134
+ hash: frame.hash.clone(),
135
+ uuid,
136
+ });
137
+ }
138
+
139
+ Ok(items)
140
+ }
141
+}
rust/src/chat/src/multimodal/tensor.rs
+32
-14
@@ -1,7 +1,7 @@
1
use std::collections::HashMap;
2
3
use half::{bf16, f16};
4
-use llm_multimodal::{ModelSpecificValue, PreprocessedEncoderInputs as PreprocessedImages};
4
+use llm_multimodal::{ModelSpecificValue, PreprocessedEncoderInputs};
5
use vllm_engine_core_client::protocol::dtype::ModelDtype;
6
use vllm_engine_core_client::protocol::multimodal::MmKwargValue as ProtocolKwargValue;
7
use vllm_engine_core_client::protocol::tensor::{ShapeExt as _, WireTensor};
@@ -25,25 +25,31 @@ pub(super) enum KwargValue {
25
Passthrough(ProtocolKwargValue),
26
}
27
28
-/// Collect `pixel_values` and model-specific outputs into one tensor map.
28
+/// Collect the primary encoder input and model-specific outputs into one
29
+/// tensor map.
30
+///
31
+/// `primary_key` names the encoder-input tensor as the model's forward kwargs
32
+/// expect it (e.g. `pixel_values` for images, `pixel_values_videos` for
33
+/// videos).
34
pub(super) fn collect_tensors(
30
- preprocessed: PreprocessedImages,
35
+ preprocessed: PreprocessedEncoderInputs,
36
+ primary_key: &str,
37
float_dtype: ModelDtype,
38
) -> Result<HashMap<String, KwargValue>> {
33
- let PreprocessedImages {
39
+ let PreprocessedEncoderInputs {
40
encoder_input,
41
model_specific,
42
..
43
} = preprocessed;
44
39
- let pixel_values = {
45
+ let primary_value = {
46
let shape = encoder_input.shape().to_vec();
47
let data = encoder_input.into_iter().collect();
48
KwargValue::from_f32_tensor(data, shape, float_dtype)?
49
};
50
51
let mut tensors = HashMap::new();
46
- tensors.insert("pixel_values".to_string(), pixel_values);
52
+ tensors.insert(primary_key.to_string(), primary_value);
53
for (key, value) in model_specific {
54
tensors.insert(key, KwargValue::from_model_specific(value, float_dtype)?);
55
}
@@ -124,10 +130,22 @@ impl TryFrom<KwargValue> for ProtocolKwargValue {
130
}
131
132
impl KwargValue {
127
- /// Extract one image from a batched tensor field.
133
+ /// First-axis length for tensor values; `None` for passthrough kwargs.
134
+ pub(super) fn first_dim(&self) -> Option<usize> {
135
+ match self {
136
+ Self::F32Tensor { shape, .. }
137
+ | Self::F16Tensor { shape, .. }
138
+ | Self::Bf16Tensor { shape, .. }
139
+ | Self::I64Tensor { shape, .. }
140
+ | Self::U32Tensor { shape, .. } => shape.first().copied(),
141
+ Self::Passthrough(_) => None,
142
+ }
143
+ }
144
+
145
+ /// Extract one media item from a batched tensor field.
146
///
129
- /// Batched fields use their first axis as image index and drop that axis in
130
- /// the per-feature value, matching vLLM's batched-field semantics.
147
+ /// Batched fields use their first axis as media-item index and drop that
148
+ /// axis in the per-feature value, matching vLLM's batched-field semantics.
149
pub(super) fn batched_value_at(&self, index: usize) -> Result<Self> {
150
match self {
151
Self::F32Tensor { data, shape } => {
@@ -154,9 +172,9 @@ impl KwargValue {
172
}
173
}
174
157
- /// Extract one image's variable-length range from a flat tensor field.
175
+ /// Extract one media item's variable-length range from a flat tensor field.
176
///
159
- /// Flat fields keep the first axis as the sliced length for this image.
177
+ /// Flat fields keep the first axis as the sliced length for this item.
178
pub(super) fn flat_value_range(&self, start: usize, end: usize) -> Result<Self> {
179
match self {
180
Self::F32Tensor { data, shape } => {
@@ -184,10 +202,10 @@ impl KwargValue {
202
}
203
}
204
187
-/// Compute the first-axis range for one image in a flat tensor.
205
+/// Compute the first-axis range for one media item in a flat tensor.
206
///
207
/// `sizes_key` names a companion tensor whose entries are cumulative slice
190
-/// sizes per image.
208
+/// sizes per media item.
209
pub(super) fn flat_range_for_index(
210
sizes: &KwargValue,
211
sizes_key: &str,
@@ -195,7 +213,7 @@ pub(super) fn flat_range_for_index(
213
) -> Result<(usize, usize)> {
214
let sizes = tensor_as_usize_vec(sizes)?;
215
let size = *sizes.get(index).ok_or_else(|| {
198
- multimodal!("flat tensor sizes key `{sizes_key}` has no entry for image {index}")
216
+ multimodal!("flat tensor sizes key `{sizes_key}` has no entry for media item {index}")
217
})?;
218
let start = sizes[..index].iter().sum::<usize>();
219
Ok((start, start + size))
rust/src/chat/src/multimodal/video.rs
new
+316
@@ -0,0 +1,316 @@
1
+//! Video-modality preparation: per-clip preprocessing, config resolution,
2
+//! and per-item feature build.
3
+
4
+use std::sync::Arc;
5
+
6
+use itertools::izip;
7
+use llm_multimodal::{FieldLayout, Modality, PreprocessedEncoderInputs, VideoClip};
8
+use thiserror_ext::AsReport as _;
9
+use tracing::warn;
10
+use vllm_engine_core_client::protocol::dtype::ModelDtype;
11
+use vllm_engine_core_client::protocol::multimodal::{
12
+ MmBatchedField, MmField, MmFieldElem, MmFlatField, MmKwargsItem, MmSharedField, MmSlice,
13
+ SliceSpec,
14
+};
15
+
16
+use super::{ModalitySupport, MultimodalModelInfo, PreparedItem, PreparedMedia, tensor};
17
+use crate::error::{Error, Result, bail_multimodal, multimodal};
18
+
19
+/// Forward-kwargs name of the primary video encoder input.
20
+///
21
+/// Video-capable vLLM models read `pixel_values_videos` alongside
22
+/// `video_grid_thw`, mirroring the HF processor output naming.
23
+const VIDEO_PRIMARY_KEY: &str = "pixel_values_videos";
24
+
25
+impl MultimodalModelInfo {
26
+ /// Preprocess fetched video clips one at a time and build per-item
27
+ /// features.
28
+ ///
29
+ /// Unlike images, each clip runs through the preprocessor independently
30
+ /// (a batch of one), so its tensors are complete per item and need no
31
+ /// cross-item slicing.
32
+ pub(super) async fn prepare_videos(
33
+ &self,
34
+ clips: Vec<Arc<VideoClip>>,
35
+ uuids: Vec<Option<String>>,
36
+ model_dtype: ModelDtype,
37
+ ) -> Result<PreparedMedia> {
38
+ let support = self.video.as_ref().ok_or_else(|| Error::UnsupportedModality {
39
+ modality: Modality::Video.to_string(),
40
+ })?;
41
+ let mut replacements = Vec::with_capacity(clips.len());
42
+ let mut items = Vec::with_capacity(clips.len());
43
+
44
+ for (clip, uuid) in izip!(&clips, uuids) {
45
+ let preprocessed = self.preprocess_video_clip(support, Arc::clone(clip)).await?;
46
+ let mut clip_replacements =
47
+ self.spec
48
+ .prompt_replacements_for(&self.context, &preprocessed, Modality::Video)?;
49
+ if clip_replacements.len() != 1 {
50
+ bail_multimodal!(
51
+ "expected exactly one prompt replacement per video clip, got {}",
52
+ clip_replacements.len()
53
+ );
54
+ }
55
+ replacements.push(clip_replacements.pop().unwrap());
56
+ items.push(self.build_video_item(
57
+ preprocessed,
58
+ clip.hash.clone(),
59
+ uuid,
60
+ model_dtype,
61
+ )?);
62
+ }
63
+
64
+ Ok(PreparedMedia {
65
+ modality: Modality::Video,
66
+ placeholder: support.placeholder.clone(),
67
+ replacements,
68
+ items,
69
+ })
70
+ }
71
+
72
+ /// Preprocess one decoded video clip with the model's resolved vision
73
+ /// processor.
74
+ async fn preprocess_video_clip(
75
+ &self,
76
+ support: &ModalitySupport,
77
+ clip: Arc<VideoClip>,
78
+ ) -> Result<PreprocessedEncoderInputs> {
79
+ let config = support.config.clone();
80
+ let processor = support.processor;
81
+
82
+ tokio::task::spawn_blocking(move || {
83
+ // Prefer the borrowed-RGB fast path, which avoids materializing a
84
+ // `DynamicImage` per sampled frame after media decode.
85
+ if let Some(rgb_video) = clip.rgb_video() {
86
+ match rgb_video.frame_refs() {
87
+ Ok(frame_refs) => match processor.preprocess_video_rgb(&frame_refs, &config) {
88
+ Ok(preprocessed) => return Ok(preprocessed),
89
+ Err(error) => warn!(
90
+ error = %error.as_report(),
91
+ "RGB video preprocessing fast path failed; falling back to materialized frames"
92
+ ),
93
+ },
94
+ Err(error) => warn!(
95
+ error,
96
+ "RGB video frame refs are invalid; falling back to materialized frames"
97
+ ),
98
+ }
99
+ }
100
+
101
+ let frames = clip.materialized_frames().map_err(|error| multimodal!("{error}"))?;
102
+ Ok(processor.preprocess_video(&frames, &config)?)
103
+ })
104
+ .await
105
+ .map_err(|error| multimodal!("video preprocessing task failed: {error}"))?
106
+ }
107
+
108
+ /// Convert one preprocessed video clip into engine kwargs.
109
+ ///
110
+ /// The clip is a batch of one, so no per-item slicing is required: the
111
+ /// primary tensor ships as a full-range flat field (the engine re-batches
112
+ /// flat fields by concatenating along the declared dim, matching vLLM's
113
+ /// `flat_from_sizes` treatment of video patches), and batched metadata
114
+ /// tensors drop their singleton batch axis.
115
+ fn build_video_item(
116
+ &self,
117
+ preprocessed: PreprocessedEncoderInputs,
118
+ hash: String,
119
+ uuid: Option<String>,
120
+ model_dtype: ModelDtype,
121
+ ) -> Result<PreparedItem> {
122
+ let tensors = tensor::collect_tensors(preprocessed, VIDEO_PRIMARY_KEY, model_dtype)?;
123
+
124
+ let mut data = MmKwargsItem::new();
125
+ for (key, tensor) in tensors {
126
+ let keep_on_cpu = self.spec.keep_on_cpu_keys.contains(&key);
127
+ let (value, field) = if key == VIDEO_PRIMARY_KEY {
128
+ let len = tensor
129
+ .first_dim()
130
+ .ok_or_else(|| multimodal!("video encoder input `{key}` is not a tensor"))?;
131
+ (
132
+ tensor,
133
+ MmField::Flat(MmFlatField {
134
+ slices: vec![MmSlice::Slice(SliceSpec {
135
+ start: Some(0),
136
+ stop: Some(len as isize),
137
+ step: None,
138
+ })],
139
+ dim: 0,
140
+ keep_on_cpu,
141
+ }),
142
+ )
143
+ } else if matches!(
144
+ self.spec.field_layouts.get(&key),
145
+ Some(FieldLayout::Batched)
146
+ ) {
147
+ (
148
+ tensor.batched_value_at(0)?,
149
+ MmField::Batched(MmBatchedField { keep_on_cpu }),
150
+ )
151
+ } else {
152
+ (
153
+ tensor,
154
+ MmField::Shared(MmSharedField {
155
+ batch_size: 1,
156
+ keep_on_cpu,
157
+ }),
158
+ )
159
+ };
160
+
161
+ data.insert(
162
+ key,
163
+ MmFieldElem {
164
+ data: Some(value.try_into()?),
165
+ field,
166
+ },
167
+ );
168
+ }
169
+
170
+ Ok(PreparedItem { data, hash, uuid })
171
+ }
172
+}
173
+
174
+#[cfg(test)]
175
+mod tests {
176
+ use std::collections::HashMap;
177
+ use std::sync::Arc;
178
+
179
+ use llm_multimodal::ModelSpecificValue;
180
+ use ndarray::ArrayD;
181
+ use vllm_engine_core_client::protocol::multimodal::MmKwargValue;
182
+
183
+ use super::super::tests::{
184
+ QWEN3_IMAGE_PAD_ID, QWEN3_VIDEO_PAD_ID, qwen3_vl_info, qwen3_vl_tokenizer,
185
+ };
186
+ use super::super::{MultimodalConfigFiles, MultimodalModelInfo};
187
+ use super::*;
188
+
189
+ #[test]
190
+ fn from_paths_resolves_video_config_from_dedicated_file_or_processor_config() {
191
+ let dir = tempfile::tempdir().unwrap();
192
+ let config_path = dir.path().join("config.json");
193
+ std::fs::write(
194
+ &config_path,
195
+ serde_json::json!({
196
+ "model_type": "qwen3_vl",
197
+ "image_token_id": QWEN3_IMAGE_PAD_ID,
198
+ "video_token_id": QWEN3_VIDEO_PAD_ID,
199
+ })
200
+ .to_string(),
201
+ )
202
+ .unwrap();
203
+
204
+ let info_for = |files: MultimodalConfigFiles<'_>| {
205
+ MultimodalModelInfo::from_paths(
206
+ "qwen3-vl-test".to_string(),
207
+ Some("qwen3_vl".to_string()),
208
+ files,
209
+ Arc::new(qwen3_vl_tokenizer()),
210
+ )
211
+ };
212
+
213
+ // Dedicated video preprocessor config file.
214
+ let video_config_path = dir.path().join("video_preprocessor_config.json");
215
+ std::fs::write(&video_config_path, r#"{"size":{"shortest_edge":128}}"#).unwrap();
216
+ let info = info_for(MultimodalConfigFiles {
217
+ config: Some(&config_path),
218
+ video_preprocessor_config: Some(&video_config_path),
219
+ ..Default::default()
220
+ })
221
+ .unwrap()
222
+ .unwrap();
223
+ assert!(info.video.is_some());
224
+
225
+ // `video_processor` section of the combined processor config.
226
+ let processor_config_path = dir.path().join("processor_config.json");
227
+ std::fs::write(
228
+ &processor_config_path,
229
+ r#"{"video_processor":{"size":{"shortest_edge":128}}}"#,
230
+ )
231
+ .unwrap();
232
+ let info = info_for(MultimodalConfigFiles {
233
+ config: Some(&config_path),
234
+ processor_config: Some(&processor_config_path),
235
+ ..Default::default()
236
+ })
237
+ .unwrap()
238
+ .unwrap();
239
+ assert!(info.video.is_some());
240
+
241
+ // Neither source: video support still resolves on the image config.
242
+ let info = info_for(MultimodalConfigFiles {
243
+ config: Some(&config_path),
244
+ ..Default::default()
245
+ })
246
+ .unwrap()
247
+ .unwrap();
248
+ assert!(info.video.is_some());
249
+
250
+ // Malformed dedicated file is a real error, not a silent fallback.
251
+ std::fs::write(&video_config_path, r#"{"size""#).unwrap();
252
+ let error = match info_for(MultimodalConfigFiles {
253
+ config: Some(&config_path),
254
+ video_preprocessor_config: Some(&video_config_path),
255
+ ..Default::default()
256
+ }) {
257
+ Err(error) => error,
258
+ Ok(_) => panic!("malformed video preprocessor config should fail"),
259
+ };
260
+ assert!(matches!(
261
+ error,
262
+ Error::Multimodal(message)
263
+ if message.contains("failed to parse video_preprocessor_config.json")
264
+ ));
265
+ }
266
+
267
+ #[test]
268
+ fn build_video_item_names_primary_tensor_and_layouts() {
269
+ let info = qwen3_vl_info();
270
+ // One clip flattened to 6 patches with 4 features each.
271
+ let preprocessed = PreprocessedEncoderInputs {
272
+ encoder_input: ArrayD::zeros(vec![6, 4]),
273
+ feature_token_counts: vec![6],
274
+ item_sizes: vec![(32, 32)],
275
+ model_specific: HashMap::from([
276
+ (
277
+ "video_grid_thw".to_string(),
278
+ ModelSpecificValue::int_2d(vec![1, 2, 3], 1, 3),
279
+ ),
280
+ (
281
+ "patches_per_video".to_string(),
282
+ ModelSpecificValue::int_1d(vec![6]),
283
+ ),
284
+ ]),
285
+ };
286
+
287
+ let item = info
288
+ .build_video_item(
289
+ preprocessed,
290
+ "<hash>".to_string(),
291
+ None,
292
+ ModelDtype::Float32,
293
+ )
294
+ .unwrap();
295
+
296
+ let primary = &item.data[VIDEO_PRIMARY_KEY];
297
+ assert!(matches!(
298
+ &primary.field,
299
+ MmField::Flat(MmFlatField { slices, dim: 0, .. })
300
+ if matches!(
301
+ slices.as_slice(),
302
+ [MmSlice::Slice(SliceSpec { start: Some(0), stop: Some(6), step: None })]
303
+ )
304
+ ));
305
+
306
+ // Batched metadata drops its singleton batch axis per item.
307
+ let grid = &item.data["video_grid_thw"];
308
+ assert!(matches!(&grid.field, MmField::Batched(_)));
309
+ let MmKwargValue::Tensor(grid_tensor) = grid.data.as_ref().unwrap() else {
310
+ panic!("expected tensor value for video_grid_thw");
311
+ };
312
+ assert_eq!(grid_tensor.shape, vec![3]);
313
+
314
+ assert_eq!(item.hash, "<hash>");
315
+ }
316
+}
rust/src/chat/src/renderer/hf/mod.rs
+83
-8
@@ -31,9 +31,14 @@ pub use template::{load_chat_template, resolve_chat_template};
31
32
pub use self::format::ChatTemplateContentFormatOption;
33
34
-#[derive(Debug, Clone)]
34
+/// Template-visible placeholder tokens per supported modality.
35
+///
36
+/// A `None` token means the loaded model does not support that modality, and
37
+/// content parts of that modality are rejected during rendering.
38
+#[derive(Debug, Clone, Default)]
39
pub struct MultimodalRenderInfo {
36
- pub placeholder_token: String,
40
+ pub image_token: Option<String>,
41
+ pub video_token: Option<String>,
42
}
43
44
/// Hugging Face chat-template renderer backed by the local Jinja chat-template
@@ -254,6 +259,7 @@ enum TemplateContent {
259
enum TemplateContentPart {
260
Text { text: String },
261
Image,
262
+ Video,
263
}
264
265
#[derive(Debug, Serialize)]
@@ -417,9 +423,17 @@ fn to_template_openai_content(
423
}
424
// All multimodal contents are normalized to `{ "type": <modality> }`.
425
ChatContentPart::ImageUrl { .. } => {
420
- multimodal.ok_or(Error::UnsupportedMultimodalContent("image_url"))?;
426
+ multimodal
427
+ .and_then(|multimodal| multimodal.image_token.as_ref())
428
+ .ok_or(Error::UnsupportedMultimodalContent("image_url"))?;
429
Ok(TemplateContentPart::Image)
430
}
431
+ ChatContentPart::VideoUrl { .. } => {
432
+ multimodal
433
+ .and_then(|multimodal| multimodal.video_token.as_ref())
434
+ .ok_or(Error::UnsupportedMultimodalContent("video_url"))?;
435
+ Ok(TemplateContentPart::Video)
436
+ }
437
})
438
.collect(),
439
}
@@ -437,9 +451,16 @@ fn to_template_string_content(
451
match part {
452
ChatContentPart::Text { text } => out.push_str(text),
453
ChatContentPart::ImageUrl { .. } => {
440
- let multimodal =
441
- multimodal.ok_or(Error::UnsupportedMultimodalContent("image_url"))?;
442
- out.push_str(&multimodal.placeholder_token);
454
+ let image_token = multimodal
455
+ .and_then(|multimodal| multimodal.image_token.as_ref())
456
+ .ok_or(Error::UnsupportedMultimodalContent("image_url"))?;
457
+ out.push_str(image_token);
458
+ }
459
+ ChatContentPart::VideoUrl { .. } => {
460
+ let video_token = multimodal
461
+ .and_then(|multimodal| multimodal.video_token.as_ref())
462
+ .ok_or(Error::UnsupportedMultimodalContent("video_url"))?;
463
+ out.push_str(video_token);
464
}
465
}
466
}
@@ -468,7 +489,7 @@ fn append_continue_final_message_tag(message: &mut TemplateMessage) -> Result<St
489
// Pick the last text part in the message.
490
TemplateContent::OpenAi(parts) => parts.iter_mut().rev().find_map(|part| match part {
491
TemplateContentPart::Text { text } => Some(text),
471
- TemplateContentPart::Image => None,
492
+ TemplateContentPart::Image | TemplateContentPart::Video => None,
493
}),
494
};
495
let text = text.ok_or_else(|| {
@@ -577,7 +598,8 @@ mod tests {
598
) -> Result<crate::RenderedPrompt> {
599
HfChatRenderer::new(Some(template.to_string()), HashMap::new(), content_format)?
600
.with_multimodal(Some(MultimodalRenderInfo {
580
- placeholder_token: "<image>".to_string(),
601
+ image_token: Some("<image>".to_string()),
602
+ video_token: Some("<video>".to_string()),
603
}))
604
.render(request)
605
}
@@ -590,6 +612,14 @@ mod tests {
612
])])
613
}
614
615
+ fn video_request() -> ChatRequest {
616
+ sample_request(vec![ChatMessage::user(vec![
617
+ ChatContentPart::text("a"),
618
+ ChatContentPart::video_url("https://example.com/demo.mp4"),
619
+ ChatContentPart::text("b"),
620
+ ])])
621
+ }
622
+
623
#[test]
624
fn string_content_format_replaces_image_with_placeholder_text() {
625
let rendered = render_mm(
@@ -602,6 +632,18 @@ mod tests {
632
assert_eq!(rendered.prompt, Prompt::Text("a<image>b".to_string()));
633
}
634
635
+ #[test]
636
+ fn string_content_format_replaces_video_with_placeholder_text() {
637
+ let rendered = render_mm(
638
+ "{{ messages[0].content }}",
639
+ &video_request(),
640
+ ChatTemplateContentFormatOption::String,
641
+ )
642
+ .unwrap();
643
+
644
+ assert_eq!(rendered.prompt, Prompt::Text("a<video>b".to_string()));
645
+ }
646
+
647
#[test]
648
fn openai_content_format_normalizes_image_url_for_template() {
649
let rendered = render_mm(
@@ -614,6 +656,39 @@ mod tests {
656
assert_eq!(rendered.prompt, Prompt::Text("a<|image_pad|>b".to_string()));
657
}
658
659
+ #[test]
660
+ fn openai_content_format_normalizes_video_url_for_template() {
661
+ let rendered = render_mm(
662
+ "{% for item in messages[0].content %}{% if item.type == 'video' %}<|video_pad|>{% else %}{{ item.text }}{% endif %}{% endfor %}",
663
+ &video_request(),
664
+ ChatTemplateContentFormatOption::OpenAi,
665
+ )
666
+ .unwrap();
667
+
668
+ assert_eq!(rendered.prompt, Prompt::Text("a<|video_pad|>b".to_string()));
669
+ }
670
+
671
+ #[test]
672
+ fn video_parts_are_rejected_when_model_lacks_video_support() {
673
+ let error = HfChatRenderer::new(
674
+ Some("{{ messages[0].content }}".to_string()),
675
+ HashMap::new(),
676
+ ChatTemplateContentFormatOption::String,
677
+ )
678
+ .unwrap()
679
+ .with_multimodal(Some(MultimodalRenderInfo {
680
+ image_token: Some("<image>".to_string()),
681
+ video_token: None,
682
+ }))
683
+ .render(&video_request())
684
+ .unwrap_err();
685
+
686
+ assert!(matches!(
687
+ error,
688
+ Error::UnsupportedMultimodalContent("video_url")
689
+ ));
690
+ }
691
+
692
#[test]
693
fn chat_template_supports_pycompat_templates() {
694
let request = sample_request(vec![ChatMessage::text(ChatRole::User, "<think>hello")]);
rust/src/chat/src/request.rs
+36
-1
@@ -36,7 +36,13 @@ pub enum ChatContentPart {
36
detail: Option<ImageDetail>,
37
uuid: Option<String>,
38
},
39
+ /// One video URL/data URL content block.
40
+ VideoUrl {
41
+ video_url: String,
42
+ uuid: Option<String>,
43
+ },
44
// ImageData...
45
+ // VideoData...
46
// ImageEmbeds...
47
}
48
@@ -55,12 +61,21 @@ impl ChatContentPart {
61
}
62
}
63
64
+ /// Construct one video URL content part with the given URL string.
65
+ pub fn video_url(video_url: impl Into<String>) -> Self {
66
+ Self::VideoUrl {
67
+ video_url: video_url.into(),
68
+ uuid: None,
69
+ }
70
+ }
71
+
72
/// Return the text content of this part when it's a text block, or an
73
/// "unsupported multimodal content" error otherwise.
74
pub(crate) fn as_text(&self) -> Result<&str> {
75
match self {
76
Self::Text { text } => Ok(text),
77
Self::ImageUrl { .. } => Err(Error::UnsupportedMultimodalContent("image_url")),
78
+ Self::VideoUrl { .. } => Err(Error::UnsupportedMultimodalContent("video_url")),
79
}
80
}
81
@@ -73,7 +88,7 @@ impl ChatContentPart {
88
pub(crate) fn is_multimodal(&self) -> bool {
89
match self {
90
Self::Text { .. } => false,
76
- Self::ImageUrl { .. } => true,
91
+ Self::ImageUrl { .. } | Self::VideoUrl { .. } => true,
92
}
93
}
94
}
@@ -555,6 +570,26 @@ mod tests {
570
assert_eq!(content, ChatContent::Text("hello".to_string()));
571
}
572
573
+ #[test]
574
+ fn chat_content_video_url_part_round_trips_through_serde() {
575
+ let content = ChatContent::Parts(vec![ChatContentPart::VideoUrl {
576
+ video_url: "https://example.com/demo.mp4".to_string(),
577
+ uuid: Some("video-1".to_string()),
578
+ }]);
579
+
580
+ let value = to_value(&content).unwrap();
581
+ assert_eq!(
582
+ value,
583
+ json!([{
584
+ "type": "video_url",
585
+ "video_url": "https://example.com/demo.mp4",
586
+ "uuid": "video-1",
587
+ }])
588
+ );
589
+ let decoded: ChatContent = serde_json::from_value(value).unwrap();
590
+ assert_eq!(decoded, content);
591
+ }
592
+
593
#[test]
594
fn chat_content_deserializes_from_openai_text_blocks() {
595
let content: ChatContent =
rust/src/server/src/routes/openai/chat_completions/convert.rs
+15
-6
@@ -290,7 +290,10 @@ fn convert_content(content: MessageContent) -> Result<ChatContent, ApiError> {
290
detail: image_url.detail,
291
uuid,
292
}),
293
- _ => bail_invalid_request!("Only text and image_url content parts are supported."),
293
+ ContentPart::VideoUrl { video_url, uuid } => Ok(ChatContentPart::VideoUrl {
294
+ video_url: video_url.url,
295
+ uuid,
296
+ }),
297
})
298
.try_collect()
299
.map(ChatContent::Parts),
@@ -772,28 +775,34 @@ mod tests {
775
}
776
777
#[test]
775
- fn prepare_chat_request_rejects_video_content_parts() {
778
+ fn prepare_chat_request_accepts_video_content_parts() {
779
let request = ChatCompletionRequest {
780
messages: vec![ChatMessage::User {
781
content: MessageContent::Parts(vec![ContentPart::VideoUrl {
782
video_url: VideoUrl {
783
url: "https://example.com/video.mp4".to_string(),
784
},
785
+ uuid: Some("video-uuid".to_string()),
786
}]),
787
name: None,
788
}],
789
..base_request()
790
};
791
788
- let error = prepare_chat_request(
792
+ let prepared = prepare_chat_request(
793
request,
794
&served(&["Qwen/Qwen1.5-0.5B-Chat"]),
795
ResolvedRequestContext::default(),
796
)
793
- .unwrap_err();
797
+ .expect("request is valid");
798
795
- expect!["Only text and image_url content parts are supported."]
796
- .assert_eq(&error.to_error_response().error.message);
799
+ assert_eq!(
800
+ prepared.chat_request.messages,
801
+ vec![VllmChatMessage::user(vec![ChatContentPart::VideoUrl {
802
+ video_url: "https://example.com/video.mp4".to_string(),
803
+ uuid: Some("video-uuid".to_string()),
804
+ }])]
805
+ );
806
}
807
808
#[test]
rust/src/server/src/routes/openai/utils/types.rs
+5
-1
@@ -91,7 +91,11 @@ pub enum ContentPart {
91
uuid: Option<String>,
92
},
93
#[serde(rename = "video_url")]
94
- VideoUrl { video_url: VideoUrl },
94
+ VideoUrl {
95
+ video_url: VideoUrl,
96
+ #[serde(skip_serializing_if = "Option::is_none")]
97
+ uuid: Option<String>,
98
+ },
99
}
100
101
#[serde_with::skip_serializing_none]
rust/src/server/src/routes/tests.rs
+8
-4
@@ -504,7 +504,7 @@ impl ChatRenderer for FakeChatBackend {
504
let placeholder = self
505
.multimodal_model_info
506
.as_ref()
507
- .map(|info| info.placeholder_token())
507
+ .and_then(|info| info.placeholder_token(llm_multimodal::Modality::Image))
508
.unwrap_or("<image>");
509
let mut prompt = String::new();
510
for message in &request.messages {
@@ -544,7 +544,9 @@ fn render_fake_content(content: &ChatContent, placeholder: &str) -> vllm_chat::R
544
for part in parts {
545
match part {
546
ChatContentPart::Text { text } => out.push_str(text),
547
- ChatContentPart::ImageUrl { .. } => out.push_str(placeholder),
547
+ ChatContentPart::ImageUrl { .. } | ChatContentPart::VideoUrl { .. } => {
548
+ out.push_str(placeholder)
549
+ }
550
}
551
}
552
out
@@ -565,8 +567,10 @@ fn qwen_multimodal_model_info() -> vllm_chat::multimodal::MultimodalModelInfo {
567
let info = vllm_chat::multimodal::MultimodalModelInfo::from_paths(
568
"qwen2-vl-test".to_string(),
569
Some("qwen2_vl".to_string()),
568
- Some(&config_path),
569
- None,
570
+ vllm_chat::multimodal::MultimodalConfigFiles {
571
+ config: Some(&config_path),
572
+ ..Default::default()
573
+ },
574
Arc::new(fake_chat_tokenizer()),
575
)
576
.expect("load multimodal info")
rust/src/text/src/backend/hf/model_files.rs
+19
@@ -41,6 +41,10 @@ pub struct ResolvedModelFiles {
41
pub tokenizer_config_path: Option<PathBuf>,
42
pub generation_config_path: Option<PathBuf>,
43
pub preprocessor_config_path: Option<PathBuf>,
44
+ /// Video-specific preprocessor config, when provided by the model repo.
45
+ pub video_preprocessor_config_path: Option<PathBuf>,
46
+ /// Combined processor config, which may embed a `video_processor` section.
47
+ pub processor_config_path: Option<PathBuf>,
48
pub chat_template_path: Option<PathBuf>,
49
pub config_path: Option<PathBuf>,
50
}
@@ -70,6 +74,11 @@ fn resolve_local_model_files(model_dir: &Path) -> Result<ResolvedModelFiles> {
74
tokenizer_config_path,
75
generation_config_path: local_file_if_exists(model_dir, "generation_config.json"),
76
preprocessor_config_path: local_file_if_exists(model_dir, "preprocessor_config.json"),
77
+ video_preprocessor_config_path: local_file_if_exists(
78
+ model_dir,
79
+ "video_preprocessor_config.json",
80
+ ),
81
+ processor_config_path: local_file_if_exists(model_dir, "processor_config.json"),
82
chat_template_path: discover_chat_template_in_dir(model_dir),
83
config_path: local_file_if_exists(model_dir, "config.json"),
84
})
@@ -107,6 +116,10 @@ async fn resolve_remote_model_files(model_id: &str) -> Result<ResolvedModelFiles
116
download_if_present(&repo, model_id, &siblings, "generation_config.json").await?;
117
let preprocessor_config_path =
118
download_if_present(&repo, model_id, &siblings, "preprocessor_config.json").await?;
119
+ let video_preprocessor_config_path =
120
+ download_if_present(&repo, model_id, &siblings, "video_preprocessor_config.json").await?;
121
+ let processor_config_path =
122
+ download_if_present(&repo, model_id, &siblings, "processor_config.json").await?;
123
let chat_template_name = siblings
124
.contains("chat_template.json")
125
.then_some("chat_template.json")
@@ -123,6 +136,8 @@ async fn resolve_remote_model_files(model_id: &str) -> Result<ResolvedModelFiles
136
tokenizer_config_path,
137
generation_config_path,
138
preprocessor_config_path,
139
+ video_preprocessor_config_path,
140
+ processor_config_path,
141
chat_template_path,
142
config_path,
143
})
@@ -143,6 +158,8 @@ fn resolve_cached_model_files(model_id: &str) -> Result<Option<ResolvedModelFile
158
})?;
159
let generation_config_path = cache_repo.get("generation_config.json");
160
let preprocessor_config_path = cache_repo.get("preprocessor_config.json");
161
+ let video_preprocessor_config_path = cache_repo.get("video_preprocessor_config.json");
162
+ let processor_config_path = cache_repo.get("processor_config.json");
163
let chat_template_path = discover_chat_template_in_dir(model_dir);
164
let config_path = cache_repo.get("config.json");
165
@@ -151,6 +168,8 @@ fn resolve_cached_model_files(model_id: &str) -> Result<Option<ResolvedModelFile
168
tokenizer_config_path,
169
generation_config_path,
170
preprocessor_config_path,
171
+ video_preprocessor_config_path,
172
+ processor_config_path,
173
chat_template_path,
174
config_path,
175
}))