[Rust Frontend] Pass vision preprocessing context for Nemotron-H (#57634)
Pass the remaining engine context-length budget to model-owned vision processors through VisionPreprocessingContext. Preserve Nemotron batched engine fields and recognize llm_config as a text_config alias. Use the merged upstream llm-multimodal revision f0985ef65967615db2c79279aa07818499301bfd. Co-authored-by: Codex <noreply@openai.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Biswa Panda committed
Sep 23, 2026 at 23:08 UTC
cf78797cb79e73bb798746c6cce7c34f56860a0a
8 files changed
+240
-21
rust/Cargo.lock
+1
-1
@@ -2290,7 +2290,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
2290
[[package]]
2291
name = "llm-multimodal"
2292
version = "1.7.1"
2293
-source = "git+https://github.com/smg-project/llm-multimodal?rev=53ca0d6ea829af7477b6b63851cc70bed409bbc8#53ca0d6ea829af7477b6b63851cc70bed409bbc8"
2293
+source = "git+https://github.com/smg-project/llm-multimodal?rev=f0985ef65967615db2c79279aa07818499301bfd#f0985ef65967615db2c79279aa07818499301bfd"
2294
dependencies = [
2295
"anyhow",
2296
"base64 0.22.1",
rust/Cargo.toml
+1
-1
@@ -61,7 +61,7 @@ indexmap = "2.13.0"
61
indicatif = "0.18.4"
62
itertools = "0.14.0"
63
libc = "0.2.177"
64
-llm-multimodal = { git = "https://github.com/smg-project/llm-multimodal", rev = "53ca0d6ea829af7477b6b63851cc70bed409bbc8", default-features = false, features = ["native-tls"] }
64
+llm-multimodal = { git = "https://github.com/smg-project/llm-multimodal", rev = "f0985ef65967615db2c79279aa07818499301bfd", default-features = false, features = ["native-tls"] }
65
mimalloc = "0.1.52"
66
minijinja = { version = "2.24", features = ["unstable_machinery", "json", "builtins", "loader", "loop_controls", "preserve_order"] }
67
minijinja-contrib = { version = "2.24", features = ["pycompat"] }
rust/src/chat/src/lib.rs
+11
-3
@@ -72,6 +72,9 @@ pub struct ChatRequestProcessor {
72
/// Effective model dtype reported by the engine.
73
/// Absent for text-only frontends without an engine handshake.
74
model_dtype: Option<ModelDtype>,
75
+ /// Effective runtime context length reported by the engine.
76
+ /// Absent for render-only frontends without an engine handshake.
77
+ max_model_len: Option<usize>,
78
/// Tool-call parser selection used when preparing generation requests.
79
tool_call_parser: ParserSelection,
80
/// Reasoning parser selection used when preparing generation requests.
@@ -83,10 +86,11 @@ pub struct ChatRequestProcessor {
86
impl ChatRequestProcessor {
87
/// Create a processor with multimodal support using the effective model
88
/// dtype reported by the engine.
86
- fn new(backend: DynChatBackend, model_dtype: ModelDtype) -> Self {
89
+ fn new(backend: DynChatBackend, model_dtype: ModelDtype, max_model_len: u32) -> Self {
90
Self {
91
backend,
92
model_dtype: Some(model_dtype),
93
+ max_model_len: Some(max_model_len as usize),
94
tool_call_parser: ParserSelection::Auto,
95
reasoning_parser: ParserSelection::Auto,
96
tool_strict_level: ToolStrictLevel::Auto,
@@ -98,6 +102,7 @@ impl ChatRequestProcessor {
102
Self {
103
backend,
104
model_dtype: None,
105
+ max_model_len: None,
106
tool_call_parser: ParserSelection::Auto,
107
reasoning_parser: ParserSelection::Auto,
108
tool_strict_level: ToolStrictLevel::Auto,
@@ -137,6 +142,7 @@ impl ChatRequestProcessor {
142
rendered,
143
self.backend.multimodal_model_info(),
144
model_dtype,
145
+ self.max_model_len,
146
)
147
.await
148
}
@@ -161,7 +167,8 @@ impl ChatRequestProcessor {
167
let features = match media {
168
multimodal::MultimodalInput::Raw(parts) => {
169
let model_dtype = self.model_dtype.ok_or(Error::UnsupportedMultimodalRenderer)?;
164
- info.prepare_multimodal(parts, token_ids, model_dtype).await?
170
+ info.prepare_multimodal(parts, token_ids, model_dtype, self.max_model_len)
171
+ .await?
172
}
173
multimodal::MultimodalInput::Preprocessed(features) => {
174
info.prepare_preprocessed(features, token_ids.len())?
@@ -256,10 +263,11 @@ impl ChatLlm {
263
/// backend.
264
pub fn new(text: TextLlm, backend: DynChatBackend) -> Self {
265
let model_dtype = text.engine_core_client().model_dtype();
266
+ let max_model_len = text.engine_core_client().max_model_len();
267
268
Self {
269
text,
262
- processor: ChatRequestProcessor::new(backend, model_dtype),
270
+ processor: ChatRequestProcessor::new(backend, model_dtype, max_model_len),
271
}
272
}
273
rust/src/chat/src/multimodal.rs
+142
-4
@@ -23,6 +23,7 @@ use llm_multimodal::{
23
ImageFrame, MediaConnector, MediaConnectorConfig, MediaContentPart, Modality, ModelMetadata,
24
ModelProcessorSpec, ModelRegistry, PreProcessorConfig, PreprocessedEncoderInputs,
25
PromptReplacement, Tokenizer as TokenResolver, TrackedMedia, VideoClip, VisionPreProcessor,
26
+ VisionPreprocessingContext,
27
};
28
use serde::{Deserialize, Serialize};
29
use thiserror_ext::AsReport as _;
@@ -55,6 +56,8 @@ pub use self::timing::{mm_request_span, mm_timing_layer};
56
#[derive(Clone)]
57
pub struct MultimodalModelInfo {
58
context: MultimodalModelContext,
59
+ /// Rendered placeholder marker IDs for all resolved modalities.
60
+ placeholder_token_ids: Vec<u32>,
61
image: Option<VisionModalitySupport>,
62
video: Option<VisionModalitySupport>,
63
audio: Option<AudioModalitySupport>,
@@ -420,8 +423,18 @@ impl MultimodalModelInfo {
423
MediaConnectorConfig::default(),
424
)?);
425
426
+ let placeholder_token_ids = [
427
+ image.as_ref().map(|support| support.placeholder.marker_token_id),
428
+ video.as_ref().map(|support| support.placeholder.marker_token_id),
429
+ audio.as_ref().map(|support| support.placeholder.marker_token_id),
430
+ ]
431
+ .into_iter()
432
+ .flatten()
433
+ .collect();
434
+
435
Ok(Some(Self {
436
context,
437
+ placeholder_token_ids,
438
image,
439
video,
440
audio,
@@ -547,6 +560,20 @@ impl MultimodalModelInfo {
560
Modality::ImageEmbeds => None,
561
}
562
}
563
+
564
+ fn vision_preprocessing_context(
565
+ &self,
566
+ prompt_token_ids: &[u32],
567
+ max_model_len: Option<usize>,
568
+ ) -> VisionPreprocessingContext {
569
+ let text_prompt_length = prompt_token_ids
570
+ .iter()
571
+ .filter(|token_id| !self.placeholder_token_ids.contains(token_id))
572
+ .count();
573
+ VisionPreprocessingContext {
574
+ token_budget: max_model_len.map(|limit| limit.saturating_sub(text_prompt_length)),
575
+ }
576
+ }
577
}
578
579
/// Finalize a rendered chat prompt into text-generation input.
@@ -559,6 +586,7 @@ pub(crate) async fn finalize_rendered_prompt(
586
rendered: RenderedPrompt,
587
info: Option<&MultimodalModelInfo>,
588
model_dtype: ModelDtype,
589
+ max_model_len: Option<usize>,
590
) -> Result<(Prompt, Option<MmFeatures>)> {
591
let media_parts = extract_media_parts(request, rendered.media_order.as_deref())?;
592
if media_parts.is_empty() {
@@ -574,7 +602,12 @@ pub(crate) async fn finalize_rendered_prompt(
602
Prompt::TokenIds(token_ids) => token_ids,
603
};
604
let prepared = info
577
- .prepare_multimodal(media_parts, &mut prompt_token_ids, model_dtype)
605
+ .prepare_multimodal(
606
+ media_parts,
607
+ &mut prompt_token_ids,
608
+ model_dtype,
609
+ max_model_len,
610
+ )
611
.instrument(mm_request_span(&request.request_id))
612
.await?;
613
@@ -768,11 +801,13 @@ impl MultimodalModelInfo {
801
media_parts: Vec<MediaContentPart>,
802
prompt_token_ids: &mut Vec<u32>,
803
model_dtype: ModelDtype,
804
+ max_model_len: Option<usize>,
805
) -> Result<MmFeatures> {
806
if media_parts.is_empty() {
807
return Ok(Vec::new());
808
}
775
- self.prepare_multimodal_timed(media_parts, prompt_token_ids, model_dtype).await
809
+ self.prepare_multimodal_timed(media_parts, prompt_token_ids, model_dtype, max_model_len)
810
+ .await
811
}
812
813
/// Timed body of [`Self::prepare_multimodal`]; each stage is a `tracing`
@@ -788,15 +823,24 @@ impl MultimodalModelInfo {
823
media_parts: Vec<MediaContentPart>,
824
prompt_token_ids: &mut Vec<u32>,
825
model_dtype: ModelDtype,
826
+ max_model_len: Option<usize>,
827
) -> Result<MmFeatures> {
828
let media_parts_len = media_parts.len();
829
self.validate_mm_limits(&media_parts)?;
830
+ let vision_context = self.vision_preprocessing_context(prompt_token_ids, max_model_len);
831
let fetched = self.fetch_media(media_parts).await?;
832
833
let mut prepared = Vec::new();
834
if !fetched.images.is_empty() {
798
- prepared
799
- .push(self.prepare_images(fetched.images, fetched.image_uuids, model_dtype).await?);
835
+ prepared.push(
836
+ self.prepare_images(
837
+ fetched.images,
838
+ fetched.image_uuids,
839
+ model_dtype,
840
+ vision_context,
841
+ )
842
+ .await?,
843
+ );
844
}
845
if !fetched.videos.is_empty() {
846
prepared
@@ -1044,6 +1088,100 @@ mod tests {
1088
);
1089
}
1090
1091
+ #[test]
1092
+ fn vision_preprocessing_context_uses_runtime_budget_and_text_tokens() {
1093
+ let info = qwen3_vl_info();
1094
+ let context = info.vision_preprocessing_context(
1095
+ &[11, QWEN3_IMAGE_PAD_ID, 12, QWEN3_VIDEO_PAD_ID, 13],
1096
+ Some(4096),
1097
+ );
1098
+
1099
+ assert_eq!(context.token_budget, Some(4096 - 3));
1100
+ assert_eq!(
1101
+ info.vision_preprocessing_context(&[11, 12, 13], Some(2)).token_budget,
1102
+ Some(0)
1103
+ );
1104
+ assert_eq!(
1105
+ info.vision_preprocessing_context(&[11], None).token_budget,
1106
+ None
1107
+ );
1108
+ }
1109
+
1110
+ #[tokio::test]
1111
+ async fn nemotron_budget_controls_preprocessing_and_engine_features() {
1112
+ use vllm_engine_core_client::protocol::multimodal::MmKwargValue;
1113
+
1114
+ let tokenizer = TestTokenizer::new()
1115
+ .with_regular_token("<image>", 1018)
1116
+ .with_regular_token("<img>", 1019)
1117
+ .with_regular_token("</img>", 1020);
1118
+ let config = serde_json::json!({
1119
+ "model_type": "nemotron_h_omni",
1120
+ "img_context_token_id": 1018,
1121
+ "max_position_embeddings": 512,
1122
+ "patch_size": 16,
1123
+ "downsample_ratio": 0.5,
1124
+ "norm_mean": [0.0, 0.0, 0.0],
1125
+ "norm_std": [1.0, 1.0, 1.0],
1126
+ "vision_config": {
1127
+ "args": {"min_num_patches": 1024, "max_num_patches": 13312}
1128
+ }
1129
+ });
1130
+ let info = test_info("nemotron_h_omni", config, tokenizer);
1131
+ let media = MediaContentPart::ImageUrl {
1132
+ url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=".to_string(),
1133
+ detail: None,
1134
+ uuid: None,
1135
+ };
1136
+ let mut prompt = vec![11; 100];
1137
+ prompt.push(1018);
1138
+
1139
+ let mut tokens = prompt.clone();
1140
+ let features = info
1141
+ .prepare_multimodal(
1142
+ vec![media.clone()],
1143
+ &mut tokens,
1144
+ ModelDtype::Float32,
1145
+ Some(512),
1146
+ )
1147
+ .await
1148
+ .unwrap();
1149
+ assert_eq!(features.len(), 1);
1150
+ let feature = &features[0];
1151
+ assert_eq!(
1152
+ (feature.mm_position.offset, feature.mm_position.length),
1153
+ (100, 258)
1154
+ );
1155
+ assert_eq!(tokens.len(), 358);
1156
+ assert_eq!(tokens[100], 1019);
1157
+ assert!(tokens[101..357].iter().all(|&token| token == 1018));
1158
+ assert_eq!(tokens[357], 1020);
1159
+ let data = feature.data.as_ref().unwrap();
1160
+ assert!(matches!(
1161
+ data["pixel_values_flat"].data.as_ref(),
1162
+ Some(MmKwargValue::Tensor(tensor)) if tensor.shape == [3, 512, 512]
1163
+ ));
1164
+ assert_eq!(
1165
+ data["num_tokens_per_image"].data,
1166
+ Some(MmKwargValue::Int(256))
1167
+ );
1168
+ assert_eq!(
1169
+ data["imgs_sizes"].data,
1170
+ Some(MmKwargValue::List(vec![
1171
+ MmKwargValue::Int(512),
1172
+ MmKwargValue::Int(512),
1173
+ ]))
1174
+ );
1175
+
1176
+ let error = info
1177
+ .prepare_multimodal(vec![media], &mut prompt, ModelDtype::Float32, Some(100))
1178
+ .await
1179
+ .unwrap_err();
1180
+ assert!(
1181
+ matches!(error, Error::Multimodal(message) if message.contains("exceeding token_budget"))
1182
+ );
1183
+ }
1184
+
1185
#[test]
1186
fn qwen3_vl_without_video_token_id_disables_video_support_only() {
1187
let config = serde_json::json!({
rust/src/chat/src/multimodal/audio.rs
+1
-1
@@ -259,7 +259,7 @@ mod tests {
259
uuid: Some("audio-1".to_string()),
260
}];
261
async {
262
- info.prepare_multimodal(parts, &mut vec![AUDIO_PAD_ID], ModelDtype::Float32)
262
+ info.prepare_multimodal(parts, &mut vec![AUDIO_PAD_ID], ModelDtype::Float32, None)
263
.instrument(mm_request_span("request-1"))
264
.await
265
.unwrap();
rust/src/chat/src/multimodal/image.rs
+9
-5
@@ -6,7 +6,7 @@
6
7
use std::sync::Arc;
8
9
-use llm_multimodal::{ImageFrame, Modality, PreprocessedEncoderInputs};
9
+use llm_multimodal::{ImageFrame, Modality, PreprocessedEncoderInputs, VisionPreprocessingContext};
10
use vllm_engine_core_client::protocol::dtype::ModelDtype;
11
12
use super::timing::MM_STAGE_TARGET;
@@ -30,11 +30,12 @@ impl MultimodalModelInfo {
30
frames: Vec<Arc<ImageFrame>>,
31
uuids: Vec<Option<String>>,
32
model_dtype: ModelDtype,
33
+ preprocessing_context: VisionPreprocessingContext,
34
) -> Result<PreparedMedia> {
35
let support = self.image.as_ref().ok_or_else(|| Error::UnsupportedModality {
36
modality: Modality::Image.to_string(),
37
})?;
37
- let preprocessed = self.preprocess_images(support, &frames).await?;
38
+ let preprocessed = self.preprocess_images(support, &frames, preprocessing_context).await?;
39
let replacements = support.spec.prompt_replacements_for(&self.context, &preprocessed)?;
40
if replacements.len() != frames.len() {
41
bail_multimodal!(
@@ -65,13 +66,16 @@ impl MultimodalModelInfo {
66
&self,
67
support: &VisionModalitySupport,
68
image_frames: &[Arc<ImageFrame>],
69
+ preprocessing_context: VisionPreprocessingContext,
70
) -> Result<PreprocessedEncoderInputs> {
71
let processor = Arc::clone(&support.processor);
72
let images = image_frames.iter().map(|frame| frame.data().clone()).collect::<Vec<_>>();
73
74
// TODO: is it still necessary given that we've already in a dedicated runtime?
73
- tokio::task::spawn_blocking(move || Ok(processor.preprocess(&images)?))
74
- .await
75
- .map_err(|error| multimodal!("image preprocessing task failed: {error}"))?
75
+ tokio::task::spawn_blocking(move || {
76
+ Ok(processor.preprocess_with_context(&images, &preprocessing_context)?)
77
+ })
78
+ .await
79
+ .map_err(|error| multimodal!("image preprocessing task failed: {error}"))?
80
}
81
}
rust/src/chat/src/multimodal/tensor.rs
+47
-2
@@ -18,7 +18,7 @@ use crate::error::{Error, Result, bail_multimodal, multimodal};
18
pub(super) enum KwargValue {
19
/// Tensor with row-major flat data and shape.
20
Tensor { wire: WireTensor },
21
- /// Non-tensor kwarg value that is shared or copied as-is.
21
+ /// Non-tensor kwarg value preserved in protocol form.
22
Passthrough(ProtocolKwargValue),
23
}
24
@@ -150,7 +150,17 @@ impl KwargValue {
150
/// Batched fields use their first axis as media-item index and drop that
151
/// axis in the per-feature value, matching vLLM's batched-field semantics.
152
pub(super) fn batched_wire_value_at(&self, index: usize) -> Result<ProtocolKwargValue> {
153
- self.wire_value_range(index, index + 1, true)
153
+ match self {
154
+ Self::Passthrough(ProtocolKwargValue::List(values)) => values
155
+ .get(index)
156
+ .cloned()
157
+ .ok_or_else(|| multimodal!("batched passthrough has no item at index {index}")),
158
+ Self::Passthrough(value) if index == 0 => Ok(value.clone()),
159
+ Self::Passthrough(_) => Err(multimodal!(
160
+ "scalar batched passthrough has no item at index {index}"
161
+ )),
162
+ Self::Tensor { .. } => self.wire_value_range(index, index + 1, true),
163
+ }
164
}
165
166
/// Convert one media item's flat tensor range directly to wire bytes.
@@ -336,6 +346,41 @@ mod tests {
346
);
347
}
348
349
+ #[test]
350
+ fn batched_passthrough_value_at_selects_media_item() {
351
+ let image_sizes = KwargValue::from_model_specific(
352
+ ModelSpecificValue::TupleVec(vec![(384, 736), (512, 512)]),
353
+ ModelDtype::BFloat16,
354
+ )
355
+ .unwrap();
356
+
357
+ assert_eq!(
358
+ image_sizes.batched_wire_value_at(1).unwrap(),
359
+ ProtocolKwargValue::List(vec![
360
+ ProtocolKwargValue::Int(512),
361
+ ProtocolKwargValue::Int(512),
362
+ ])
363
+ );
364
+ assert!(image_sizes.batched_wire_value_at(2).is_err());
365
+
366
+ let token_counts = KwargValue::from_model_specific(
367
+ ModelSpecificValue::IntVec(vec![276, 400]),
368
+ ModelDtype::BFloat16,
369
+ )
370
+ .unwrap();
371
+ assert_eq!(
372
+ token_counts.batched_wire_value_at(1).unwrap(),
373
+ ProtocolKwargValue::Int(400)
374
+ );
375
+
376
+ let scalar = KwargValue::Passthrough(ProtocolKwargValue::Int(7));
377
+ assert_eq!(
378
+ scalar.batched_wire_value_at(0).unwrap(),
379
+ ProtocolKwargValue::Int(7)
380
+ );
381
+ assert!(scalar.batched_wire_value_at(1).is_err());
382
+ }
383
+
384
#[test]
385
fn flat_wire_value_range_keeps_first_axis() {
386
let value = KwargValue::Tensor {
rust/src/text/src/backend/hf/config.rs
+28
-4
@@ -84,10 +84,11 @@ impl HfSpecialTokens {
84
85
/// Minimal subset of `config.json` (the model's main HF config).
86
///
87
-/// This intentionally supports only the two layouts we currently care about in
87
+/// This intentionally supports only the layouts we currently care about in
88
/// the Rust frontend:
89
/// - pure text models that keep text metadata at the top level
90
-/// - composite models that expose a single nested `text_config`
90
+/// - composite models that expose a single nested `text_config` or its
91
+/// `llm_config` alias (used by Nemotron)
92
///
93
/// We do not support additional entry points such as `decoder`, `generator`, or
94
/// `text_encoder`.
@@ -102,6 +103,7 @@ pub struct ModelConfig {
103
n_routed_experts: Option<OneOrManyExpertCount>,
104
num_local_experts: Option<OneOrManyExpertCount>,
105
block_configs: Vec<BlockConfig>,
106
+ #[serde(alias = "llm_config")]
107
text_config: Option<Box<ModelConfig>>,
108
}
109
@@ -188,7 +190,7 @@ impl ModelConfig {
190
/// Return the config that the Rust frontend treats as the text/LLM config.
191
///
192
/// This is deliberately narrower than Python/transformers: we only support
191
- /// either the top-level config itself or a single nested `text_config`.
193
+ /// either the top-level config itself or a single nested text config.
194
fn effective_text_config(&self) -> &Self {
195
self.text_config.as_deref().unwrap_or(self)
196
}
@@ -233,7 +235,8 @@ impl ModelConfig {
235
/// config.
236
///
237
/// The only intentional simplification here is how we pick the text config:
236
- /// Rust only looks at the top level or `text_config`, not the broader
238
+ /// Rust only looks at the top level or `text_config` (including its
239
+ /// `llm_config` alias), not the broader
240
/// transformers composite-config surface.
241
fn num_experts_from_block_configs(&self) -> u32 {
242
self.effective_text_config()
@@ -405,6 +408,27 @@ mod tests {
408
assert_eq!(config.vocab_size().unwrap(), 151936);
409
}
410
411
+ #[test]
412
+ fn model_config_uses_llm_config_for_nemotron_composite_models() {
413
+ let config: ModelConfig = serde_json::from_str(
414
+ r#"{
415
+ "model_type": "nemotron_h_omni",
416
+ "llm_config": {
417
+ "model_type": "nemotron_h",
418
+ "vocab_size": 131072,
419
+ "n_routed_experts": 256,
420
+ "eos_token_id": 2
421
+ }
422
+ }"#,
423
+ )
424
+ .unwrap();
425
+
426
+ assert_eq!(config.model_type(), Some("nemotron_h_omni"));
427
+ assert_eq!(config.vocab_size().unwrap(), 131072);
428
+ assert_eq!(config.eos_token_ids(), &[2]);
429
+ assert_eq!(config.num_experts(), 256);
430
+ }
431
+
432
#[test]
433
fn model_config_reads_top_level_eos_token_ids() {
434
let single: ModelConfig = serde_json::from_str(r#"{"eos_token_id":151645}"#).unwrap();