[Rust Frontend] Add --limit-mm-per-prompt support (#49604)
Signed-off-by: Maria Guevara <kawaiiplush14@gmail.com>
Maria Guevara committed
Jul 29, 2026 at 10:21 UTC
c44e191b014db0619bd51921e94c86b901ab952e
14 files changed
+413
-16
rust/src/chat/src/backend/hf.rs
+2
@@ -57,6 +57,7 @@ impl HfChatBackend {
57
processor_config: files.processor_config_path.as_deref(),
58
},
59
tokenizer.clone(),
60
+ options.limit_mm_per_prompt.clone(),
61
)?
62
};
63
let multimodal_render_info = resolve_multimodal_render_info(multimodal_model_info.as_ref());
@@ -231,6 +232,7 @@ mod tests {
232
chat_template_content_format: Default::default(),
233
chat_template: None,
234
default_chat_template_kwargs: HashMap::new(),
235
+ limit_mm_per_prompt: HashMap::new(),
236
},
237
test_tokenizer(),
238
)
rust/src/chat/src/backend/mod.rs
+4
-1
@@ -8,7 +8,7 @@ use serde_json::Value;
8
use vllm_text::{DynTextBackend, TextBackend};
9
10
use crate::error::Result;
11
-use crate::multimodal::MultimodalModelInfo;
11
+use crate::multimodal::{MmLimitPerPrompt, MultimodalModelInfo};
12
use crate::output::DynChatOutputProcessor;
13
use crate::renderer::DynChatRenderer;
14
use crate::request::ChatRequest;
@@ -74,6 +74,9 @@ pub struct LoadModelBackendsOptions {
74
/// Optional server-default keyword arguments merged into every
75
/// chat-template render before request-level `chat_template_kwargs`.
76
pub default_chat_template_kwargs: HashMap<String, Value>,
77
+ /// Maximum number of input items allowed per prompt for each modality.
78
+ /// Unspecified modalities are unlimited.
79
+ pub limit_mm_per_prompt: MmLimitPerPrompt,
80
}
81
82
/// Shared backends loaded from a model id.
rust/src/chat/src/error.rs
+4
-1
@@ -23,6 +23,8 @@ pub enum Error {
23
UnsupportedMultimodalContent(&'static str),
24
#[error("`{modality}` input is not supported by this model")]
25
UnsupportedModality { modality: String },
26
+ #[error("At most {limit} {modality}(s) may be provided in one prompt.")]
27
+ MmLimitExceeded { modality: String, limit: usize },
28
#[error("multimodal preprocessing error: {0}")]
29
Multimodal(#[message] String),
30
#[error("{kind} parsing is not available for model `{model_id}`")]
@@ -87,7 +89,8 @@ impl Error {
89
Self::Text(error) => error.is_request_validation_error(),
90
Self::UnsupportedMultimodalRenderer
91
| Self::UnsupportedMultimodalContent(_)
90
- | Self::UnsupportedModality { .. } => true,
92
+ | Self::UnsupportedModality { .. }
93
+ | Self::MmLimitExceeded { .. } => true,
94
95
_ => false,
96
}
rust/src/chat/src/multimodal.rs
+262
-9
@@ -11,7 +11,7 @@
11
//! Raw media stays above `vllm-text`; this module lowers it into token IDs and
12
//! opaque tensor payloads before the request is handed to text generation.
13
14
-use std::collections::HashSet;
14
+use std::collections::{BTreeMap, HashMap, HashSet};
15
use std::fs;
16
use std::path::Path;
17
use std::sync::{Arc, LazyLock};
@@ -24,6 +24,7 @@ use llm_multimodal::{
24
PromptReplacement, Tokenizer as TokenResolver, TrackedMedia, VideoClip, VisionPreProcessor,
25
VisionProcessorRegistry,
26
};
27
+use serde::{Deserialize, Serialize};
28
use thiserror_ext::AsReport as _;
29
use tracing::warn;
30
use vllm_engine_core_client::protocol::dtype::ModelDtype;
@@ -52,6 +53,71 @@ pub struct MultimodalModelInfo {
53
video: Option<ModalitySupport>,
54
audio: Option<AudioModalitySupport>,
55
media_connector: Arc<MediaConnector>,
56
+ /// Maximum number of input items allowed per prompt for each modality.
57
+ limit_mm_per_prompt: MmLimitPerPrompt,
58
+}
59
+
60
+/// Per-modality item-count limits configured by `--limit-mm-per-prompt`.
61
+///
62
+/// Modalities absent from the map are unlimited.
63
+pub type MmLimitPerPrompt = HashMap<MmLimitModality, MmLimitSpec>;
64
+
65
+/// Modalities that `--limit-mm-per-prompt` can be keyed by.
66
+///
67
+/// Closed on purpose: these are exactly the keys Python accepts, per
68
+/// `MultiModalDummyOptionsBuiltins` in `vllm/config/multimodal.py`.
69
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
70
+#[serde(rename_all = "snake_case")]
71
+pub enum MmLimitModality {
72
+ Image,
73
+ Audio,
74
+ Video,
75
+}
76
+
77
+impl MmLimitModality {
78
+ /// The wire name, matching Python's modality strings.
79
+ pub fn as_str(self) -> &'static str {
80
+ match self {
81
+ Self::Image => "image",
82
+ Self::Audio => "audio",
83
+ Self::Video => "video",
84
+ }
85
+ }
86
+}
87
+
88
+/// One modality's limit, in either of the two shapes Python accepts.
89
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90
+#[serde(
91
+ untagged,
92
+ expecting = "an item count, or an object with an optional `count` field"
93
+)]
94
+pub enum MmLimitSpec {
95
+ /// Legacy form: `"image": 16`
96
+ Count(usize),
97
+
98
+ /// Configurable form:
99
+ /// `"video": {"count": 1, "num_frames": 32}`
100
+ Options {
101
+ /// Absent means unlimited, matching an absent modality.
102
+ #[serde(default, skip_serializing_if = "Option::is_none")]
103
+ count: Option<usize>,
104
+
105
+ /// Preserve Python-owned options for forwarding. Never interpreted
106
+ /// here: they size the engine's dummy-profiling encoder cache, which
107
+ /// has no Rust counterpart.
108
+ #[serde(flatten)]
109
+ extra: BTreeMap<String, serde_json::Value>,
110
+ },
111
+}
112
+
113
+impl MmLimitSpec {
114
+ /// The configured item count, or `None` when this modality is unlimited.
115
+ pub fn count(&self) -> Option<usize> {
116
+ match self {
117
+ Self::Count(count) => Some(*count),
118
+ Self::Options { count, .. } => *count,
119
+ }
120
+ }
121
}
122
123
/// Model metadata and tokenizer access shared by all multimodal specs.
@@ -287,6 +353,7 @@ impl MultimodalModelInfo {
353
model_type: Option<String>,
354
files: MultimodalConfigFiles<'_>,
355
tokenizer: DynTokenizer,
356
+ limit_mm_per_prompt: MmLimitPerPrompt,
357
) -> Result<Option<Self>> {
358
let config = match files.config {
359
Some(path) => {
@@ -319,7 +386,12 @@ impl MultimodalModelInfo {
386
tokenizer: TokenizerResolver(tokenizer),
387
};
388
322
- Self::from_loaded(context, preprocessor_config, video_preprocessor_config)
389
+ Self::from_loaded(
390
+ context,
391
+ preprocessor_config,
392
+ video_preprocessor_config,
393
+ limit_mm_per_prompt,
394
+ )
395
}
396
397
/// Resolve multimodal support from an assembled context and parsed
@@ -328,6 +400,7 @@ impl MultimodalModelInfo {
400
context: MultimodalModelContext,
401
preprocessor_config: PreProcessorConfig,
402
video_preprocessor_config: PreProcessorConfig,
403
+ limit_mm_per_prompt: MmLimitPerPrompt,
404
) -> Result<Option<Self>> {
405
let (image, video) = Self::resolve_vision_lanes(
406
&context,
@@ -356,6 +429,7 @@ impl MultimodalModelInfo {
429
video,
430
audio,
431
media_connector,
432
+ limit_mm_per_prompt,
433
}))
434
}
435
@@ -567,7 +641,55 @@ fn input_audio_data_url(data: &str, format: Option<&str>) -> Result<String> {
641
Ok(format!("data:{mime_type};base64,{data}"))
642
}
643
644
+/// The modality a content part counts against, or `None` for plain text.
645
+///
646
+/// Embedding inputs share their base modality's budget rather than getting one
647
+/// of their own, matching Python's `modality.replace("_embeds", "")` in
648
+/// `vllm/entrypoints/chat_utils.py`.
649
+fn media_part_limit_modality(part: &MediaContentPart) -> Option<MmLimitModality> {
650
+ match part {
651
+ MediaContentPart::Text { .. } => None,
652
+ MediaContentPart::ImageUrl { .. }
653
+ | MediaContentPart::ImageData { .. }
654
+ | MediaContentPart::ImageEmbeds { .. } => Some(MmLimitModality::Image),
655
+ MediaContentPart::AudioUrl { .. } | MediaContentPart::AudioData { .. } => {
656
+ Some(MmLimitModality::Audio)
657
+ }
658
+ MediaContentPart::VideoUrl { .. } | MediaContentPart::VideoData { .. } => {
659
+ Some(MmLimitModality::Video)
660
+ }
661
+ }
662
+}
663
+
664
impl MultimodalModelInfo {
665
+ /// Reject requests exceeding `--limit-mm-per-prompt`'s configured
666
+ /// per-modality item count, before any fetch/decode work is spent on them.
667
+ ///
668
+ /// Modalities without a configured count are unlimited.
669
+ fn validate_mm_limits(&self, media_parts: &[MediaContentPart]) -> Result<()> {
670
+ let mut counts: HashMap<MmLimitModality, usize> = HashMap::new();
671
+ for part in media_parts {
672
+ if let Some(modality) = media_part_limit_modality(part) {
673
+ *counts.entry(modality).or_default() += 1;
674
+ }
675
+ }
676
+
677
+ for (modality, count) in counts {
678
+ let Some(limit) = self.limit_mm_per_prompt.get(&modality).and_then(MmLimitSpec::count)
679
+ else {
680
+ continue;
681
+ };
682
+ if count > limit {
683
+ return Err(Error::MmLimitExceeded {
684
+ modality: modality.as_str().to_string(),
685
+ limit,
686
+ });
687
+ }
688
+ }
689
+
690
+ Ok(())
691
+ }
692
+
693
/// Run media fetch, per-modality preprocessing, prompt expansion, and
694
/// feature build.
695
///
@@ -584,8 +706,7 @@ impl MultimodalModelInfo {
706
if media_parts_len == 0 {
707
return Ok(Vec::new());
708
}
587
- // TODO: enforce per-modality item-count limits, aligned with the
588
- // engine's `--limit-mm-per-prompt` semantics.
709
+ self.validate_mm_limits(&media_parts)?;
710
let fetched = self.fetch_media(media_parts).await?;
711
712
let mut prepared = Vec::new();
@@ -753,10 +874,11 @@ mod tests {
874
.with_regular_token("<|video_pad|>", QWEN3_VIDEO_PAD_ID)
875
}
876
756
- fn test_info(
877
+ fn test_info_with_limits(
878
model_type: &str,
879
config: serde_json::Value,
880
tokenizer: TestTokenizer,
881
+ limit_mm_per_prompt: MmLimitPerPrompt,
882
) -> MultimodalModelInfo {
883
let context = MultimodalModelContext {
884
model_id: format!("{model_type}-test"),
@@ -769,11 +891,20 @@ mod tests {
891
context,
892
PreProcessorConfig::default(),
893
PreProcessorConfig::default(),
894
+ limit_mm_per_prompt,
895
)
896
.unwrap()
897
.unwrap_or_else(|| panic!("{model_type} multimodal support should resolve"))
898
}
899
900
+ fn test_info(
901
+ model_type: &str,
902
+ config: serde_json::Value,
903
+ tokenizer: TestTokenizer,
904
+ ) -> MultimodalModelInfo {
905
+ test_info_with_limits(model_type, config, tokenizer, HashMap::new())
906
+ }
907
+
908
fn llama4_info() -> MultimodalModelInfo {
909
let config = serde_json::json!({
910
"model_type": "llama4",
@@ -783,16 +914,19 @@ mod tests {
914
test_info("llama4", config, llama4_tokenizer())
915
}
916
786
- pub(super) fn qwen3_vl_info() -> MultimodalModelInfo {
787
- let config = serde_json::json!({
917
+ fn qwen3_vl_config() -> serde_json::Value {
918
+ serde_json::json!({
919
"model_type": "qwen3_vl",
920
"image_token_id": QWEN3_IMAGE_PAD_ID,
921
"video_token_id": QWEN3_VIDEO_PAD_ID,
922
"vision_start_token_id": 151652,
923
"vision_end_token_id": 151653,
924
"vision_config": {"patch_size": 16}
794
- });
795
- test_info("qwen3_vl", config, qwen3_vl_tokenizer())
925
+ })
926
+ }
927
+
928
+ pub(super) fn qwen3_vl_info() -> MultimodalModelInfo {
929
+ test_info("qwen3_vl", qwen3_vl_config(), qwen3_vl_tokenizer())
930
}
931
932
#[test]
@@ -853,4 +987,123 @@ mod tests {
987
);
988
assert!(input_audio_data_url("AAAA", Some("flac")).is_err());
989
}
990
+
991
+ fn image_url_part() -> MediaContentPart {
992
+ MediaContentPart::ImageUrl {
993
+ url: "https://example.com/image.png".to_string(),
994
+ detail: None,
995
+ uuid: None,
996
+ }
997
+ }
998
+
999
+ fn qwen3_vl_info_with_limits(limit_mm_per_prompt: MmLimitPerPrompt) -> MultimodalModelInfo {
1000
+ test_info_with_limits(
1001
+ "qwen3_vl",
1002
+ qwen3_vl_config(),
1003
+ qwen3_vl_tokenizer(),
1004
+ limit_mm_per_prompt,
1005
+ )
1006
+ }
1007
+
1008
+ #[test]
1009
+ fn validate_mm_limits_ignores_text_parts() {
1010
+ let info = qwen3_vl_info();
1011
+ let parts = vec![
1012
+ MediaContentPart::Text {
1013
+ text: "hello".to_string(),
1014
+ },
1015
+ MediaContentPart::Text {
1016
+ text: "world".to_string(),
1017
+ },
1018
+ ];
1019
+ assert!(info.validate_mm_limits(&parts).is_ok());
1020
+ }
1021
+
1022
+ #[test]
1023
+ fn validate_mm_limits_leaves_unconfigured_modalities_unlimited() {
1024
+ let info = qwen3_vl_info();
1025
+ let parts: Vec<_> = std::iter::repeat_with(image_url_part).take(1_000).collect();
1026
+
1027
+ assert!(info.validate_mm_limits(&parts).is_ok());
1028
+ }
1029
+
1030
+ #[test]
1031
+ fn validate_mm_limits_enforces_configured_limit_at_the_boundary() {
1032
+ let info = qwen3_vl_info_with_limits(HashMap::from([(
1033
+ MmLimitModality::Image,
1034
+ MmLimitSpec::Count(1),
1035
+ )]));
1036
+ assert!(info.validate_mm_limits(&[image_url_part()]).is_ok());
1037
+
1038
+ let error = info.validate_mm_limits(&[image_url_part(), image_url_part()]).unwrap_err();
1039
+ assert_eq!(
1040
+ error.to_report_string(),
1041
+ "At most 1 image(s) may be provided in one prompt."
1042
+ );
1043
+ // Confirms the HTTP-mapping bug found during implementation stays fixed:
1044
+ // this must map to 400, not 500.
1045
+ assert!(error.is_request_validation_error());
1046
+ }
1047
+
1048
+ #[test]
1049
+ fn validate_mm_limits_counts_image_embeds_against_the_image_limit() {
1050
+ let info = qwen3_vl_info_with_limits(HashMap::from([(
1051
+ MmLimitModality::Image,
1052
+ MmLimitSpec::Count(1),
1053
+ )]));
1054
+ let image_embeds_part = MediaContentPart::ImageEmbeds {
1055
+ payload: serde_json::Value::String("AAAA".to_string()),
1056
+ uuid: None,
1057
+ };
1058
+
1059
+ let error = info.validate_mm_limits(&[image_url_part(), image_embeds_part]).unwrap_err();
1060
+ assert_eq!(
1061
+ error.to_report_string(),
1062
+ "At most 1 image(s) may be provided in one prompt."
1063
+ );
1064
+ }
1065
+
1066
+ /// An options object without a `count` carries only profiling keys, which
1067
+ /// say nothing about how many items are allowed.
1068
+ #[test]
1069
+ fn validate_mm_limits_treats_a_count_less_options_object_as_unlimited() {
1070
+ let info = qwen3_vl_info_with_limits(HashMap::from([(
1071
+ MmLimitModality::Image,
1072
+ MmLimitSpec::Options {
1073
+ count: None,
1074
+ extra: BTreeMap::from([("width".to_string(), serde_json::json!(512))]),
1075
+ },
1076
+ )]));
1077
+
1078
+ assert!(info.validate_mm_limits(&[image_url_part(), image_url_part()]).is_ok());
1079
+ }
1080
+
1081
+ fn parse_limits(json: &str) -> MmLimitPerPrompt {
1082
+ serde_json::from_str(json).expect("limit map should parse")
1083
+ }
1084
+
1085
+ #[test]
1086
+ fn limit_map_parses_both_shapes_python_accepts() {
1087
+ let limits = parse_limits(r#"{"image": 16, "video": {"count": 1, "num_frames": 32}}"#);
1088
+
1089
+ assert_eq!(limits[&MmLimitModality::Image].count(), Some(16));
1090
+ assert_eq!(limits[&MmLimitModality::Video].count(), Some(1));
1091
+ assert_eq!(limits.get(&MmLimitModality::Audio), None);
1092
+ }
1093
+
1094
+ #[test]
1095
+ fn limit_map_rejects_keys_python_does_not_accept() {
1096
+ assert!(serde_json::from_str::<MmLimitPerPrompt>(r#"{"image_embeds": 1}"#).is_err());
1097
+ }
1098
+
1099
+ /// Managed mode forwards this map back to Python as JSON, where
1100
+ /// `BaseDummyOptions.count` is a non-optional `int` under `extra="forbid"`.
1101
+ /// Emitting `"count": null` would make the engine subprocess fail to start.
1102
+ #[test]
1103
+ fn limit_map_round_trips_without_emitting_a_null_count() {
1104
+ let source = r#"{"video":{"num_frames":32}}"#;
1105
+ let encoded = serde_json::to_string(&parse_limits(source)).expect("map should serialize");
1106
+
1107
+ assert_eq!(encoded, source);
1108
+ }
1109
}
rust/src/chat/src/multimodal/audio.rs
+2
@@ -107,6 +107,7 @@ mod tests {
107
context,
108
PreProcessorConfig::default(),
109
PreProcessorConfig::default(),
110
+ HashMap::new(),
111
)
112
.unwrap()
113
.expect("Inkling multimodal support")
@@ -126,6 +127,7 @@ mod tests {
127
context,
128
PreProcessorConfig::default(),
129
PreProcessorConfig::default(),
130
+ HashMap::new(),
131
)
132
.unwrap()
133
.expect("Qwen3-ASR multimodal support")
rust/src/chat/src/multimodal/video.rs
+1
@@ -207,6 +207,7 @@ mod tests {
207
Some("qwen3_vl".to_string()),
208
files,
209
Arc::new(qwen3_vl_tokenizer()),
210
+ std::collections::HashMap::new(),
211
)
212
};
213
rust/src/cmd/src/cli.rs
+27
@@ -23,6 +23,7 @@ use serde_with::{DefaultOnNull, OneOrMany, serde_as};
23
use thiserror_ext::AsReport as _;
24
use uuid::Uuid;
25
use vllm_chat::ReasoningParserFactory;
26
+use vllm_chat::multimodal::MmLimitPerPrompt;
27
use vllm_engine_core_client::TransportMode;
28
use vllm_managed_engine::ManagedEngineConfig;
29
use vllm_managed_engine::cli::{ManagedEngineArgs, repartition_managed_engine_args};
@@ -186,6 +187,17 @@ pub struct SharedRuntimeArgs {
187
#[serde(default)]
188
pub default_chat_template_kwargs: Option<HashMap<String, Value>>,
189
190
+ /// The maximum number of input items allowed per prompt for each
191
+ /// modality, as a JSON object (e.g. `{"image": 16, "video": 2}`).
192
+ ///
193
+ /// Also accepts the engine's configurable form
194
+ /// (e.g. `{"video": {"count": 1, "num_frames": 32}}`); the extra
195
+ /// profiling options are forwarded to the engine untouched.
196
+ /// Unspecified modalities are unlimited.
197
+ #[arg(long, value_parser = parse_json::<MmLimitPerPrompt>, value_name = "JSON", default_value = "{}")]
198
+ #[serde(default)]
199
+ pub limit_mm_per_prompt: MmLimitPerPrompt,
200
+
201
/// The format to render message content within a chat template.
202
///
203
/// * "auto" detects the format from the template
@@ -348,6 +360,18 @@ impl SharedRuntimeArgs {
360
.expect("profiler config serialization should not fail")
361
}
362
363
+ /// Return the per-modality limits as JSON for managed Python engine
364
+ /// forwarding, or `None` when nothing is configured.
365
+ ///
366
+ /// Round-tripping the parsed map rather than the raw argument keeps the
367
+ /// engine's own profiling options (`num_frames`, `width`, ...) intact.
368
+ pub fn limit_mm_per_prompt_json(&self) -> Option<String> {
369
+ (!self.limit_mm_per_prompt.is_empty()).then(|| {
370
+ serde_json::to_string(&self.limit_mm_per_prompt)
371
+ .expect("limit-mm-per-prompt serialization should not fail")
372
+ })
373
+ }
374
+
375
/// Apply fallback logic for API key configuration from env variables.
376
fn apply_env_api_key_fallback(&mut self) {
377
if self.api_key.is_empty()
@@ -399,6 +423,7 @@ impl SharedRuntimeArgs {
423
language_model_only: self.language_model_only,
424
chat_template: self.chat_template,
425
default_chat_template_kwargs: self.default_chat_template_kwargs,
426
+ limit_mm_per_prompt: self.limit_mm_per_prompt,
427
chat_template_content_format: self.chat_template_content_format,
428
max_logprobs: self.max_logprobs,
429
api_server_options,
@@ -451,6 +476,7 @@ impl SharedRuntimeArgs {
476
language_model_only: self.language_model_only,
477
chat_template: self.chat_template,
478
default_chat_template_kwargs: self.default_chat_template_kwargs,
479
+ limit_mm_per_prompt: self.limit_mm_per_prompt,
480
chat_template_content_format: self.chat_template_content_format,
481
max_logprobs: self.max_logprobs,
482
api_server_options,
@@ -666,6 +692,7 @@ impl ServeArgs {
692
self.runtime.disable_log_stats,
693
self.runtime.shutdown_timeout,
694
handshake_port,
695
+ self.runtime.limit_mm_per_prompt_json(),
696
)
697
}
698
}
rust/src/cmd/src/cli/tests.rs
+24
@@ -64,6 +64,7 @@ fn serve_args_forward_python_flags_with_separator() {
64
http_timeout_keep_alive: None,
65
chat_template: None,
66
default_chat_template_kwargs: None,
67
+ limit_mm_per_prompt: {},
68
chat_template_content_format: Auto,
69
enable_log_requests: false,
70
enable_prompt_tokens_details: false,
@@ -762,6 +763,7 @@ fn frontend_args_accept_json() {
763
http_timeout_keep_alive: None,
764
chat_template: None,
765
default_chat_template_kwargs: None,
766
+ limit_mm_per_prompt: {},
767
chat_template_content_format: Auto,
768
enable_log_requests: false,
769
enable_prompt_tokens_details: false,
@@ -1117,6 +1119,24 @@ fn frontend_args_json_rejects_malformed_json() {
1119
"#]].assert_eq(&error.to_string());
1120
}
1121
1122
+#[test]
1123
+fn serve_args_reject_unsupported_modality_in_limit_mm_per_prompt() {
1124
+ let error = Cli::try_parse_from([
1125
+ "vllm-rs",
1126
+ "serve",
1127
+ "Qwen/Qwen3-0.6B",
1128
+ "--limit-mm-per-prompt",
1129
+ r#"{"unsupported_modality": 1}"#,
1130
+ ])
1131
+ .unwrap_err();
1132
+
1133
+ expect![[r#"
1134
+ error: invalid value '{"unsupported_modality": 1}' for '--limit-mm-per-prompt <JSON>': invalid JSON object: unknown variant `unsupported_modality`, expected one of `image`, `audio`, `video` at line 1 column 23
1135
+
1136
+ For more information, try '--help'.
1137
+ "#]].assert_eq(&error.to_string());
1138
+}
1139
+
1140
#[test]
1141
fn serve_args_reject_flags_before_model() {
1142
let error = Cli::try_parse_from(["vllm-rs", "serve", "--python", "python3", "Qwen/Qwen3-0.6B"])
@@ -1331,6 +1351,7 @@ fn serve_args_accept_handshake_aliases() {
1351
http_timeout_keep_alive: None,
1352
chat_template: None,
1353
default_chat_template_kwargs: None,
1354
+ limit_mm_per_prompt: {},
1355
chat_template_content_format: Auto,
1356
enable_log_requests: false,
1357
enable_prompt_tokens_details: false,
@@ -1474,6 +1495,7 @@ fn serve_frontend_config_uses_dp_address_as_advertised_host() {
1495
language_model_only: false,
1496
chat_template: None,
1497
default_chat_template_kwargs: None,
1498
+ limit_mm_per_prompt: {},
1499
chat_template_content_format: Auto,
1500
max_logprobs: None,
1501
api_server_options: ApiServerOptions {
@@ -1558,6 +1580,7 @@ fn serve_frontend_config_keeps_tcp_transport_for_non_local_only_topology() {
1580
language_model_only: false,
1581
chat_template: None,
1582
default_chat_template_kwargs: None,
1583
+ limit_mm_per_prompt: {},
1584
chat_template_content_format: Auto,
1585
max_logprobs: None,
1586
api_server_options: ApiServerOptions {
@@ -1660,6 +1683,7 @@ fn frontend_config_uses_external_coordinator_when_coordinator_address_is_present
1683
language_model_only: false,
1684
chat_template: None,
1685
default_chat_template_kwargs: None,
1686
+ limit_mm_per_prompt: {},
1687
chat_template_content_format: Auto,
1688
max_logprobs: None,
1689
api_server_options: ApiServerOptions {
rust/src/cmd/src/cli/unsupported.rs
-5
@@ -299,11 +299,6 @@ pub struct EngineUnsupportedArgs {
299
)]
300
pub kv_sharing_fast_prefill: Option<Unsupported>,
301
302
- /// The maximum number of input items and options allowed per
303
- /// prompt for each modality.
304
- #[arg(long)]
305
- pub limit_mm_per_prompt: Option<Unsupported>,
306
-
302
/// Additional args passed to process media inputs, keyed by modalities.
303
#[arg(long)]
304
pub media_io_kwargs: Option<Unsupported>,
rust/src/managed-engine/src/cli.rs
+5
@@ -91,6 +91,7 @@ impl ManagedEngineArgs {
91
disable_log_stats: bool,
92
shutdown_timeout: u64,
93
handshake_port: u16,
94
+ limit_mm_per_prompt: Option<String>,
95
) -> ManagedEngineConfig {
96
let mut python_args = self.python_args;
97
// Manually forward some args to the Python engine.
@@ -126,6 +127,10 @@ impl ManagedEngineArgs {
127
python_args.push("--data-parallel-size-local".to_string());
128
python_args.push(data_parallel_size_local.to_string());
129
}
130
+ if let Some(limit_mm_per_prompt) = limit_mm_per_prompt {
131
+ python_args.push("--limit-mm-per-prompt".to_string());
132
+ python_args.push(limit_mm_per_prompt);
133
+ }
134
135
ManagedEngineConfig {
136
python: self.python,
rust/src/server/examples/external_engine_openai_qwen.rs
+2
@@ -1,6 +1,7 @@
1
// SPDX-License-Identifier: Apache-2.0
2
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+use std::collections::HashMap;
5
use std::time::Duration;
6
7
use anyhow::{Context, Result, bail};
@@ -70,6 +71,7 @@ async fn main() -> Result<()> {
71
language_model_only: false,
72
chat_template: None,
73
default_chat_template_kwargs: None,
74
+ limit_mm_per_prompt: HashMap::new(),
75
chat_template_content_format: ChatTemplateContentFormatOption::Auto,
76
max_logprobs: None,
77
api_server_options: ApiServerOptions::default(),
rust/src/server/src/config.rs
+4
@@ -10,6 +10,7 @@ use axum::http::{HeaderName, HeaderValue, Method};
10
use educe::Educe;
11
use serde::Serialize;
12
use serde_json::Value;
13
+use vllm_chat::multimodal::MmLimitPerPrompt;
14
use vllm_chat::{ChatTemplateContentFormatOption, ParserSelection, RendererSelection};
15
use vllm_engine_core_client::{CoordinatorMode as EngineCoreCoordinatorMode, TransportMode};
16
@@ -184,6 +185,9 @@ pub struct Config {
185
pub chat_template: Option<String>,
186
/// Server-default keyword arguments merged into every chat-template render.
187
pub default_chat_template_kwargs: Option<HashMap<String, Value>>,
188
+ /// Maximum number of input items allowed per prompt for each modality.
189
+ /// Unspecified modalities are unlimited.
190
+ pub limit_mm_per_prompt: MmLimitPerPrompt,
191
/// How to serialize `message.content` for chat-template rendering.
192
pub chat_template_content_format: ChatTemplateContentFormatOption,
193
/// Optional maximum number of top log probabilities accepted by the
rust/src/server/src/lib.rs
+1
@@ -101,6 +101,7 @@ async fn build_state(config: &Config) -> Result<Arc<AppState>> {
101
.default_chat_template_kwargs
102
.clone()
103
.unwrap_or_default(),
104
+ limit_mm_per_prompt: config.limit_mm_per_prompt.clone(),
105
},
106
)
107
.await
rust/src/server/src/routes/tests.rs
+75
@@ -577,6 +577,12 @@ fn render_fake_content(content: &ChatContent, placeholder: &str) -> vllm_chat::R
577
}
578
579
fn qwen_multimodal_model_info() -> vllm_chat::multimodal::MultimodalModelInfo {
580
+ qwen_multimodal_model_info_with_limits(std::collections::HashMap::new())
581
+}
582
+
583
+fn qwen_multimodal_model_info_with_limits(
584
+ limit_mm_per_prompt: vllm_chat::multimodal::MmLimitPerPrompt,
585
+) -> vllm_chat::multimodal::MultimodalModelInfo {
586
let config_path = std::env::temp_dir().join(format!(
587
"vllm-server-qwen-config-{}.json",
588
uuid::Uuid::new_v4()
@@ -594,6 +600,7 @@ fn qwen_multimodal_model_info() -> vllm_chat::multimodal::MultimodalModelInfo {
600
..Default::default()
601
},
602
Arc::new(fake_chat_tokenizer()),
603
+ limit_mm_per_prompt,
604
)
605
.expect("load multimodal info")
606
.expect("qwen multimodal info is registered");
@@ -2307,6 +2314,74 @@ async fn non_stream_chat_image_url_reaches_engine_mm_features() {
2314
assert_eq!(json["choices"][0]["message"]["content"], "hi");
2315
}
2316
2317
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2318
+#[serial]
2319
+async fn non_stream_chat_rejects_when_image_count_exceeds_limit_mm_per_prompt() {
2320
+ // The request is rejected by `--limit-mm-per-prompt` validation before
2321
+ // ever reaching the engine, so the mock engine task is never awaited.
2322
+ let (chat, _engine_task) = test_models_with_engine_outputs_and_backend(
2323
+ b"engine-openai-mm-limit",
2324
+ default_stream_output_specs(),
2325
+ Arc::new(FakeChatBackend::with_multimodal_model_info(
2326
+ qwen_multimodal_model_info_with_limits(std::collections::HashMap::from([(
2327
+ vllm_chat::multimodal::MmLimitModality::Image,
2328
+ vllm_chat::multimodal::MmLimitSpec::Count(1),
2329
+ )])),
2330
+ )),
2331
+ )
2332
+ .await;
2333
+ let app = build_router(Arc::new(AppState::new(
2334
+ vec!["Qwen/Qwen1.5-0.5B-Chat".to_string()],
2335
+ chat,
2336
+ )));
2337
+
2338
+ let response = app
2339
+ .clone()
2340
+ .call(
2341
+ Request::builder()
2342
+ .method("POST")
2343
+ .uri("/v1/chat/completions")
2344
+ .header("content-type", "application/json")
2345
+ .body(Body::from(
2346
+ json!({
2347
+ "model": "Qwen/Qwen1.5-0.5B-Chat",
2348
+ "stream": false,
2349
+ "messages": [{
2350
+ "role": "user",
2351
+ "content": [
2352
+ {"type": "text", "text": "describe "},
2353
+ {
2354
+ "type": "image_url",
2355
+ "image_url": {
2356
+ "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
2357
+ }
2358
+ },
2359
+ {
2360
+ "type": "image_url",
2361
+ "image_url": {
2362
+ "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
2363
+ }
2364
+ }
2365
+ ]
2366
+ }]
2367
+ })
2368
+ .to_string(),
2369
+ ))
2370
+ .expect("build request"),
2371
+ )
2372
+ .await
2373
+ .expect("call app");
2374
+
2375
+ assert_eq!(response.status(), StatusCode::BAD_REQUEST);
2376
+ let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
2377
+ let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json");
2378
+ assert_eq!(json["error"]["type"], "invalid_request_error");
2379
+ assert_eq!(
2380
+ json["error"]["message"],
2381
+ "At most 1 image(s) may be provided in one prompt."
2382
+ );
2383
+}
2384
+
2385
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2386
#[serial]
2387
async fn non_stream_chat_includes_logprobs_and_prompt_logprobs() {