[Bugfix][Multimodal] Fix flat/scoped mm_processor_kwargs merge and resolution (#56372)

Signed-off-by: Daniel M. García-Ocaña Hernández <danielgarciaocana@gmail.com>

Daniel M. García-Ocaña Hernández committed Sep 29, 2026 at 05:58 UTC 295a0b8d6456b765edc5b135c6d2b5c03bccbb90
11 files changed +1601 -187
tests/config/test_multimodal_config.py
+244
@@ -188,6 +188,250 @@ def test_mm_processor_device_type_normalizes(device: object, expected: str | Non
188 assert config.get_mm_processor_device_type() == expected
189
190
191 +def test_merge_mm_processor_kwargs_scoped_precedence():
192 + configured = {
193 + "size": {
194 + "shortest_edge": 64,
195 + "longest_edge": 512,
196 + },
197 + "do_resize": True,
198 + "padding": False,
199 + "images_kwargs": {
200 + "size": {"longest_edge": 1024},
201 + "do_resize": False,
202 + },
203 + "videos_kwargs": {
204 + "size": {"longest_edge": 2048},
205 + },
206 + }
207 + inference = {
208 + "size": {"shortest_edge": 128},
209 + "do_resize": True,
210 + "images_kwargs": {
211 + "size": {"shortest_edge": 256},
212 + },
213 + }
214 +
215 + config = MultiModalConfig(
216 + mm_processor_kwargs=configured,
217 + mm_device_do_normalize=False,
218 + )
219 +
220 + assert config.merge_mm_processor_kwargs(inference) == {
221 + "size": {
222 + "shortest_edge": 128,
223 + "longest_edge": 512,
224 + },
225 + "do_resize": True,
226 + "padding": False,
227 + "images_kwargs": {
228 + "size": {
229 + "shortest_edge": 256,
230 + "longest_edge": 1024,
231 + },
232 + "do_resize": True,
233 + },
234 + "videos_kwargs": {
235 + "size": {
236 + "shortest_edge": 128,
237 + "longest_edge": 2048,
238 + },
239 + },
240 + }
241 +
242 +
243 +def test_merge_mm_processor_kwargs_mapping_vs_replacement():
244 + config = MultiModalConfig(
245 + mm_processor_kwargs={
246 + "size": {"shortest_edge": 64},
247 + "images_kwargs": {"size": {"longest_edge": 1024}},
248 + },
249 + mm_device_do_normalize=False,
250 + )
251 +
252 + assert config.merge_mm_processor_kwargs({"size": 7}) == {
253 + "size": 7,
254 + "images_kwargs": {"size": 7},
255 + }
256 + assert config.merge_mm_processor_kwargs({"size": {}}) == {
257 + "size": {"shortest_edge": 64},
258 + "images_kwargs": {
259 + "size": {
260 + "shortest_edge": 64,
261 + "longest_edge": 1024,
262 + },
263 + },
264 + }
265 +
266 +
267 +_EMPTY_IMAGE_SCOPE_KWARGS: tuple[dict[str, object], ...] = (
268 + {},
269 + {"images_kwargs": None},
270 + {"images_kwargs": {}},
271 +)
272 +
273 +
274 +@pytest.mark.parametrize("configured", _EMPTY_IMAGE_SCOPE_KWARGS)
275 +@pytest.mark.parametrize("inference", _EMPTY_IMAGE_SCOPE_KWARGS)
276 +def test_merge_mm_processor_kwargs_empty_scopes_are_absent(
277 + configured: dict[str, object],
278 + inference: dict[str, object],
279 +):
280 + config = MultiModalConfig(
281 + mm_processor_kwargs=configured,
282 + mm_device_do_normalize=False,
283 + )
284 +
285 + assert config.merge_mm_processor_kwargs(inference) == {}
286 +
287 +
288 +@pytest.mark.parametrize("empty_inference", _EMPTY_IMAGE_SCOPE_KWARGS)
289 +def test_merge_mm_processor_kwargs_empty_inference_scope_preserves_config_merge(
290 + empty_inference: dict[str, object],
291 +):
292 + config = MultiModalConfig(
293 + mm_processor_kwargs={
294 + "images_kwargs": {
295 + "size": {
296 + "shortest_edge": 64,
297 + "longest_edge": 512,
298 + },
299 + },
300 + },
301 + mm_device_do_normalize=False,
302 + )
303 + inference = {
304 + "size": {"shortest_edge": 128},
305 + **empty_inference,
306 + }
307 +
308 + assert config.merge_mm_processor_kwargs(inference) == {
309 + "size": {"shortest_edge": 128},
310 + "images_kwargs": {
311 + "size": {
312 + "shortest_edge": 128,
313 + "longest_edge": 512,
314 + },
315 + },
316 + }
317 +
318 +
319 +@pytest.mark.parametrize("empty_config", _EMPTY_IMAGE_SCOPE_KWARGS)
320 +def test_merge_mm_processor_kwargs_empty_config_scope_allows_inference_merge(
321 + empty_config: dict[str, object],
322 +):
323 + config = MultiModalConfig(
324 + mm_processor_kwargs={
325 + "size": {"longest_edge": 512},
326 + **empty_config,
327 + },
328 + mm_device_do_normalize=False,
329 + )
330 +
331 + assert config.merge_mm_processor_kwargs(
332 + {
333 + "size": {"shortest_edge": 128},
334 + "images_kwargs": {"size": {"longest_edge": 1024}},
335 + }
336 + ) == {
337 + "size": {
338 + "shortest_edge": 128,
339 + "longest_edge": 512,
340 + },
341 + "images_kwargs": {
342 + "size": {
343 + "shortest_edge": 128,
344 + "longest_edge": 1024,
345 + },
346 + },
347 + }
348 +
349 +
350 +@pytest.mark.parametrize("non_mapping_scope", [False, 0, "", [], 123])
351 +def test_merge_mm_processor_kwargs_falsey_non_mapping_scopes_are_not_empty(
352 + non_mapping_scope: object,
353 +):
354 + config = MultiModalConfig(
355 + mm_processor_kwargs={"images_kwargs": non_mapping_scope},
356 + mm_device_do_normalize=False,
357 + )
358 +
359 + for empty_inference in _EMPTY_IMAGE_SCOPE_KWARGS:
360 + assert config.merge_mm_processor_kwargs(empty_inference) == {
361 + "images_kwargs": non_mapping_scope
362 + }
363 +
364 +
365 +@pytest.mark.parametrize(
366 + ("configured_scope", "inference_scope", "expected_scope"),
367 + [
368 + (123, {"size": {"shortest_edge": 128}}, {"size": {"shortest_edge": 128}}),
369 + ({"size": {"shortest_edge": 64}}, 123, 123),
370 + (123, 456, 456),
371 + ],
372 +)
373 +def test_merge_mm_processor_kwargs_non_mapping_scope_precedence(
374 + configured_scope: object,
375 + inference_scope: object,
376 + expected_scope: object,
377 +):
378 + config = MultiModalConfig(
379 + mm_processor_kwargs={"images_kwargs": configured_scope},
380 + mm_device_do_normalize=False,
381 + )
382 +
383 + merged = config.merge_mm_processor_kwargs({"images_kwargs": inference_scope})
384 +
385 + assert merged["images_kwargs"] == expected_scope
386 +
387 +
388 +def test_merge_mm_processor_kwargs_none_leaf_is_explicit():
389 + config = MultiModalConfig(mm_device_do_normalize=False)
390 +
391 + assert config.merge_mm_processor_kwargs({"images_kwargs": {"do_resize": None}}) == {
392 + "images_kwargs": {"do_resize": None}
393 + }
394 +
395 +
396 +def test_merge_mm_processor_kwargs_normalize_and_ownership():
397 + configured: dict[str, object] = {
398 + "images_kwargs": {
399 + "size": {"shortest_edge": 64},
400 + },
401 + }
402 + inference: dict[str, object] = {
403 + "do_normalize": True,
404 + "do_rescale": True,
405 + "images_kwargs": {
406 + "size": {"longest_edge": 1024},
407 + },
408 + }
409 + configured_before = copy.deepcopy(configured)
410 + inference_before = copy.deepcopy(inference)
411 +
412 + config = MultiModalConfig(
413 + mm_processor_kwargs=configured,
414 + mm_device_do_normalize=True,
415 + )
416 + merged = config.merge_mm_processor_kwargs(inference)
417 +
418 + assert merged["do_normalize"] is False
419 + assert merged["do_rescale"] is False
420 + assert configured == configured_before
421 + assert inference == inference_before
422 + assert config.mm_processor_kwargs == configured_before
423 +
424 + images_kwargs = merged["images_kwargs"]
425 + assert isinstance(images_kwargs, dict)
426 + size = images_kwargs["size"]
427 + assert isinstance(size, dict)
428 + size["shortest_edge"] = 999
429 +
430 + assert configured == configured_before
431 + assert inference == inference_before
432 + assert config.mm_processor_kwargs == configured_before
433 +
434 +
435 def _validate_mm_processor_device(*, device: str, ec_role: ECRole | None) -> None:
436 ec_config = (
437 None
tests/models/multimodal/processing/test_gemma4.py
+9 -16
@@ -202,19 +202,19 @@ def test_get_prompt_updates_respects_nested_max_soft_tokens(model_id: str):
202
203
204 @pytest.mark.parametrize("model_id", [GEMMA4_MODEL_ID])
205 -@pytest.mark.parametrize("kwargs_on_init", [False, True])
205 @pytest.mark.parametrize(
206 "image_kwargs", [{"rescale_factor": 1 / 127.5}, {"max_soft_tokens": 560}]
207 )
208 @pytest.mark.parametrize("video_uuid", [None, "same-video"])
209 def test_video_cache_is_independent_of_image_kwargs(
210 model_id: str,
212 - kwargs_on_init: bool,
211 image_kwargs: dict[str, object],
212 video_uuid: str | None,
213 ):
216 - """Image overrides must not change video frames or depend on cache warmth."""
217 - kwargs = {"images_kwargs": image_kwargs}
214 + """Request image overrides must not change video frames or make results
215 + depend on cache warmth.
216 + """
217 + request_kwargs = {"images_kwargs": image_kwargs}
218 ctx = build_model_context(
219 model_id,
220 limit_mm_per_prompt={"image": 1, "video": 1},
@@ -239,18 +239,11 @@ def test_video_cache_is_independent_of_image_kwargs(
239 cache=cache,
240 )
241
242 - baseline = process({}, cache=None)
243 - if kwargs_on_init:
244 - ctx = build_model_context(
245 - model_id,
246 - mm_processor_kwargs=kwargs,
247 - limit_mm_per_prompt={"image": 1, "video": 1},
248 - mm_processor_cache_gb=1,
249 - )
250 - cache = MultiModalProcessorOnlyCache(ctx.model_config)
251 - processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
252 - request_kwargs = {} if kwargs_on_init else kwargs
253 - process(request_kwargs, cache=cache)
242 + # Populate the video cache without image overrides, then change only
243 + # images_kwargs on the next request. Since images_kwargs are excluded from
244 + # the video hash, and must not affect the processed video, the cached result
245 + # must match a fresh recomputation.
246 + baseline = process({}, cache=cache)
247 cached = process(request_kwargs, cache=cache)
248 cache.clear_cache()
249 fresh = process(request_kwargs, cache=cache)
tests/models/multimodal/processing/test_mistral3.py
+39
@@ -100,6 +100,11 @@ def _expected_placeholder_tokens_per_image(
100 [
101 ({}, (448, 448), 256),
102 ({"size": {"longest_edge": 1008}}, (1540, 1540), 1296),
103 + (
104 + {"images_kwargs": {"size": {"longest_edge": 1008}}},
105 + (1540, 1540),
106 + 1296,
107 + ),
108 ({"size": {"longest_edge": 1288}}, (1536, 1187), 1656),
109 ({"size": {"longest_edge": 1008}}, (29, 29), 4),
110 ({"size": {"longest_edge": 1000}}, (1540, 1700), 1188),
@@ -148,6 +153,40 @@ def test_processor_size_override(
153 assert prompt_update_tokens == hf_placeholder_tokens
154
155
156 +def test_scoped_request_size_overrides_configured_flat_size():
157 + ctx = build_model_context(
158 + _MODEL_ID,
159 + mm_processor_kwargs={"size": {"longest_edge": 1008}},
160 + limit_mm_per_prompt={"image": 1},
161 + model_config_kwargs=_MODEL_CONFIG_KWARGS,
162 + )
163 + processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
164 + hf_processor = AutoProcessor.from_pretrained(
165 + _MODEL_ID,
166 + fix_mistral_regex=True,
167 + )
168 +
169 + hf_processor_mm_kwargs: dict[str, object] = {
170 + "images_kwargs": {"size": {"longest_edge": 1288}}
171 + }
172 + image = Image.new("RGB", (1536, 1187), color=(127, 127, 127))
173 +
174 + merged_mm_kwargs = processor.info.ctx.get_merged_mm_kwargs(hf_processor_mm_kwargs)
175 + pixel_values, hf_placeholder_tokens = _process_images_with_hf(
176 + hf_processor, [image], merged_mm_kwargs
177 + )
178 + prompt_update_tokens = _placeholder_tokens_from_prompt_updates(
179 + processor, [image], pixel_values, hf_processor_mm_kwargs
180 + )
181 +
182 + expected_from_pixel_values = _expected_placeholder_tokens_per_image(
183 + hf_processor, pixel_values[0]
184 + )
185 + assert expected_from_pixel_values == 1656
186 + assert hf_placeholder_tokens.count(hf_processor.image_token_id) == 1656
187 + assert prompt_update_tokens == hf_placeholder_tokens
188 +
189 +
190 def test_lightonocr_keeps_vision_config_image_size():
191 ctx = build_model_context(
192 _LIGHTON_MODEL_ID,
tests/models/multimodal/processing/test_qwen2_vl.py
+224
@@ -1,6 +1,8 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 +from copy import deepcopy
5 +from types import SimpleNamespace
6 from typing import Any
7
8 import numpy as np
@@ -8,16 +10,238 @@ import pytest
10 import torch
11 from PIL import Image
12
13 +from vllm.config.multimodal import MultiModalConfig
14 from vllm.model_executor.layers.fusion.mm_input_norm import build_mm_input_norm
15 +from vllm.model_executor.models.qwen2_vl import Qwen2VLProcessingInfo
16 from vllm.multimodal import MULTIMODAL_REGISTRY
17 from vllm.multimodal.cache import MultiModalProcessorOnlyCache
18 from vllm.multimodal.inputs import batched_tensors_equal
19 +from vllm.multimodal.processing.context import InputProcessingContext
20 from vllm.platforms import current_platform
21
22 from ....conftest import ImageTestAssets
23 from ...utils import build_model_context
24
25
26 +@pytest.mark.parametrize(
27 + ("mm_kwargs", "expected"),
28 + [
29 + (
30 + {"size": {"shortest_edge": 64, "longest_edge": 1024}},
31 + {
32 + "size": {"shortest_edge": 64, "longest_edge": 1024},
33 + "min_pixels": 64,
34 + "max_pixels": 1024,
35 + },
36 + ),
37 + (
38 + {"min_pixels": 64, "max_pixels": 1024},
39 + {
40 + "min_pixels": 64,
41 + "max_pixels": 1024,
42 + "size": {"shortest_edge": 64, "longest_edge": 1024},
43 + },
44 + ),
45 + (
46 + {
47 + "size": {"shortest_edge": 64, "longest_edge": 1024},
48 + "min_pixels": 128,
49 + "max_pixels": 2048,
50 + },
51 + {
52 + "size": {"shortest_edge": 64, "longest_edge": 1024},
53 + "min_pixels": 128,
54 + "max_pixels": 2048,
55 + },
56 + ),
57 + (
58 + {
59 + "min_pixels": None,
60 + "size": {"shortest_edge": 0},
61 + "images_kwargs": {"max_pixels": 4096},
62 + "videos_kwargs": {"size": {"longest_edge": 8192}},
63 + },
64 + {
65 + "min_pixels": None,
66 + "size": {"shortest_edge": 0},
67 + "images_kwargs": {
68 + "max_pixels": 4096,
69 + "size": {"longest_edge": 4096},
70 + },
71 + "videos_kwargs": {
72 + "size": {"longest_edge": 8192},
73 + "max_pixels": 8192,
74 + },
75 + },
76 + ),
77 + (
78 + {"min_pixels": 0},
79 + {"min_pixels": 0, "size": {"shortest_edge": 0}},
80 + ),
81 + (
82 + {"size": None, "min_pixels": 64},
83 + {"size": {"shortest_edge": 64}, "min_pixels": 64},
84 + ),
85 + ],
86 +)
87 +def test_complete_mm_processor_size_aliases(
88 + mm_kwargs: dict[str, object],
89 + expected: dict[str, object],
90 +) -> None:
91 + original = deepcopy(mm_kwargs)
92 +
93 + actual = Qwen2VLProcessingInfo._complete_mm_processor_size_aliases(mm_kwargs)
94 +
95 + assert actual == expected
96 + assert mm_kwargs == original
97 +
98 +
99 +def test_merge_and_resolve_mm_processor_kwargs_preserves_request_alias_precedence(
100 + monkeypatch: pytest.MonkeyPatch,
101 +) -> None:
102 + mm_config = MultiModalConfig(
103 + mm_processor_kwargs={
104 + "images_kwargs": {"min_pixels": 64},
105 + },
106 + mm_device_do_normalize=False,
107 + )
108 + model_config = SimpleNamespace(get_multimodal_config=lambda: mm_config)
109 + ctx = InputProcessingContext(model_config, tokenizer=None) # type: ignore[arg-type]
110 + info = Qwen2VLProcessingInfo(ctx)
111 + monkeypatch.setattr(
112 + info,
113 + "get_supported_mm_processor_kwargs",
114 + lambda: {"images_kwargs": {"size", "min_pixels"}},
115 + )
116 +
117 + assert info._merge_and_resolve_mm_processor_kwargs(
118 + {"size": {"shortest_edge": 128}}
119 + ) == {
120 + "images_kwargs": {
121 + "size": {"shortest_edge": 128},
122 + "min_pixels": 128,
123 + },
124 + }
125 +
126 +
127 +@pytest.mark.parametrize(
128 + ("mm_kwargs", "default_size", "expected"),
129 + [
130 + (
131 + {},
132 + {"shortest_edge": 32, "longest_edge": 1024},
133 + {"shortest_edge": 32, "longest_edge": 1024},
134 + ),
135 + (
136 + {"size": {"shortest_edge": 64}},
137 + {"shortest_edge": 32, "longest_edge": 1024},
138 + {"shortest_edge": 64, "longest_edge": 1024},
139 + ),
140 + (
141 + {
142 + "size": {"shortest_edge": 64, "longest_edge": 2048},
143 + "min_pixels": 128,
144 + "max_pixels": 4096,
145 + },
146 + {"shortest_edge": 32, "longest_edge": 1024},
147 + {"shortest_edge": 128, "longest_edge": 4096},
148 + ),
149 + (
150 + {
151 + "size": {"shortest_edge": 64},
152 + "max_pixels": 4096,
153 + },
154 + None,
155 + {"shortest_edge": 64, "longest_edge": 4096},
156 + ),
157 + (
158 + {
159 + "size": {"shortest_edge": 64, "longest_edge": 2048},
160 + "min_pixels": None,
161 + "max_pixels": 0,
162 + },
163 + {"shortest_edge": 32, "longest_edge": 1024},
164 + {"shortest_edge": 64, "longest_edge": 0},
165 + ),
166 + ],
167 +)
168 +def test_get_vision_size(
169 + mm_kwargs: dict[str, object],
170 + default_size: dict[str, object] | None,
171 + expected: dict[str, object],
172 +) -> None:
173 + original_mm_kwargs = {
174 + key: dict(value) if isinstance(value, dict) else value
175 + for key, value in mm_kwargs.items()
176 + }
177 + original_default_size = dict(default_size) if default_size is not None else None
178 +
179 + actual = Qwen2VLProcessingInfo._get_vision_size(
180 + mm_kwargs,
181 + default_size=default_size,
182 + )
183 +
184 + assert actual == expected
185 + assert mm_kwargs == original_mm_kwargs
186 + assert default_size == original_default_size
187 +
188 +
189 +@pytest.mark.parametrize(
190 + ("modality", "scope"),
191 + [
192 + ("image", "images_kwargs"),
193 + ("video", "videos_kwargs"),
194 + ],
195 +)
196 +def test_get_vision_info_reads_merged_modality_scope(
197 + monkeypatch: pytest.MonkeyPatch,
198 + modality: str,
199 + scope: str,
200 +) -> None:
201 + vision_config = SimpleNamespace(
202 + patch_size=14,
203 + spatial_merge_size=2,
204 + temporal_patch_size=2,
205 + )
206 + ctx = SimpleNamespace(
207 + get_hf_config=lambda *_: SimpleNamespace(vision_config=vision_config)
208 + )
209 + info = Qwen2VLProcessingInfo(ctx)
210 + merged = {
211 + "images_kwargs": {"size": {"shortest_edge": 111}},
212 + "videos_kwargs": {"size": {"shortest_edge": 222}},
213 + }
214 + monkeypatch.setattr(
215 + info,
216 + "_merge_and_resolve_mm_processor_kwargs",
217 + lambda _: merged,
218 + )
219 +
220 + seen: list[dict[str, object]] = []
221 +
222 + def get_size(
223 + mm_kwargs: dict[str, object],
224 + default_size: dict[str, object] | None = None,
225 + ) -> dict[str, object]:
226 + seen.append(mm_kwargs)
227 + return dict(default_size or {})
228 +
229 + monkeypatch.setattr(info, "_get_vision_size", get_size)
230 + image_processor = SimpleNamespace(size={"shortest_edge": 32, "longest_edge": 1024})
231 +
232 + info._get_vision_info(
233 + image_width=28,
234 + image_height=28,
235 + num_frames=2,
236 + do_resize=False,
237 + image_processor=image_processor,
238 + mm_kwargs={},
239 + modality=modality, # type: ignore[arg-type]
240 + )
241 +
242 + assert seen == [merged[scope]]
243 +
244 +
245 def test_jina_vl_processing_order() -> None:
246 """Jina's document-first prompt keeps cached features and hashes aligned."""
247 ctx = build_model_context(
tests/models/multimodal/processing/test_qwen3_vl.py
+185 -16
@@ -1,19 +1,21 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -"""Regression tests for Qwen3-VL processor.
4 -
5 -Covers the fix for num_frames-based timestamp calculation
6 -(issue vllm-project/vllm#35909).
7 -"""
3 +"""Regression tests for Qwen3-VL multimodal processing."""
4
5 +from copy import deepcopy
6 +from types import SimpleNamespace
7 from typing import Any
8
9 import numpy as np
10 import pytest
11 +import torch
12 +from transformers.models.qwen3_vl import Qwen3VLVideoProcessor
13
14 from vllm.config import ModelConfig
15 from vllm.config.multimodal import MultiModalDummyOptions
16 +from vllm.model_executor.models.qwen3_vl import Qwen3VLProcessingInfo
17 from vllm.multimodal import MULTIMODAL_REGISTRY
18 +from vllm.multimodal.processing.context import BaseProcessingInfo
19
20 from ...registry import HF_EXAMPLE_MODELS
21 from ...utils import build_model_context
@@ -21,6 +23,32 @@ from ...utils import build_model_context
23 MODEL_ID = "Qwen/Qwen3-VL-4B-Instruct"
24
25
26 +def test_qwen3_video_routing_schema_adds_vllm_compatibility_kwargs(
27 + monkeypatch: pytest.MonkeyPatch,
28 +) -> None:
29 + native_video_kwargs = set(Qwen3VLVideoProcessor.valid_kwargs.__annotations__)
30 + assert "size" in native_video_kwargs
31 + assert "min_pixels" not in native_video_kwargs
32 + assert "max_pixels" not in native_video_kwargs
33 +
34 + info = Qwen3VLProcessingInfo(SimpleNamespace())
35 + parent_supported = {
36 + "text_kwargs": {"padding"},
37 + "images_kwargs": {"size", "min_pixels", "max_pixels"},
38 + "videos_kwargs": set(native_video_kwargs),
39 + }
40 + monkeypatch.setattr(
41 + BaseProcessingInfo,
42 + "get_supported_mm_processor_kwargs",
43 + lambda self: {scope: set(kwargs) for scope, kwargs in parent_supported.items()},
44 + )
45 +
46 + supported = info.supported_mm_processor_kwargs
47 +
48 + assert {"min_pixels", "max_pixels"} <= supported["videos_kwargs"]
49 + assert parent_supported["videos_kwargs"] == native_video_kwargs
50 +
51 +
52 def _build_video_mm_data(
53 num_frames: int,
54 width: int = 128,
@@ -45,6 +73,122 @@ def _build_video_mm_data(
73 return {"video": [(video, metadata)]}
74
75
76 +@pytest.mark.parametrize("model_id", [MODEL_ID])
77 +def test_processor_partial_scoped_image_size(model_id: str) -> None:
78 + """Partial image ``size`` must be completed and applied before the HF call.
79 +
80 + Keep video compatibility and per-video kwargs in an image-only call as well:
81 + Qwen3 must sanitize the video scope before HF validates it. Regression for
82 + #56363.
83 + """
84 + ctx = build_model_context(
85 + model_id,
86 + limit_mm_per_prompt={"image": 1, "video": 0},
87 + )
88 + processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
89 +
90 + image = np.zeros((128, 128, 3), dtype=np.uint8)
91 + min_pixels = 256 * 32 * 32
92 + default_size = dict(processor.info.get_image_processor().size)
93 + complete_size = default_size | {"shortest_edge": min_pixels}
94 +
95 + def process(hf_mm_kwargs: dict[str, Any]) -> torch.Tensor:
96 + mm_items = processor.info.parse_mm_data({"image": [image]})
97 + return processor._apply_hf_processor_main(mm_items, hf_mm_kwargs)[
98 + "image_grid_thw"
99 + ]
100 +
101 + stock_grid = process({})
102 + complete_grid = process({"images_kwargs": {"size": complete_size}})
103 + partial_grid = process(
104 + {
105 + "images_kwargs": {
106 + "size": {"shortest_edge": min_pixels},
107 + },
108 + "videos_kwargs": {
109 + "min_pixels": 4096,
110 + "max_pixels": 469_762_048,
111 + "fps": [2.0],
112 + "num_frames": [8],
113 + },
114 + }
115 + )
116 +
117 + assert torch.equal(partial_grid, complete_grid)
118 + assert not torch.equal(partial_grid, stock_grid)
119 +
120 +
121 +@pytest.mark.parametrize("model_id", [MODEL_ID])
122 +def test_processor_partial_scoped_video_size(model_id: str) -> None:
123 + """Partial scoped video ``size`` must be completed and applied before HF."""
124 + ctx = build_model_context(
125 + model_id,
126 + limit_mm_per_prompt={"image": 0, "video": 1},
127 + )
128 + processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
129 +
130 + min_pixels = 512 * 32 * 32
131 + default_size = dict(processor.info.get_video_processor().size)
132 + complete_size = default_size | {"shortest_edge": min_pixels}
133 +
134 + def process(hf_mm_kwargs: dict[str, Any]) -> torch.Tensor:
135 + mm_items = processor.info.parse_mm_data(_build_video_mm_data(num_frames=8))
136 + return processor._apply_hf_processor_main(mm_items, hf_mm_kwargs)[
137 + "video_grid_thw"
138 + ]
139 +
140 + stock_grid = process({})
141 + complete_grid = process({"videos_kwargs": {"size": complete_size}})
142 + partial_grid = process(
143 + {
144 + "videos_kwargs": {
145 + "size": {"shortest_edge": min_pixels},
146 + },
147 + }
148 + )
149 +
150 + assert torch.equal(partial_grid, complete_grid)
151 + assert not torch.equal(partial_grid, stock_grid)
152 +
153 +
154 +@pytest.mark.parametrize("model_id", [MODEL_ID])
155 +def test_processor_flat_video_pixel_aliases_match_scoped_size(model_id: str) -> None:
156 + """Flat min/max pixel aliases keep their historical Qwen3 video effect."""
157 + ctx = build_model_context(
158 + model_id,
159 + limit_mm_per_prompt={"image": 0, "video": 1},
160 + )
161 + processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
162 + pixel_budget = 512 * 32 * 32
163 +
164 + def process(hf_mm_kwargs: dict[str, Any]) -> torch.Tensor:
165 + mm_items = processor.info.parse_mm_data(_build_video_mm_data(num_frames=8))
166 + return processor._apply_hf_processor_main(mm_items, hf_mm_kwargs)[
167 + "video_grid_thw"
168 + ]
169 +
170 + stock_grid = process({})
171 + scoped_grid = process(
172 + {
173 + "videos_kwargs": {
174 + "size": {
175 + "shortest_edge": pixel_budget,
176 + "longest_edge": pixel_budget,
177 + },
178 + },
179 + }
180 + )
181 + flat_grid = process(
182 + {
183 + "min_pixels": pixel_budget,
184 + "max_pixels": pixel_budget,
185 + }
186 + )
187 +
188 + assert torch.equal(flat_grid, scoped_grid)
189 + assert not torch.equal(flat_grid, stock_grid)
190 +
191 +
192 @pytest.mark.parametrize("model_id", [MODEL_ID])
193 @pytest.mark.parametrize(
194 "num_frames",
@@ -189,20 +333,21 @@ def test_processor_kwargs_videos_kwargs_does_not_leak_into_image_budget(
333
334 @pytest.mark.parametrize("model_id", [MODEL_ID])
335 @pytest.mark.parametrize(
192 - "hf_mm_kwargs",
193 - [{"num_frames": [8, 16]}, {"fps": [2.0, 4.0]}],
336 + ("hf_mm_kwargs", "scalar_kwargs"),
337 + [
338 + ({"num_frames": [8, 16]}, [{"num_frames": 8}, {"num_frames": 16}]),
339 + ({"fps": [4.0, 12.0]}, [{"fps": 4.0}, {"fps": 12.0}]),
340 + ],
341 )
342 def test_processor_multi_video_list_kwargs(
343 model_id: str,
344 hf_mm_kwargs: dict[str, Any],
345 + scalar_kwargs: list[dict[str, Any]],
346 ) -> None:
199 - """Regression test: a multi-video request with list-valued per-video
200 - ``mm_processor_kwargs`` (one ``fps``/``num_frames`` per video) must not
201 - crash.
347 + """List-valued video kwargs must be scalarized per item without mutation.
348
203 - Before the fix, ``_apply_hf_processor_main`` copied the whole kwargs to every
204 - video without slicing, so ``_get_video_second_idx`` received the list
205 - where a scalar was expected and raised ``TypeError``.
349 + Each video in the multi-video request must match processing the same input
350 + independently with its corresponding scalar ``fps``/``num_frames`` value.
351 """
352 ctx = build_model_context(
353 model_id,
@@ -216,17 +361,36 @@ def test_processor_multi_video_list_kwargs(
361 )
362 mm_data = {
363 "video": [
219 - _build_video_mm_data(num_frames=16)["video"][0],
364 + _build_video_mm_data(num_frames=32)["video"][0],
365 _build_video_mm_data(num_frames=32)["video"][0],
366 ]
367 }
368
369 + hf_mm_kwargs_before = deepcopy(hf_mm_kwargs)
370 processed = processor(
371 prompt,
372 mm_items=processor.info.parse_mm_data(mm_data),
373 hf_processor_mm_kwargs=hf_mm_kwargs,
374 )
375
376 + def process_single(hf_kwargs: dict[str, Any]) -> torch.Tensor:
377 + single_prompt = "<|vision_start|><|video_pad|><|vision_end|>"
378 + single_mm_data = _build_video_mm_data(num_frames=32)
379 + single = processor(
380 + single_prompt,
381 + mm_items=processor.info.parse_mm_data(single_mm_data),
382 + hf_processor_mm_kwargs=hf_kwargs,
383 + )
384 + return single["mm_kwargs"].get_data()["video_grid_thw"][0]
385 +
386 + expected_grids = [process_single(kwargs) for kwargs in scalar_kwargs]
387 + multi_grids = processed["mm_kwargs"].get_data()["video_grid_thw"]
388 +
389 + assert not torch.equal(expected_grids[0], expected_grids[1])
390 + assert torch.equal(multi_grids[0], expected_grids[0])
391 + assert torch.equal(multi_grids[1], expected_grids[1])
392 + assert hf_mm_kwargs == hf_mm_kwargs_before
393 +
394 video_phs = processed["mm_placeholders"].get("video", [])
395 assert len(video_phs) == 2, (
396 f"Expected exactly 2 video placeholders, got {len(video_phs)}"
@@ -336,7 +500,12 @@ def test_dummy_video_spreads_budget_when_frame_cap_enabled(model_id: str) -> Non
500
501 capped_ctx = build_model_context(
502 model_id,
339 - mm_processor_kwargs={"size": size, "cap_pixels_per_frame": True},
503 + mm_processor_kwargs={
504 + "videos_kwargs": {
505 + "size": size,
506 + "cap_pixels_per_frame": True,
507 + },
508 + },
509 limit_mm_per_prompt={"image": 0, "video": 1},
510 )
511 capped = MULTIMODAL_REGISTRY.create_processor(capped_ctx.model_config)
@@ -350,7 +519,7 @@ def test_dummy_video_spreads_budget_when_frame_cap_enabled(model_id: str) -> Non
519
520 uncapped_ctx = build_model_context(
521 model_id,
353 - mm_processor_kwargs={"size": size},
522 + mm_processor_kwargs={"videos_kwargs": {"size": size}},
523 limit_mm_per_prompt={"image": 0, "video": 1},
524 )
525 uncapped = MULTIMODAL_REGISTRY.create_processor(uncapped_ctx.model_config)
tests/multimodal/test_processing.py
+291 -39
@@ -3,18 +3,23 @@
3
4 import time
5 from contextlib import nullcontext
6 +from copy import deepcopy
7 +from types import SimpleNamespace
8 +from typing import TypedDict
9
10 import numpy as np
11 import pytest
12
13 from vllm.config import ModelConfig, SchedulerConfig
14 +from vllm.config.multimodal import MultiModalConfig
15 from vllm.exceptions import VLLMValidationError
16 from vllm.multimodal import MULTIMODAL_REGISTRY
17 from vllm.multimodal.hasher import MultiModalHasher
18 from vllm.multimodal.parse import MultiModalDataParser
19 from vllm.multimodal.processing.context import (
20 + BaseProcessingInfo,
21 InputProcessingContext,
17 - overlay_modality_mm_kwargs,
22 + _resolve_mm_processor_kwargs,
23 )
24 from vllm.multimodal.processing.inputs import ProcessorInputs
25 from vllm.multimodal.processing.processor import (
@@ -1207,58 +1212,305 @@ def test_apply_prompt_updates_falls_back_with_index_targets():
1212 assert [p.tokens for p in placeholders["image"]] == [[200, 201], [9]]
1213
1214
1210 -@pytest.mark.skip_global_cleanup
1211 -def test_overlay_modality_mm_kwargs_scoped_video_does_not_leak_to_image():
1212 - """HF-style videos_kwargs must overlay only when modality is video."""
1213 - video_size = {"longest_edge": 469762048, "shortest_edge": 4096}
1214 - kwargs = {"videos_kwargs": {"size": video_size}}
1215 +class _TextProcessorKwargs(TypedDict, total=False):
1216 + padding: bool
1217
1216 - assert overlay_modality_mm_kwargs(kwargs, None) == kwargs
1217 - assert "size" not in overlay_modality_mm_kwargs(kwargs, "image")
1218 - assert overlay_modality_mm_kwargs(kwargs, "video")["size"] == video_size
1218
1219 +class _AudioProcessorKwargs(TypedDict, total=False):
1220 + sampling_rate: int
1221
1221 -@pytest.mark.skip_global_cleanup
1222 -def test_overlay_modality_mm_kwargs_flat_size_stays_shared():
1223 - """A flat size override keeps the current shared-namespace behavior."""
1224 - size = {"longest_edge": 469762048, "shortest_edge": 4096}
1225 - kwargs = {"size": size}
1222
1227 - for modality in (None, "image", "video"):
1228 - assert overlay_modality_mm_kwargs(kwargs, modality)["size"] == size
1223 +class _ProcessorKwargs(TypedDict, total=False):
1224 + text_kwargs: _TextProcessorKwargs
1225 + audio_kwargs: _AudioProcessorKwargs
1226
1227
1231 -@pytest.mark.skip_global_cleanup
1232 -def test_overlay_modality_mm_kwargs_scoped_wins_over_flat_for_modality():
1233 - """A nested videos_kwargs size wins over a flat size for video reads."""
1228 +class _ImageProcessorKwargs(TypedDict, total=False):
1229 + size: dict[str, int]
1230 + min_pixels: int
1231 +
1232 +
1233 +class _VideoProcessorKwargs(TypedDict, total=False):
1234 + size: dict[str, int]
1235 + fps: float
1236 +
1237 +
1238 +@pytest.mark.parametrize(
1239 + ("supported_mm_limits", "processor", "expected"),
1240 + [
1241 + (
1242 + {"image": None, "video": None},
1243 + SimpleNamespace(
1244 + valid_processor_kwargs=_ProcessorKwargs,
1245 + image_processor=SimpleNamespace(valid_kwargs=_ImageProcessorKwargs),
1246 + video_processor=SimpleNamespace(valid_kwargs=_VideoProcessorKwargs),
1247 + ),
1248 + {
1249 + "text_kwargs": {"padding"},
1250 + "images_kwargs": {"size", "min_pixels"},
1251 + "videos_kwargs": {"size", "fps"},
1252 + },
1253 + ),
1254 + (
1255 + {"image": None},
1256 + SimpleNamespace(
1257 + valid_processor_kwargs=_ProcessorKwargs,
1258 + image_processor=SimpleNamespace(valid_kwargs=_ImageProcessorKwargs),
1259 + ),
1260 + {
1261 + "text_kwargs": {"padding"},
1262 + "images_kwargs": {"size", "min_pixels"},
1263 + },
1264 + ),
1265 + (
1266 + {"audio": None},
1267 + SimpleNamespace(valid_processor_kwargs=_ProcessorKwargs),
1268 + {
1269 + "text_kwargs": {"padding"},
1270 + "audio_kwargs": {"sampling_rate"},
1271 + },
1272 + ),
1273 + ],
1274 +)
1275 +def test_get_supported_mm_processor_kwargs_uses_supported_modalities(
1276 + monkeypatch: pytest.MonkeyPatch,
1277 + supported_mm_limits: dict[str, int | None],
1278 + processor: SimpleNamespace,
1279 + expected: dict[str, set[str]],
1280 +) -> None:
1281 + info = BaseProcessingInfo(SimpleNamespace())
1282 + info.__dict__["supported_mm_limits"] = supported_mm_limits
1283 + monkeypatch.setattr(info, "get_hf_processor", lambda **_: processor)
1284 +
1285 + assert info.get_supported_mm_processor_kwargs() == expected
1286 +
1287 +
1288 +def test_supported_mm_processor_kwargs_is_cached(
1289 + monkeypatch: pytest.MonkeyPatch,
1290 +) -> None:
1291 + info = BaseProcessingInfo(SimpleNamespace())
1292 + expected = {"images_kwargs": {"size"}}
1293 + calls = 0
1294 +
1295 + def get_supported_mm_processor_kwargs() -> dict[str, set[str]]:
1296 + nonlocal calls
1297 + calls += 1
1298 + return expected
1299 +
1300 + monkeypatch.setattr(
1301 + info,
1302 + "get_supported_mm_processor_kwargs",
1303 + get_supported_mm_processor_kwargs,
1304 + )
1305 +
1306 + first = info.supported_mm_processor_kwargs
1307 + second = info.supported_mm_processor_kwargs
1308 +
1309 + assert first is expected
1310 + assert second is first
1311 + assert calls == 1
1312 +
1313 +
1314 +def test_resolve_mm_processor_kwargs_routes_supported_flat_kwargs():
1315 + kwargs = {
1316 + "size": {"shortest_edge": 64},
1317 + "padding": True,
1318 + "sampling_rate": 16000,
1319 + "unknown": "keep",
1320 + "images_kwargs": {
1321 + "size": {"longest_edge": 1024},
1322 + "custom_image_kwarg": "keep",
1323 + },
1324 + }
1325 + kwargs_before = deepcopy(kwargs)
1326 +
1327 + resolved = _resolve_mm_processor_kwargs(
1328 + kwargs,
1329 + supported_mm_processor_kwargs={
1330 + "text_kwargs": {"padding"},
1331 + "images_kwargs": {"size"},
1332 + "videos_kwargs": {"size"},
1333 + "audio_kwargs": {"sampling_rate"},
1334 + },
1335 + )
1336 +
1337 + assert resolved == {
1338 + "unknown": "keep",
1339 + "text_kwargs": {"padding": True},
1340 + "images_kwargs": {
1341 + "size": {
1342 + "shortest_edge": 64,
1343 + "longest_edge": 1024,
1344 + },
1345 + "custom_image_kwarg": "keep",
1346 + },
1347 + "videos_kwargs": {
1348 + "size": {"shortest_edge": 64},
1349 + },
1350 + "audio_kwargs": {"sampling_rate": 16000},
1351 + }
1352 + assert kwargs == kwargs_before
1353 +
1354 + video_kwargs = resolved["videos_kwargs"]
1355 + image_kwargs = resolved["images_kwargs"]
1356 + assert isinstance(video_kwargs, dict)
1357 + assert isinstance(image_kwargs, dict)
1358 + video_size = video_kwargs["size"]
1359 + image_size = image_kwargs["size"]
1360 + assert isinstance(video_size, dict)
1361 + assert isinstance(image_size, dict)
1362 + video_size["shortest_edge"] = 999
1363 +
1364 + assert image_size["shortest_edge"] == 64
1365 + assert kwargs == kwargs_before
1366 +
1367 +
1368 +def test_resolve_mm_processor_kwargs_rejects_non_mapping_destination_scope():
1369 + with pytest.raises(TypeError, match="images_kwargs"):
1370 + _resolve_mm_processor_kwargs(
1371 + {
1372 + "size": {"shortest_edge": 64},
1373 + "images_kwargs": 123,
1374 + },
1375 + supported_mm_processor_kwargs={
1376 + "images_kwargs": {"size"},
1377 + },
1378 + )
1379 +
1380 +
1381 +def test_resolve_mm_processor_kwargs_without_schema_deduplicates_existing_scopes():
1382 kwargs = {
1235 - "size": {"longest_edge": 1},
1236 - "videos_kwargs": {"size": {"longest_edge": 2}},
1237 - "images_kwargs": {"size": {"longest_edge": 3}},
1383 + "size": {"shortest_edge": 64},
1384 + "num_frames": 16,
1385 + "fps": 4,
1386 + "images_kwargs": {
1387 + "size": {"longest_edge": 1024},
1388 + },
1389 + "videos_kwargs": {
1390 + "num_frames": 8,
1391 + },
1392 + "audio_kwargs": 123,
1393 + "common_kwargs": {
1394 + "fps": 2,
1395 + },
1396 + }
1397 + kwargs_before = deepcopy(kwargs)
1398 +
1399 + resolved = _resolve_mm_processor_kwargs(kwargs)
1400 +
1401 + assert resolved == {
1402 + "fps": 4,
1403 + "images_kwargs": {
1404 + "size": {"longest_edge": 1024},
1405 + },
1406 + "videos_kwargs": {
1407 + "num_frames": 8,
1408 + },
1409 + "audio_kwargs": 123,
1410 + "common_kwargs": {
1411 + "fps": 2,
1412 + },
1413 }
1414 + assert kwargs == kwargs_before
1415 + assert "text_kwargs" not in resolved
1416 +
1417
1240 - assert overlay_modality_mm_kwargs(kwargs, "video")["size"] == {"longest_edge": 2}
1241 - assert overlay_modality_mm_kwargs(kwargs, "image")["size"] == {"longest_edge": 3}
1242 - assert overlay_modality_mm_kwargs(kwargs, None)["size"] == {"longest_edge": 1}
1418 +@pytest.mark.parametrize(
1419 + "inference_kwargs",
1420 + [
1421 + {"size": {"shortest_edge": 128}},
1422 + {"size": {"shortest_edge": 128}, "images_kwargs": None},
1423 + {"size": {"shortest_edge": 128}, "images_kwargs": {}},
1424 + ],
1425 +)
1426 +def test_get_merged_mm_kwargs_treats_empty_scopes_as_absent_before_routing(
1427 + inference_kwargs: dict[str, object],
1428 +):
1429 + mm_config = MultiModalConfig(
1430 + mm_processor_kwargs={
1431 + "size": {
1432 + "shortest_edge": 64,
1433 + "longest_edge": 512,
1434 + },
1435 + "images_kwargs": {
1436 + "size": {"longest_edge": 1024},
1437 + },
1438 + },
1439 + mm_device_do_normalize=False,
1440 + )
1441 + model_config = SimpleNamespace(get_multimodal_config=lambda: mm_config)
1442 + ctx = InputProcessingContext(model_config, tokenizer=None) # type: ignore[arg-type]
1443 +
1444 + merged = ctx.get_merged_mm_kwargs(
1445 + inference_kwargs,
1446 + supported_mm_processor_kwargs={
1447 + "images_kwargs": {"size"},
1448 + "videos_kwargs": {"size"},
1449 + },
1450 + )
1451
1452 + assert merged == {
1453 + "images_kwargs": {
1454 + "size": {
1455 + "shortest_edge": 128,
1456 + "longest_edge": 1024,
1457 + },
1458 + },
1459 + "videos_kwargs": {
1460 + "size": {
1461 + "shortest_edge": 128,
1462 + "longest_edge": 512,
1463 + },
1464 + },
1465 + }
1466
1245 -@pytest.mark.skip_global_cleanup
1246 -def test_overlay_modality_mm_kwargs_ignores_non_mapping_scoped_value():
1247 - kwargs = {"images_kwargs": "not-a-dict", "size": {"longest_edge": 1}}
1248 - assert overlay_modality_mm_kwargs(kwargs, "image")["size"] == {"longest_edge": 1}
1467
1468 +def test_get_merged_mm_kwargs_merges_before_routing():
1469 + mm_config = MultiModalConfig(
1470 + mm_processor_kwargs={
1471 + "size": {
1472 + "shortest_edge": 64,
1473 + "longest_edge": 512,
1474 + },
1475 + "images_kwargs": {
1476 + "size": {"longest_edge": 1024},
1477 + },
1478 + },
1479 + mm_device_do_normalize=False,
1480 + )
1481 + model_config = SimpleNamespace(get_multimodal_config=lambda: mm_config)
1482 + ctx = InputProcessingContext(model_config, tokenizer=None) # type: ignore[arg-type]
1483
1251 -@pytest.mark.skip_global_cleanup
1252 -def test_mm_processor_kwargs_merge_then_overlay_preserves_scoping():
1253 - """Configured videos_kwargs overlay only for video reads after merge."""
1254 - from vllm.config.multimodal import MultiModalConfig
1484 + merged = ctx.get_merged_mm_kwargs(
1485 + {
1486 + "size": {
1487 + "shortest_edge": 128,
1488 + "longest_edge": 768,
1489 + },
1490 + "images_kwargs": {
1491 + "size": {"longest_edge": 2048},
1492 + },
1493 + },
1494 + supported_mm_processor_kwargs={
1495 + "images_kwargs": {"size"},
1496 + "videos_kwargs": {"size"},
1497 + },
1498 + )
1499
1256 - size = {"longest_edge": 469762048, "shortest_edge": 4096}
1257 - mm_config = MultiModalConfig(mm_processor_kwargs={"videos_kwargs": {"size": size}})
1258 - merged = mm_config.merge_mm_processor_kwargs({})
1259 - assert overlay_modality_mm_kwargs(merged, "video")["size"] == size
1260 - assert "size" not in overlay_modality_mm_kwargs(merged, "image")
1261 - assert "size" not in overlay_modality_mm_kwargs(merged, None)
1500 + assert merged == {
1501 + "images_kwargs": {
1502 + "size": {
1503 + "shortest_edge": 128,
1504 + "longest_edge": 2048,
1505 + },
1506 + },
1507 + "videos_kwargs": {
1508 + "size": {
1509 + "shortest_edge": 128,
1510 + "longest_edge": 768,
1511 + },
1512 + },
1513 + }
1514
1515
1516 def test_processor_inputs_hashes_partial_uuids():
vllm/config/multimodal.py
+187 -1
@@ -27,6 +27,186 @@ logger = init_logger(__name__)
27
28 _T = TypeVar("_T")
29
30 +# HuggingFace processors accept a shared flat namespace in addition to the
31 +# nested processor kwarg scopes ``text_kwargs`` / ``images_kwargs`` /
32 +# ``videos_kwargs`` / ``audio_kwargs``. vLLM recognizes these scopes when
33 +# resolving ``mm_processor_kwargs``.
34 +_HF_PROCESSOR_KWARG_SCOPES = (
35 + "text_kwargs",
36 + "images_kwargs",
37 + "videos_kwargs",
38 + "audio_kwargs",
39 +)
40 +
41 +
42 +def _recursively_merge_mm_processor_kwargs(
43 + defaults: Mapping[str, object],
44 + overrides: Mapping[str, object],
45 +) -> dict[str, object]:
46 + """Merge two mappings recursively, with ``overrides`` taking precedence.
47 +
48 + Nested values are merged only when both values are mappings; otherwise the
49 + override replaces the existing value.
50 + """
51 + merged = dict(defaults)
52 +
53 + for key, value in overrides.items():
54 + default_value = merged.get(key)
55 + if isinstance(default_value, Mapping) and isinstance(value, Mapping):
56 + merged[key] = _recursively_merge_mm_processor_kwargs(default_value, value)
57 + else:
58 + merged[key] = value
59 +
60 + return merged
61 +
62 +
63 +def _merge_mm_processor_scope(
64 + config_flat: Mapping[str, object],
65 + config_scoped: Mapping[str, object],
66 + inference_flat: Mapping[str, object],
67 + inference_scoped: Mapping[str, object],
68 +) -> dict[str, object]:
69 + """Merge kwargs for one existing HuggingFace processor kwarg scope.
70 +
71 + Flat kwargs contribute only to keys present in either the configured or
72 + inference-time scope. For those keys, precedence is:
73 + configured flat < configured scoped < inference flat < inference scoped.
74 +
75 + Nested mappings are merged recursively; otherwise the higher-priority value
76 + replaces the lower-priority value.
77 + """
78 + # Flat kwargs contribute only to keys present in this scope in either input.
79 + scoped_keys = config_scoped.keys() | inference_scoped.keys()
80 + config_flat_for_scope = {
81 + key: value for key, value in config_flat.items() if key in scoped_keys
82 + }
83 + inference_flat_for_scope = {
84 + key: value for key, value in inference_flat.items() if key in scoped_keys
85 + }
86 +
87 + # Merge from lowest to highest priority:
88 + # configured flat -> configured scoped -> inference flat -> inference scoped.
89 + merged = _recursively_merge_mm_processor_kwargs(
90 + config_flat_for_scope, config_scoped
91 + )
92 + merged = _recursively_merge_mm_processor_kwargs(merged, inference_flat_for_scope)
93 + merged = _recursively_merge_mm_processor_kwargs(merged, inference_scoped)
94 + return merged
95 +
96 +
97 +def _merge_mm_processor_kwargs(
98 + config_kwargs: Mapping[str, object],
99 + inference_kwargs: Mapping[str, object],
100 +) -> dict[str, object]:
101 + """Merge configured and inference-time multi-modal processor kwargs.
102 +
103 + The merge is performed in two parts:
104 +
105 + 1. The shared flat kwargs are merged recursively, with inference-time values
106 + taking precedence.
107 + 2. Each existing HuggingFace processor kwarg scope is merged separately.
108 + For keys present in either the configured or inference-time scope,
109 + precedence is:
110 + configured flat < configured scoped < inference flat < inference scoped.
111 +
112 + For example:
113 +
114 + configured = {
115 + "size": {"shortest_edge": 256},
116 + "videos_kwargs": {
117 + "size": {"longest_edge": 1024},
118 + "num_frames": 16,
119 + },
120 + }
121 + inference = {
122 + "size": {"shortest_edge": 512},
123 + "videos_kwargs": {"fps": 4},
124 + }
125 +
126 + produces:
127 +
128 + {
129 + "size": {"shortest_edge": 512},
130 + "videos_kwargs": {
131 + "size": {
132 + "shortest_edge": 512,
133 + "longest_edge": 1024,
134 + },
135 + "num_frames": 16,
136 + "fps": 4,
137 + },
138 + }
139 +
140 + Scoped values do not modify the shared flat kwargs or sibling scopes.
141 + Missing scopes, None-valued scopes, and empty scope mappings are treated as
142 + absent and do not create a scope.
143 + """
144 + # Merge the shared flat kwargs first; nested scopes are handled separately
145 + # below.
146 + config_flat = {
147 + key: value
148 + for key, value in config_kwargs.items()
149 + if key not in _HF_PROCESSOR_KWARG_SCOPES
150 + }
151 + inference_flat = {
152 + key: value
153 + for key, value in inference_kwargs.items()
154 + if key not in _HF_PROCESSOR_KWARG_SCOPES
155 + }
156 + merged = _recursively_merge_mm_processor_kwargs(config_flat, inference_flat)
157 +
158 + # Merge each nested processor kwarg scope present in either input.
159 + for scoped_key in _HF_PROCESSOR_KWARG_SCOPES:
160 + config_scope_value = config_kwargs.get(scoped_key)
161 + inference_scope_value = inference_kwargs.get(scoped_key)
162 +
163 + # Treat missing scopes, None-valued scopes, and empty scope mappings as
164 + # absent.
165 + if isinstance(config_scope_value, Mapping) and not config_scope_value:
166 + config_scope_value = None
167 + if isinstance(inference_scope_value, Mapping) and not inference_scope_value:
168 + inference_scope_value = None
169 +
170 + # Skip adding the scope to the merged result when it is absent from both
171 + # sources.
172 + if config_scope_value is None and inference_scope_value is None:
173 + continue
174 +
175 + # Handle the inference scope first because it has higher precedence; only
176 + # mapping-valued scopes participate in the recursive scoped merge.
177 + inference_scope_kwargs: Mapping[str, object]
178 + if inference_scope_value is None:
179 + inference_scope_kwargs = {}
180 + elif not isinstance(inference_scope_value, Mapping):
181 + # Non-mapping scopes cannot participate in the recursive scoped merge;
182 + # the higher-priority inference value replaces the configured scope.
183 + merged[scoped_key] = inference_scope_value
184 + continue
185 + else:
186 + inference_scope_kwargs = inference_scope_value
187 +
188 + # Handle the configured scope next because it has lower precedence; only
189 + # mapping-valued scopes participate in the recursive scoped merge.
190 + if isinstance(config_scope_value, Mapping):
191 + config_scope_kwargs = config_scope_value
192 + elif inference_scope_value is None:
193 + # Non-mapping scopes cannot participate in the recursive scoped merge;
194 + # preserve the configured value when inference contributes nothing.
195 + merged[scoped_key] = config_scope_value
196 + continue
197 + else:
198 + config_scope_kwargs = {}
199 +
200 + # Merge this scope together with the matching flat kwargs.
201 + merged[scoped_key] = _merge_mm_processor_scope(
202 + config_flat,
203 + config_scope_kwargs,
204 + inference_flat,
205 + inference_scope_kwargs,
206 + )
207 +
208 + return merged
209 +
210
211 @dataclass
212 class BaseDummyOptions:
@@ -533,8 +713,14 @@ class MultiModalConfig:
713 ) -> dict[str, object]:
714 """Get the keyword arguments to pass to the multi-modal processor
715 according to the extra arguments passed during inference.
716 +
717 + Nested mappings are merged recursively, with inference-time values taking
718 + precedence over configured values.
719 """
537 - merged = dict(self.mm_processor_kwargs or {}) | dict(inference_kwargs)
720 + merged = _merge_mm_processor_kwargs(
721 + self.mm_processor_kwargs or {},
722 + inference_kwargs,
723 + )
724 if self.mm_device_do_normalize:
725 # The model expects raw pixels and normalises on device;
726 # overriding these per request would silently double-normalise.
vllm/model_executor/models/mistral3.py
+9 -3
@@ -3,7 +3,7 @@
3
4 import math
5 from collections.abc import Iterable, Mapping, Sequence
6 -from typing import Annotated, Literal
6 +from typing import Annotated, Any, Literal, cast
7
8 import torch
9 import torch.nn as nn
@@ -212,8 +212,14 @@ class Mistral3ProcessingInfo(BaseProcessingInfo):
212 ) -> PixtralHFEncoderInfo:
213 processor = self.get_hf_processor()
214 size = processor.image_processor.size
215 - merged_kwargs = self.ctx.get_merged_mm_kwargs(mm_processor_kwargs or {})
216 - if override_size := merged_kwargs.get("size"):
215 + merged_mm_kwargs = self._merge_and_resolve_mm_processor_kwargs(
216 + mm_processor_kwargs or {}
217 + )
218 + image_mm_kwargs = cast(
219 + Mapping[str, Any],
220 + merged_mm_kwargs.get("images_kwargs", {}),
221 + )
222 + if override_size := image_mm_kwargs.get("size"):
223 size = size | override_size
224
225 image_size = size["longest_edge"]
vllm/model_executor/models/qwen2_vl.py
+130 -18
@@ -28,7 +28,7 @@
28 import math
29 from collections.abc import Callable, Hashable, Iterable, Iterator, Mapping, Sequence
30 from functools import partial
31 -from typing import Annotated, Any, Literal, TypeAlias
31 +from typing import Annotated, Any, Literal, TypeAlias, cast
32
33 import numpy as np
34 import torch
@@ -865,6 +865,118 @@ class Qwen2VLProcessingInfo(BaseProcessingInfo):
865 def get_supported_mm_limits(self) -> Mapping[str, int | None]:
866 return {"image": None, "video": None}
867
868 + @staticmethod
869 + def _complete_mm_processor_size_aliases(
870 + mm_kwargs: Mapping[str, object],
871 + ) -> dict[str, object]:
872 + """Complete missing Qwen-VL size aliases.
873 +
874 + Qwen-VL accepts ``size.shortest_edge`` / ``min_pixels`` and
875 + ``size.longest_edge`` / ``max_pixels`` as equivalent controls. When
876 + only one representation is provided with a non-``None`` value, add the
877 + missing equivalent representation. If both are already present, leave
878 + them unchanged even when their values conflict.
879 +
880 + Apply this independently to the flat kwargs and to each existing
881 + ``images_kwargs`` / ``videos_kwargs`` mapping. Values are not propagated
882 + between these mappings.
883 + """
884 +
885 + def add_missing_size_aliases(
886 + kwargs: Mapping[str, object],
887 + ) -> dict[str, object]:
888 + """Add missing size aliases within a kwargs mapping."""
889 + out_kwargs = dict(kwargs)
890 + # Copy `size` separately because `out_kwargs` is a shallow copy and
891 + # size edges may be added below.
892 + raw_size = cast(
893 + Mapping[str, object] | None,
894 + out_kwargs.get("size"),
895 + )
896 + size_kwargs = {} if raw_size is None else dict(raw_size)
897 +
898 + for edge_key, pixel_key in (
899 + ("shortest_edge", "min_pixels"),
900 + ("longest_edge", "max_pixels"),
901 + ):
902 + # Add the pixel alias from `size` when only the size edge is
903 + # present.
904 + if pixel_key not in out_kwargs:
905 + if edge_key in size_kwargs and size_kwargs[edge_key] is not None:
906 + out_kwargs[pixel_key] = size_kwargs[edge_key]
907 + continue
908 +
909 + pixel_value = out_kwargs[pixel_key]
910 + # HF treats `min_pixels` / `max_pixels` set to None as no size
911 + # override, so do not propagate them to the equivalent size edge.
912 + if pixel_value is None:
913 + continue
914 +
915 + # Add the size edge when only the pixel alias is present.
916 + if edge_key not in size_kwargs:
917 + size_kwargs[edge_key] = pixel_value
918 + out_kwargs["size"] = size_kwargs
919 +
920 + return out_kwargs
921 +
922 + prepared_mm_kwargs = add_missing_size_aliases(mm_kwargs)
923 + # Complete aliases independently inside existing image/video kwargs;
924 + # flat values are not copied into the nested mappings.
925 + for nested_kwargs_key in ("images_kwargs", "videos_kwargs"):
926 + nested_kwargs = prepared_mm_kwargs.get(nested_kwargs_key)
927 + if isinstance(nested_kwargs, Mapping):
928 + prepared_mm_kwargs[nested_kwargs_key] = add_missing_size_aliases(
929 + nested_kwargs
930 + )
931 +
932 + return prepared_mm_kwargs
933 +
934 + def _merge_and_resolve_mm_processor_kwargs(
935 + self,
936 + mm_kwargs: Mapping[str, object],
937 + ) -> dict[str, Any]:
938 + """Merge configured and request Qwen-VL ``mm_processor_kwargs``.
939 +
940 + Request-side ``size.shortest_edge`` / ``min_pixels`` and
941 + ``size.longest_edge`` / ``max_pixels`` aliases are completed before the
942 + merge so request precedence is preserved across equivalent
943 + representations.
944 + """
945 + # Complete request-side Qwen-VL size aliases before merging with
946 + # configured kwargs.
947 + prepared_mm_kwargs = self._complete_mm_processor_size_aliases(mm_kwargs)
948 +
949 + return super()._merge_and_resolve_mm_processor_kwargs(prepared_mm_kwargs)
950 +
951 + @staticmethod
952 + def _get_vision_size(
953 + mm_kwargs: Mapping[str, object],
954 + default_size: Mapping[str, int] | None = None,
955 + ) -> dict[str, int]:
956 + """Resolve Qwen-VL vision size kwargs with optional processor defaults.
957 +
958 + ``size`` overrides any processor defaults, then ``min_pixels`` and
959 + ``max_pixels`` override the corresponding size edges.
960 + """
961 + size = dict(default_size) if default_size is not None else {}
962 +
963 + override_size = cast(
964 + Mapping[str, int] | None,
965 + mm_kwargs.get("size"),
966 + )
967 + if override_size is not None:
968 + size.update(override_size)
969 +
970 + min_pixels = cast(int | None, mm_kwargs.get("min_pixels"))
971 + if min_pixels is not None:
972 + size["shortest_edge"] = min_pixels
973 +
974 + max_pixels = cast(int | None, mm_kwargs.get("max_pixels"))
975 + if max_pixels is not None:
976 + size["longest_edge"] = max_pixels
977 +
978 + return size
979 +
980 def get_mm_max_tokens_per_item(
981 self,
982 seq_len: int,
@@ -883,7 +995,7 @@ class Qwen2VLProcessingInfo(BaseProcessingInfo):
995 do_resize: bool = True,
996 image_processor: Qwen2VLImageProcessor,
997 mm_kwargs: Mapping[str, object],
886 - modality: str | None = None,
998 + modality: Literal["image", "video"] | None = None,
999 ) -> tuple[ImageSize, int]:
1000 hf_config = self.get_hf_config()
1001 vision_config = hf_config.vision_config
@@ -891,14 +1003,16 @@ class Qwen2VLProcessingInfo(BaseProcessingInfo):
1003 merge_size = vision_config.spatial_merge_size
1004 temporal_patch_size = vision_config.temporal_patch_size
1005
894 - mm_kwargs = self.ctx.get_merged_mm_kwargs(mm_kwargs, modality=modality)
895 - size = image_processor.size
896 - if override_size := mm_kwargs.get("size"):
897 - size = size | override_size
898 - if (override_min_pixels := mm_kwargs.get("min_pixels")) is not None:
899 - size = size | {"shortest_edge": override_min_pixels}
900 - if (override_max_pixels := mm_kwargs.get("max_pixels")) is not None:
901 - size = size | {"longest_edge": override_max_pixels}
1006 + if modality is None:
1007 + modality = "image"
1008 +
1009 + merged_mm_kwargs = self._merge_and_resolve_mm_processor_kwargs(mm_kwargs)
1010 + scope = "images_kwargs" if modality == "image" else "videos_kwargs"
1011 + scoped_mm_kwargs = merged_mm_kwargs.get(scope, {})
1012 + size = self._get_vision_size(
1013 + scoped_mm_kwargs,
1014 + default_size=image_processor.size,
1015 + )
1016
1017 if do_resize:
1018 resized_height, resized_width = smart_resize(
@@ -986,14 +1100,12 @@ class Qwen2VLProcessingInfo(BaseProcessingInfo):
1100 if max_pixels is None:
1101 image_processor = self.get_image_processor()
1102
989 - mm_kwargs = self.ctx.get_merged_mm_kwargs({}, modality="image")
990 - size = image_processor.size
991 - if override_size := mm_kwargs.get("size"):
992 - size = size | override_size
993 - if (override_min_pixels := mm_kwargs.get("min_pixels")) is not None:
994 - size = size | {"shortest_edge": override_min_pixels}
995 - if (override_max_pixels := mm_kwargs.get("max_pixels")) is not None:
996 - size = size | {"longest_edge": override_max_pixels}
1103 + merged_mm_kwargs = self._merge_and_resolve_mm_processor_kwargs({})
1104 + image_mm_kwargs = merged_mm_kwargs.get("images_kwargs", {})
1105 + size = self._get_vision_size(
1106 + image_mm_kwargs,
1107 + default_size=image_processor.size,
1108 + )
1109
1110 max_pixels = size["longest_edge"]
1111
vllm/model_executor/models/qwen3_vl.py
+107 -52
@@ -27,7 +27,7 @@
27 from collections.abc import Callable, Hashable, Iterable, Iterator, Mapping, Sequence
28 from functools import lru_cache, partial
29 from itertools import islice
30 -from typing import Any
30 +from typing import Any, Literal
31
32 import numpy as np
33 import torch
@@ -944,6 +944,16 @@ class Qwen3VLProcessingInfo(Qwen2VLProcessingInfo):
944 allow_missing_mm_embeddings=self.allow_missing_mm_embeddings,
945 )
946
947 + def get_supported_mm_processor_kwargs(self) -> dict[str, set[str]]:
948 + supported = super().get_supported_mm_processor_kwargs()
949 +
950 + # vLLM has historically accepted `min_pixels` / `max_pixels` for
951 + # Qwen3-VL video. Include them in the routing schema even though
952 + # Qwen3VLVideoProcessor only accepts the equivalent `size`; they are
953 + # folded into `size` before calling HF.
954 + supported["videos_kwargs"].update({"min_pixels", "max_pixels"})
955 + return supported
956 +
957 def _get_vision_info(
958 self,
959 *,
@@ -953,7 +963,7 @@ class Qwen3VLProcessingInfo(Qwen2VLProcessingInfo):
963 do_resize: bool = True,
964 image_processor: Qwen2VLImageProcessor | Qwen3VLVideoProcessor,
965 mm_kwargs: Mapping[str, object],
956 - modality: str | None = None,
966 + modality: Literal["image", "video"] | None = None,
967 ) -> tuple[ImageSize, int]:
968 is_video = isinstance(image_processor, Qwen3VLVideoProcessor)
969
@@ -965,14 +975,14 @@ class Qwen3VLProcessingInfo(Qwen2VLProcessingInfo):
975
976 if modality is None:
977 modality = "video" if is_video else "image"
968 - mm_kwargs = self.ctx.get_merged_mm_kwargs(mm_kwargs, modality=modality)
969 - size = image_processor.size
970 - if override_size := mm_kwargs.get("size"):
971 - size = size | override_size
972 - if (override_min_pixels := mm_kwargs.get("min_pixels")) is not None:
973 - size = size | {"shortest_edge": override_min_pixels}
974 - if (override_max_pixels := mm_kwargs.get("max_pixels")) is not None:
975 - size = size | {"longest_edge": override_max_pixels}
978 +
979 + merged_mm_kwargs = self._merge_and_resolve_mm_processor_kwargs(mm_kwargs)
980 + scope = "videos_kwargs" if modality == "video" else "images_kwargs"
981 + scoped_mm_kwargs = merged_mm_kwargs.get(scope, {})
982 + size = self._get_vision_size(
983 + scoped_mm_kwargs,
984 + default_size=image_processor.size,
985 + )
986
987 if do_resize:
988 if is_video:
@@ -1030,9 +1040,13 @@ class Qwen3VLProcessingInfo(Qwen2VLProcessingInfo):
1040 ) -> int:
1041 video_processor = self.get_video_processor()
1042
1033 - mm_kwargs = self.ctx.get_merged_mm_kwargs({}, modality="video")
1034 - video_size = mm_kwargs.get("size", video_processor.size)
1035 - temporal_patch_size = mm_kwargs.get(
1043 + merged_mm_kwargs = self._merge_and_resolve_mm_processor_kwargs({})
1044 + video_mm_kwargs = merged_mm_kwargs.get("videos_kwargs", {})
1045 + video_size = self._get_vision_size(
1046 + video_mm_kwargs,
1047 + default_size=video_processor.size,
1048 + )
1049 + temporal_patch_size = video_mm_kwargs.get(
1050 "temporal_patch_size", video_processor.temporal_patch_size
1051 )
1052
@@ -1162,9 +1176,13 @@ class Qwen3VLDummyInputsBuilder(BaseDummyInputsBuilder[Qwen3VLProcessingInfo]):
1176
1177 video_processor = self.info.get_video_processor()
1178
1165 - mm_kwargs = self.info.ctx.get_merged_mm_kwargs({}, modality="video")
1166 - video_size = mm_kwargs.get("size", video_processor.size)
1167 - temporal_patch_size = mm_kwargs.get(
1179 + merged_mm_kwargs = self.info._merge_and_resolve_mm_processor_kwargs({})
1180 + video_mm_kwargs = merged_mm_kwargs.get("videos_kwargs", {})
1181 + video_size = self.info._get_vision_size(
1182 + video_mm_kwargs,
1183 + default_size=video_processor.size,
1184 + )
1185 + temporal_patch_size = video_mm_kwargs.get(
1186 "temporal_patch_size", video_processor.temporal_patch_size
1187 )
1188
@@ -1180,13 +1198,13 @@ class Qwen3VLDummyInputsBuilder(BaseDummyInputsBuilder[Qwen3VLProcessingInfo]):
1198 # over enough frames that the cap is not binding for the dummy.
1199 # This takes precedence over a shrinking num_frames override, which
1200 # would otherwise reintroduce the under-profiling.
1183 - if mm_kwargs.get("cap_pixels_per_frame"):
1184 - max_video_tokens = mm_kwargs.get(
1201 + if video_mm_kwargs.get("cap_pixels_per_frame"):
1202 + max_video_tokens = video_mm_kwargs.get(
1203 "max_video_tokens",
1204 getattr(video_processor, "max_video_tokens", 768),
1205 )
1188 - patch_size = mm_kwargs.get("patch_size", video_processor.patch_size)
1189 - merge_size = mm_kwargs.get("merge_size", video_processor.merge_size)
1206 + patch_size = video_mm_kwargs.get("patch_size", video_processor.patch_size)
1207 + merge_size = video_mm_kwargs.get("merge_size", video_processor.merge_size)
1208 per_frame_cap = max_video_tokens * (patch_size * merge_size) ** 2
1209 target_num_frames = max(
1210 target_num_frames,
@@ -1348,6 +1366,47 @@ class Qwen3VLMultiModalProcessor(BaseMultiModalProcessor[Qwen3VLProcessingInfo])
1366 if not hf_data:
1367 return self._finalize_hf_mm_data(hf_data, hf_kwargs, passthrough_data)
1368
1369 + # Merge and resolve the multimodal processor kwargs once so every HF
1370 + # call below starts from the same engine/request state and scoped form.
1371 + merged_mm_kwargs = self.info._merge_and_resolve_mm_processor_kwargs(hf_kwargs)
1372 +
1373 + if "images_kwargs" in merged_mm_kwargs:
1374 + image_scoped_kwargs = dict(merged_mm_kwargs["images_kwargs"])
1375 +
1376 + # Qwen2VLImageProcessor requires both `size` edges. If a partial size
1377 + # override is passed, fill missing edges from the processor defaults
1378 + # before any HF call.
1379 + if image_scoped_kwargs.keys() & {"size", "min_pixels", "max_pixels"}:
1380 + default_image_size = self.info.get_image_processor().size
1381 + image_scoped_kwargs["size"] = self.info._get_vision_size(
1382 + image_scoped_kwargs,
1383 + default_size=default_image_size,
1384 + )
1385 +
1386 + merged_mm_kwargs["images_kwargs"] = image_scoped_kwargs
1387 +
1388 + if "videos_kwargs" in merged_mm_kwargs:
1389 + video_scoped_kwargs = dict(merged_mm_kwargs["videos_kwargs"])
1390 +
1391 + # Qwen3VLVideoProcessor requires both `size` edges. If a partial size
1392 + # override is passed, fill missing edges from the processor defaults
1393 + # before any HF call.
1394 + if video_scoped_kwargs.keys() & {"size", "min_pixels", "max_pixels"}:
1395 + default_video_size = self.info.get_video_processor().size
1396 + video_scoped_kwargs["size"] = self.info._get_vision_size(
1397 + video_scoped_kwargs,
1398 + default_size=default_video_size,
1399 + )
1400 +
1401 + # Qwen3VLVideoProcessor does not accept vLLM's min/max pixel
1402 + # compatibility kwargs. They have already been folded into `size`,
1403 + # so remove them before any HF call, including calls that contain
1404 + # only text/images.
1405 + video_scoped_kwargs.pop("min_pixels", None)
1406 + video_scoped_kwargs.pop("max_pixels", None)
1407 +
1408 + merged_mm_kwargs["videos_kwargs"] = video_scoped_kwargs
1409 +
1410 # Separate video processing from image processing. Because the videos
1411 # are processed into several image patches
1412 video_input_ids_lst: list[list[int]] = []
@@ -1373,32 +1432,22 @@ class Qwen3VLMultiModalProcessor(BaseMultiModalProcessor[Qwen3VLProcessingInfo])
1432 # used to calculate the timestamps. Make sure that
1433 # do_sample_frames in mm_kwargs is false for presampled videos.
1434
1376 - # NOTE: a copy of is created to update do_sample_frames,
1377 - # otherwise mm_hash for the object will be incorrect.
1378 - video_mm_kwargs: dict[str, Any] = dict(hf_kwargs)
1379 - merged = self.info.ctx.get_merged_mm_kwargs(hf_kwargs, modality="video")
1380 - if merged.keys() & {"size", "min_pixels", "max_pixels"}:
1381 - video_size = dict(self.info.get_video_processor().size)
1382 - size_override = merged.get("size")
1383 - if size_override is not None:
1384 - video_size = video_size | size_override
1385 - min_pixels = merged.get("min_pixels")
1386 - if min_pixels is not None:
1387 - video_size["shortest_edge"] = min_pixels
1388 - max_pixels = merged.get("max_pixels")
1389 - if max_pixels is not None:
1390 - video_size["longest_edge"] = max_pixels
1391 - video_mm_kwargs["size"] = video_size
1392 - sampled_fps = video_mm_kwargs.get("fps")
1435 + # The root copy is shallow, so give each item its own video
1436 + # scope before modifying per-video values below.
1437 + video_mm_kwargs = dict(merged_mm_kwargs)
1438 + video_scoped_kwargs = dict(video_mm_kwargs.get("videos_kwargs", {}))
1439 + video_mm_kwargs["videos_kwargs"] = video_scoped_kwargs
1440 +
1441 + sampled_fps = video_scoped_kwargs.get("fps")
1442 if is_list_of(sampled_fps, float):
1394 - video_mm_kwargs["fps"] = sampled_fps[item_idx]
1395 - sampled_num_frames = video_mm_kwargs.get("num_frames")
1443 + video_scoped_kwargs["fps"] = sampled_fps[item_idx]
1444 + sampled_num_frames = video_scoped_kwargs.get("num_frames")
1445 if is_list_of(sampled_num_frames, int):
1397 - video_mm_kwargs["num_frames"] = sampled_num_frames[item_idx]
1398 - if "do_sample_frames" not in video_mm_kwargs:
1446 + video_scoped_kwargs["num_frames"] = sampled_num_frames[item_idx]
1447 + if "do_sample_frames" not in video_scoped_kwargs:
1448 # qwen_vl_utils already has "do_sample_frames" in
1449 # mm_kwargs, don't overwrite it.
1401 - video_mm_kwargs["do_sample_frames"] = metadata.get(
1450 + video_scoped_kwargs["do_sample_frames"] = metadata.get(
1451 "do_sample_frames", False
1452 )
1453
@@ -1409,9 +1458,9 @@ class Qwen3VLMultiModalProcessor(BaseMultiModalProcessor[Qwen3VLProcessingInfo])
1458 # Compute timestamps here where we have access to metadata
1459 timestamps = self.info._get_video_second_idx(
1460 metadata=metadata,
1412 - do_sample_frames=video_mm_kwargs["do_sample_frames"],
1413 - sampled_fps=video_mm_kwargs.get("fps"),
1414 - sampled_num_frames=video_mm_kwargs.get("num_frames"),
1461 + do_sample_frames=video_scoped_kwargs["do_sample_frames"],
1462 + sampled_fps=video_scoped_kwargs.get("fps"),
1463 + sampled_num_frames=video_scoped_kwargs.get("num_frames"),
1464 )
1465 timestamps_per_video.append(timestamps)
1466
@@ -1423,8 +1472,11 @@ class Qwen3VLMultiModalProcessor(BaseMultiModalProcessor[Qwen3VLProcessingInfo])
1472 # to prevent HF's BaseVideoProcessor.preprocess() from
1473 # filling in the class default (fps=2) via setdefault(),
1474 # which would conflict with num_frames (mutually exclusive).
1426 - if "num_frames" in video_mm_kwargs and "fps" not in video_mm_kwargs:
1427 - video_mm_kwargs["fps"] = None
1475 + if (
1476 + "num_frames" in video_scoped_kwargs
1477 + and "fps" not in video_scoped_kwargs
1478 + ):
1479 + video_scoped_kwargs["fps"] = None
1480
1481 video_outputs = self.info.ctx.call_hf_processor(
1482 self.info.get_hf_processor(**video_mm_kwargs),
@@ -1485,11 +1537,14 @@ class Qwen3VLMultiModalProcessor(BaseMultiModalProcessor[Qwen3VLProcessingInfo])
1537 else:
1538 video_outputs = dict()
1539
1488 - # fps/num_frames are video-only kwargs already consumed by the loop;
1489 - # exclude them so the text/image processor call below never gets a list.
1490 - non_video_mm_kwargs = {
1491 - k: v for k, v in hf_kwargs.items() if k not in ("fps", "num_frames")
1492 - }
1540 + # The root copy is shallow, so copy the video scope before removing
1541 + # per-video kwargs to avoid mutating `merged_mm_kwargs`.
1542 + non_video_mm_kwargs = dict(merged_mm_kwargs)
1543 + if "videos_kwargs" in non_video_mm_kwargs:
1544 + non_video_scoped_kwargs = dict(non_video_mm_kwargs["videos_kwargs"])
1545 + non_video_scoped_kwargs.pop("fps", None)
1546 + non_video_scoped_kwargs.pop("num_frames", None)
1547 + non_video_mm_kwargs["videos_kwargs"] = non_video_scoped_kwargs
1548 processed_data = self.info.ctx.call_hf_processor(
1549 self.info.get_hf_processor(**non_video_mm_kwargs),
1550 hf_data,
vllm/multimodal/processing/context.py
+176 -42
@@ -5,7 +5,7 @@ from collections.abc import Callable, Mapping
5 from contextlib import contextmanager
6 from dataclasses import dataclass, field
7 from functools import cached_property
8 -from typing import TYPE_CHECKING, Any, overload
8 +from typing import TYPE_CHECKING, Any, get_type_hints, overload
9
10 import torch
11 from typing_extensions import TypeVar
@@ -42,46 +42,129 @@ else:
42
43 logger = init_logger(__name__)
44
45 -# HuggingFace processors accept nested ``images_kwargs`` / ``videos_kwargs`` /
46 -# ``audio_kwargs`` in addition to a shared flat namespace. vLLM's token-budget
47 -# and dummy-input code only reads the flat keys, so scoped overrides have to
48 -# be overlaid per modality.
49 -_HF_MODALITY_PROCESSOR_KWARGS = {
50 - "image": "images_kwargs",
51 - "video": "videos_kwargs",
52 - "audio": "audio_kwargs",
53 -}
54 -
45 +# HuggingFace processors accept a shared flat namespace in addition to the
46 +# nested processor kwarg scopes ``text_kwargs`` / ``images_kwargs`` /
47 +# ``videos_kwargs`` / ``audio_kwargs``. vLLM recognizes these scopes when
48 +# resolving ``mm_processor_kwargs``.
49 +_HF_PROCESSOR_KWARG_SCOPES = (
50 + "text_kwargs",
51 + "images_kwargs",
52 + "videos_kwargs",
53 + "audio_kwargs",
54 +)
55
56 -def overlay_modality_mm_kwargs(
57 - kwargs: Mapping[str, object],
58 - modality: str | None,
59 -) -> dict[str, Any]:
60 - """Overlay HF-style nested processor kwargs onto the flat namespace.
56
62 - Overlay only the requested modality so a video ``size`` bump does not leak
63 - into the image budget. Flat keys keep their current shared-namespace
64 - behavior when no scoped dict is present.
57 +def _merge_scoped_mm_processor_value(
58 + flat_value: object,
59 + scoped_value: object,
60 +) -> object:
61 + """Merge a flat value with an existing, more specific scoped value.
62
66 - Args:
67 - kwargs: Merged multi-modal processor kwargs.
68 - modality: Target modality (``image``, ``video``, or ``audio``).
69 - When ``None``, ``kwargs`` is copied without overlay.
63 + Mappings are merged recursively; otherwise the scoped value replaces the
64 + flat value.
65 + """
66 + if isinstance(flat_value, Mapping) and isinstance(scoped_value, Mapping):
67 + # Merge recursively so scoped leaves override matching flat leaves
68 + # without dropping unrelated flat entries.
69 + merged = dict(flat_value)
70 + for key, value in scoped_value.items():
71 + if key in merged:
72 + merged[key] = _merge_scoped_mm_processor_value(merged[key], value)
73 + else:
74 + merged[key] = value
75 + return merged
76 + return scoped_value
77
71 - Returns:
72 - A new dict with the modality-scoped keys overlaid when present.
78
79 +def _resolve_mm_processor_kwargs(
80 + kwargs: Mapping[str, object],
81 + supported_mm_processor_kwargs: Mapping[str, set[str]] | None = None,
82 +) -> dict[str, object]:
83 + """Resolve flat processor kwargs into HuggingFace processor kwarg scopes.
84 +
85 + With ``supported_mm_processor_kwargs``, each scope lists the flat keys it
86 + supports. Matching flat keys are routed to every scope that supports them,
87 + existing scoped values take precedence, and keys not supported by any scope
88 + remain flat.
89 +
90 + Without ``supported_mm_processor_kwargs``, flat keys already represented in
91 + at least one existing mapping-valued scope are removed from the shared flat
92 + namespace; all other flat keys are left unchanged because there is no
93 + information to determine which scopes should receive them.
94 """
75 - merged = dict(kwargs)
76 - if modality is None:
77 - return merged
78 - scoped_key = _HF_MODALITY_PROCESSOR_KWARGS.get(modality)
79 - if scoped_key is None:
80 - return merged
81 - scoped = merged.get(scoped_key)
82 - if not isinstance(scoped, Mapping):
83 - return merged
84 - return merged | dict(scoped)
95 + resolved = dict(kwargs)
96 +
97 + # Without ``supported_mm_processor_kwargs``, remove flat entries already
98 + # represented in at least one existing mapping-valued scope. There is no
99 + # information to route the remaining flat entries.
100 + if supported_mm_processor_kwargs is None:
101 + # Collect all keys represented in existing mapping-valued scopes.
102 + # Non-mapping scope values do not contain scoped kwargs and are left
103 + # untouched.
104 + represented_scoped_keys: set[str] = set()
105 + for scoped_key in _HF_PROCESSOR_KWARG_SCOPES:
106 + scoped_kwargs = resolved.get(scoped_key)
107 + if isinstance(scoped_kwargs, Mapping):
108 + represented_scoped_keys.update(scoped_kwargs)
109 +
110 + # Remove flat entries for keys already represented in a scope.
111 + for key in represented_scoped_keys:
112 + if key not in _HF_PROCESSOR_KWARG_SCOPES:
113 + resolved.pop(key, None)
114 + return resolved
115 +
116 + # With ``supported_mm_processor_kwargs``, collect the flat kwargs before
117 + # routing each one to the processor kwarg scopes that support it.
118 + flat_kwargs = {
119 + key: value
120 + for key, value in resolved.items()
121 + if key not in _HF_PROCESSOR_KWARG_SCOPES
122 + }
123 +
124 + for key, flat_value in flat_kwargs.items():
125 + # Find every processor kwarg scope that supports this flat key. If none
126 + # supports it, keep the key in the shared flat namespace.
127 + supported_scopes = [
128 + scoped_key
129 + for scoped_key in _HF_PROCESSOR_KWARG_SCOPES
130 + if key in supported_mm_processor_kwargs.get(scoped_key, set())
131 + ]
132 + if not supported_scopes:
133 + continue
134 +
135 + # Add the flat value to each supported destination scope.
136 + for scoped_key in supported_scopes:
137 + # Start from a copy of the existing scoped kwargs, or an empty scope
138 + # if absent.
139 + if scoped_key in resolved:
140 + scoped_kwargs = resolved[scoped_key]
141 + # A scope must be a mapping before keys can be added to it. Do
142 + # not replace an explicit non-mapping value.
143 + if not isinstance(scoped_kwargs, Mapping):
144 + raise TypeError(f"`{scoped_key}` must be a mapping")
145 + scoped_kwargs = dict(scoped_kwargs)
146 + else:
147 + scoped_kwargs = {}
148 +
149 + # Add the flat value to this scope. Existing scoped values take
150 + # precedence.
151 + if key in scoped_kwargs:
152 + scoped_kwargs[key] = _merge_scoped_mm_processor_value(
153 + flat_value, scoped_kwargs[key]
154 + )
155 + else:
156 + # Assign the flat value to this scope. For mappings, assign a
157 + # copy so the same mapping object is not shared between the flat
158 + # value and multiple scopes.
159 + scoped_kwargs[key] = (
160 + dict(flat_value) if isinstance(flat_value, Mapping) else flat_value
161 + )
162 + resolved[scoped_key] = scoped_kwargs
163 +
164 + # After routing to every destination, remove the flat copy.
165 + resolved.pop(key)
166 +
167 + return resolved
168
169
170 @dataclass
@@ -294,19 +377,27 @@ class InputProcessingContext:
377 self,
378 kwargs: Mapping[str, object],
379 *,
297 - modality: str | None = None,
380 + supported_mm_processor_kwargs: Mapping[str, set[str]] | None = None,
381 ) -> dict[str, Any]:
382 """Merge configured and request ``mm_processor_kwargs``.
383
301 - When ``modality`` is set, HF-style nested
302 - ``images_kwargs`` / ``videos_kwargs`` / ``audio_kwargs`` are overlaid
303 - onto the flat namespace for vLLM-side reads (token budgets, dummy
304 - inputs). Processor construction and HF ``__call__`` should omit
305 - ``modality`` so the nested dicts still reach the HF processor.
384 + When ``supported_mm_processor_kwargs`` is provided, flat keys are matched
385 + against the keys supported by each HuggingFace processor kwarg scope.
386 + Matching flat keys are routed to every scope that supports them and
387 + removed from the shared flat namespace; keys not supported by any scope
388 + remain flat.
389 +
390 + Without ``supported_mm_processor_kwargs``, flat keys already represented
391 + in at least one existing mapping-valued scope are removed from the shared
392 + flat namespace; all other flat keys are left unchanged because there is
393 + no information to determine which scopes should receive them.
394 """
395 mm_config = self.model_config.get_multimodal_config()
396 merged = mm_config.merge_mm_processor_kwargs(kwargs)
309 - return overlay_modality_mm_kwargs(merged, modality)
397 + return _resolve_mm_processor_kwargs(
398 + merged,
399 + supported_mm_processor_kwargs=supported_mm_processor_kwargs,
400 + )
401
402 def call_hf_processor(
403 self,
@@ -380,6 +471,49 @@ class BaseProcessingInfo:
471 """
472 return self.ctx.get_hf_processor(**kwargs)
473
474 + def get_supported_mm_processor_kwargs(self) -> dict[str, set[str]]:
475 + """Return supported kwarg names for each HF processor kwargs scope."""
476 + processor = self.get_hf_processor()
477 + processor_kwargs = get_type_hints(processor.valid_processor_kwargs)
478 +
479 + supported = {
480 + "text_kwargs": set(processor_kwargs["text_kwargs"].__annotations__),
481 + }
482 + if "image" in self.supported_mm_limits:
483 + supported["images_kwargs"] = set(
484 + processor.image_processor.valid_kwargs.__annotations__
485 + )
486 + if "video" in self.supported_mm_limits:
487 + supported["videos_kwargs"] = set(
488 + processor.video_processor.valid_kwargs.__annotations__
489 + )
490 + if "audio" in self.supported_mm_limits:
491 + supported["audio_kwargs"] = set(
492 + processor_kwargs["audio_kwargs"].__annotations__
493 + )
494 +
495 + return supported
496 +
497 + @cached_property
498 + def supported_mm_processor_kwargs(self) -> dict[str, set[str]]:
499 + """Supported kwarg names for each HF processor kwargs scope."""
500 + return self.get_supported_mm_processor_kwargs()
501 +
502 + def _merge_and_resolve_mm_processor_kwargs(
503 + self,
504 + mm_kwargs: Mapping[str, object],
505 + ) -> dict[str, Any]:
506 + """Merge configured and request ``mm_processor_kwargs``.
507 +
508 + Flat kwargs are routed into the HuggingFace processor kwarg scopes that
509 + support them after the configured/request merge. When a routed flat value
510 + conflicts with an existing scoped value, the scoped value takes precedence.
511 + """
512 + return self.ctx.get_merged_mm_kwargs(
513 + mm_kwargs,
514 + supported_mm_processor_kwargs=self.supported_mm_processor_kwargs,
515 + )
516 +
517 def get_default_tok_params(self) -> TokenizeParams:
518 """Construct the default parameters for tokenization."""
519 from vllm.renderers import TokenizeParams