| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | |
| 4 | from unittest.mock import patch |
| 5 | |
| 6 | import numpy as np |
| 7 | import pytest |
| 8 | import torch |
| 9 | |
| 10 | from vllm import envs |
| 11 | from vllm.model_executor.models.nano_nemotron_vl import ( |
| 12 | NanoNemotronVLMultiModalProcessor, |
| 13 | NemotronH_Nano_VL_V2, |
| 14 | ) |
| 15 | from vllm.multimodal.parse import ( |
| 16 | MultiModalDataItems, |
| 17 | MultiModalDataParser, |
| 18 | VideoProcessorItems, |
| 19 | ) |
| 20 | |
| 21 | |
| 22 | @pytest.mark.parametrize("input_key", ["image_embeds", "video_embeds"]) |
| 23 | def test_precomputed_multimodal_embeddings(input_key: str): |
| 24 | model = object.__new__(NemotronH_Nano_VL_V2) |
| 25 | embeds = torch.randn(2, 4, 8) |
| 26 | |
| 27 | outputs = model.embed_multimodal(**{input_key: embeds}) |
| 28 | |
| 29 | assert len(outputs) == len(embeds) |
| 30 | assert all(torch.equal(output, embed) for output, embed in zip(outputs, embeds)) |
| 31 | |
| 32 | |
| 33 | class _TextOnlyMultiModalConfig: |
| 34 | def get_limit_per_prompt(self, modality: str) -> int: |
| 35 | return 0 |
| 36 | |
| 37 | |
| 38 | class _ImageOnlyMultiModalConfig: |
| 39 | def get_limit_per_prompt(self, modality: str) -> int: |
| 40 | return 1 if modality == "image" else 0 |
| 41 | |
| 42 | |
| 43 | class _ModelConfig: |
| 44 | multimodal_config = _TextOnlyMultiModalConfig() |
| 45 | |
| 46 | |
| 47 | class _ImageOnlyModelConfig: |
| 48 | multimodal_config = _ImageOnlyMultiModalConfig() |
| 49 | |
| 50 | |
| 51 | class _LanguageModel: |
| 52 | def __init__(self) -> None: |
| 53 | self.loaded_weights: list[tuple[str, object]] = [] |
| 54 | |
| 55 | def load_weights(self, weights): |
| 56 | self.loaded_weights = list(weights) |
| 57 | |
| 58 | |
| 59 | class _MissingMultiModalModule: |
| 60 | def named_parameters(self): |
| 61 | raise AssertionError("multimodal weights should not be inspected") |
| 62 | |
| 63 | def load_weights(self, weights): |
| 64 | raise AssertionError("multimodal weights should not be loaded") |
| 65 | |
| 66 | |
| 67 | class _AdapterModule: |
| 68 | def named_parameters(self): |
| 69 | return [] |
| 70 | |
| 71 | |
| 72 | class _VisionModel: |
| 73 | def __init__(self) -> None: |
| 74 | self.loaded_weights: list[tuple[str, object]] = [] |
| 75 | |
| 76 | def load_weights(self, weights): |
| 77 | self.loaded_weights = list(weights) |
| 78 | |
| 79 | |
| 80 | class _FakeTensor: |
| 81 | """Sentinel stand-in for torch.Tensor in load_weights tests. Supports the |
| 82 | .detach().clone() chain used by load_weights for buffered mm weights; |
| 83 | both methods return self so identity (and the existing equality |
| 84 | assertions) are preserved through cloning.""" |
| 85 | |
| 86 | def detach(self): |
| 87 | return self |
| 88 | |
| 89 | def clone(self): |
| 90 | return self |
| 91 | |
| 92 | |
| 93 | def test_nano_nemotron_vl_skips_multimodal_weights_in_text_only_mode(): |
| 94 | model = object.__new__(NemotronH_Nano_VL_V2) |
| 95 | language_model = _LanguageModel() |
| 96 | object.__setattr__(model, "model_config", _ModelConfig()) |
| 97 | object.__setattr__(model, "language_model", language_model) |
| 98 | object.__setattr__(model, "mlp1", _AdapterModule()) |
| 99 | object.__setattr__(model, "vision_model", _MissingMultiModalModule()) |
| 100 | object.__setattr__(model, "sound_encoder", None) |
| 101 | |
| 102 | language_weight = object() |
| 103 | model.load_weights( |
| 104 | [ |
| 105 | ("language_model.layers.0.weight", language_weight), |
| 106 | ("mlp1.0.weight", object()), |
| 107 | ("vision_model.radio_model.encoder.weight", object()), |
| 108 | ("sound_encoder.encoder.weight", object()), |
| 109 | ] |
| 110 | ) |
| 111 | |
| 112 | assert language_model.loaded_weights == [("layers.0.weight", language_weight)] |
| 113 | |
| 114 | |
| 115 | def test_nano_nemotron_vl_loads_vision_weights_without_sound_encoder(): |
| 116 | model = object.__new__(NemotronH_Nano_VL_V2) |
| 117 | language_model = _LanguageModel() |
| 118 | vision_model = _VisionModel() |
| 119 | object.__setattr__(model, "model_config", _ImageOnlyModelConfig()) |
| 120 | object.__setattr__(model, "language_model", language_model) |
| 121 | object.__setattr__(model, "mlp1", _AdapterModule()) |
| 122 | object.__setattr__(model, "vision_model", vision_model) |
| 123 | object.__setattr__(model, "sound_encoder", None) |
| 124 | |
| 125 | language_weight = object() |
| 126 | vision_weight = _FakeTensor() |
| 127 | model.load_weights( |
| 128 | [ |
| 129 | ("language_model.layers.0.weight", language_weight), |
| 130 | ("vision_model.radio_model.encoder.weight", vision_weight), |
| 131 | ] |
| 132 | ) |
| 133 | |
| 134 | assert language_model.loaded_weights == [("layers.0.weight", language_weight)] |
| 135 | assert vision_model.loaded_weights == [ |
| 136 | ("radio_model.encoder.weight", vision_weight) |
| 137 | ] |
| 138 | |
| 139 | |
| 140 | def test_nano_nemotron_vl_requires_sound_encoder_for_sound_weights(): |
| 141 | model = object.__new__(NemotronH_Nano_VL_V2) |
| 142 | language_model = _LanguageModel() |
| 143 | vision_model = _VisionModel() |
| 144 | object.__setattr__(model, "model_config", _ImageOnlyModelConfig()) |
| 145 | object.__setattr__(model, "language_model", language_model) |
| 146 | object.__setattr__(model, "mlp1", _AdapterModule()) |
| 147 | object.__setattr__(model, "vision_model", vision_model) |
| 148 | object.__setattr__(model, "sound_encoder", None) |
| 149 | |
| 150 | with pytest.raises(AssertionError): |
| 151 | model.load_weights([("sound_encoder.encoder.weight", object())]) |
| 152 | |
| 153 | |
| 154 | def _make_mm_items_with_video_bytes( |
| 155 | video_bytes: bytes, |
| 156 | ) -> MultiModalDataItems: |
| 157 | """Build a minimal MultiModalDataItems with one video entry.""" |
| 158 | items = MultiModalDataItems() |
| 159 | items["video"] = VideoProcessorItems( |
| 160 | data=[None], |
| 161 | metadata=[{"original_video_bytes": video_bytes}], |
| 162 | ) |
| 163 | return items |
| 164 | |
| 165 | |
| 166 | def test_extract_audio_from_videos_passes_max_duration(): |
| 167 | """_extract_audio_from_videos must forward VLLM_MAX_AUDIO_DECODE_DURATION_S |
| 168 | to load_audio_pyav so decompression-bomb audio is rejected.""" |
| 169 | dummy_audio = (np.zeros(16000, dtype=np.float32), 16000.0) |
| 170 | mm_items = _make_mm_items_with_video_bytes(b"\x00" * 64) |
| 171 | |
| 172 | processor = object.__new__(NanoNemotronVLMultiModalProcessor) |
| 173 | processor.data_parser = MultiModalDataParser( |
| 174 | target_sr=dummy_audio[1], target_channels=1 |
| 175 | ) |
| 176 | |
| 177 | target = "vllm.model_executor.models.nano_nemotron_vl.load_audio_pyav" |
| 178 | with patch(target, return_value=dummy_audio) as mock_load: |
| 179 | processor._extract_audio_from_videos(mm_items) |
| 180 | |
| 181 | mock_load.assert_called_once() |
| 182 | _, kwargs = mock_load.call_args |
| 183 | assert kwargs["max_duration_s"] == envs.VLLM_MAX_AUDIO_DECODE_DURATION_S |
| 184 | |
| 185 | |
| 186 | def test_extract_audio_from_videos_rejects_oversized_audio(): |
| 187 | """When load_audio_pyav raises due to duration limit the video is |
| 188 | marked as having no audio instead of crashing the server.""" |
| 189 | mm_items = _make_mm_items_with_video_bytes(b"\x00" * 64) |
| 190 | |
| 191 | processor = object.__new__(NanoNemotronVLMultiModalProcessor) |
| 192 | |
| 193 | target = "vllm.model_executor.models.nano_nemotron_vl.load_audio_pyav" |
| 194 | with patch( |
| 195 | target, |
| 196 | side_effect=ValueError("Audio exceeds maximum allowed duration"), |
| 197 | ): |
| 198 | _, audio_items, has_audio = processor._extract_audio_from_videos(mm_items) |
| 199 | |
| 200 | assert audio_items == [] |
| 201 | assert has_audio == [False] |