From f1ad0002f17c55d3da19da1e01dda7eda4f29aa1 Mon Sep 17 00:00:00 2001 From: apex-mochen <2756823972@qq.com> Date: Thu, 24 Sep 2026 13:23:53 +0800 Subject: [PATCH] [Bugfix] Deep-merge nested mm_processor_kwargs instead of shallow union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Request-level nested mm_processor_kwargs (e.g. videos_kwargs) previously wholesale-replaced the configured mapping, dropping sibling keys such as fps that were not repeated in the request. Nested mappings are now merged key-by-key; flat kwargs are unchanged. Regression test added. Fixes #58406 Signed-off-by: 叮当猫 <2756823972@qq.com> --- tests/multimodal/test_processing.py | 32 +++++++++++++++++++++++++++++ vllm/config/multimodal.py | 24 +++++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/tests/multimodal/test_processing.py b/tests/multimodal/test_processing.py index 0bd19a0ea0dc..24343021df2b 100644 --- a/tests/multimodal/test_processing.py +++ b/tests/multimodal/test_processing.py @@ -1261,6 +1261,38 @@ def test_mm_processor_kwargs_merge_then_overlay_preserves_scoping(): assert "size" not in overlay_modality_mm_kwargs(merged, None) +@pytest.mark.skip_global_cleanup +def test_mm_processor_kwargs_deep_merges_nested_scoped_kwargs(): + """Request-level nested kwargs must not drop configured sibling keys. + + A shallow ``dict`` union replaced the whole ``videos_kwargs`` mapping, so a + configured ``fps`` (or any key not repeated in the request) was silently + dropped. Nested mappings should merge key-by-key instead. + """ + from vllm.config.multimodal import MultiModalConfig + + mm_config = MultiModalConfig( + mm_processor_kwargs={ + "size": {"shortest_edge": 100, "longest_edge": 1000}, + "videos_kwargs": {"fps": 2, "size": {"longest_edge": 1200}}, + } + ) + merged = mm_config.merge_mm_processor_kwargs( + { + "size": {"longest_edge": 1800}, + "videos_kwargs": {"size": {"shortest_edge": 200}}, + } + ) + # Configured fps survives the request merge. + assert merged["videos_kwargs"]["fps"] == 2 + # Nested size dicts are merged key-by-key. + assert merged["videos_kwargs"]["size"] == { + "longest_edge": 1200, + "shortest_edge": 200, + } + assert merged["size"] == {"shortest_edge": 100, "longest_edge": 1800} + + def test_processor_inputs_hashes_partial_uuids(): rng = np.random.RandomState(0) images = [random_image(rng, min_wh=8, max_wh=9) for _ in range(2)] diff --git a/vllm/config/multimodal.py b/vllm/config/multimodal.py index 4eca42430c21..2e7f8da65def 100644 --- a/vllm/config/multimodal.py +++ b/vllm/config/multimodal.py @@ -117,6 +117,28 @@ def __get_pydantic_core_schema__( `"xpu"` on XPU). Validated against that set by the CLI.""" + +def _deep_merge_mm_kwargs( + base: Mapping[str, object], override: Mapping[str, object] +) -> dict[str, object]: + """Deep-merge inference ``mm_processor_kwargs`` over configured ones. + + The previous implementation used a shallow ``dict`` union, so supplying a + nested mapping (e.g. ``videos_kwargs``) in the request wholesale replaced the + configured one and dropped every key not repeated in the request (e.g. a + configured ``fps``). Nested mappings are now merged key-by-key, while + non-mapping values are overridden as before. + """ + result: dict[str, object] = dict(base) + for key, value in override.items(): + existing = result.get(key) + if isinstance(existing, Mapping) and isinstance(value, Mapping): + result[key] = _deep_merge_mm_kwargs(existing, value) + else: + result[key] = value + return result + + @config class MultiModalConfig: """Controls the behavior of multimodal models.""" @@ -538,7 +560,7 @@ def merge_mm_processor_kwargs( if self.mm_device_do_normalize: kwargs["do_normalize"] = False kwargs["do_rescale"] = False - return kwargs | dict(inference_kwargs) + return _deep_merge_mm_kwargs(kwargs, inference_kwargs) def use_gpu_video_backend(self) -> bool: """Return whether the configured video loader or codec uses the GPU."""