diff --git a/tests/entrypoints/openai_api/test_decode_audio_url.py b/tests/entrypoints/openai_api/test_decode_audio_url.py index fbf56b70e..2a5c5be47 100644 --- a/tests/entrypoints/openai_api/test_decode_audio_url.py +++ b/tests/entrypoints/openai_api/test_decode_audio_url.py @@ -1,19 +1,16 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Unit tests for decode_audio_url helper and _AudioFileMediaIO.""" +"""Unit tests for decode_audio_url helper.""" import asyncio import base64 import os +import httpx import pytest -from vllm.multimodal.media import MediaConnector from vllm_omni.entrypoints.openai.errors import InvalidInputReferenceError -from vllm_omni.entrypoints.openai.video_api_utils import ( - _AudioFileMediaIO, - decode_audio_url, -) +from vllm_omni.entrypoints.openai.video_api_utils import decode_audio_url pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -27,127 +24,15 @@ def _cleanup(path): os.unlink(path) -def _permissive_connector(): - return MediaConnector( - allowed_local_media_path="", - allowed_media_domains=None, - ) - - -def _restricted_connector(allowed_domains=None): - return MediaConnector( - allowed_local_media_path="", - allowed_media_domains=allowed_domains or [], - ) - - -# --------------------------------------------------------------------------- -# _AudioFileMediaIO direct tests -# --------------------------------------------------------------------------- - - -class TestAudioFileMediaIO: - """Direct tests for _AudioFileMediaIO methods.""" - - def test_load_bytes_writes_temp_file(self): - audio_bytes = b"\xff\xfb\x90\x00" * 50 - io = _AudioFileMediaIO() - path = io.load_bytes(audio_bytes) - try: - assert os.path.isfile(path) - assert path.endswith(".wav") - with open(path, "rb") as f: - assert f.read() == audio_bytes - finally: - _cleanup(path) - - def test_load_bytes_empty_raises(self): - io = _AudioFileMediaIO() - with pytest.raises(InvalidInputReferenceError, match="audio data is empty"): - io.load_bytes(b"") - - def test_load_base64_mp3_suffix(self): - audio_bytes = b"\xff\xfb\x90\x00" * 50 - b64 = base64.b64encode(audio_bytes).decode() - io = _AudioFileMediaIO() - path = io.load_base64("audio/mp3", b64) - try: - assert path.endswith(".mp3") - with open(path, "rb") as f: - assert f.read() == audio_bytes - finally: - _cleanup(path) - - def test_load_base64_mpeg_uses_mp3(self): - io = _AudioFileMediaIO() - path = io.load_base64("audio/mpeg", base64.b64encode(b"\x00" * 10).decode()) - try: - assert path.endswith(".mp3") - finally: - _cleanup(path) - - def test_load_base64_wav_suffix(self): - io = _AudioFileMediaIO() - path = io.load_base64("audio/wav", base64.b64encode(b"\x00" * 10).decode()) - try: - assert path.endswith(".wav") - finally: - _cleanup(path) - - def test_load_base64_flac_suffix(self): - io = _AudioFileMediaIO() - path = io.load_base64("audio/flac", base64.b64encode(b"\x00" * 10).decode()) - try: - assert path.endswith(".flac") - finally: - _cleanup(path) - - def test_load_base64_empty_media_type_uses_wav(self): - io = _AudioFileMediaIO() - path = io.load_base64("", base64.b64encode(b"\x00" * 10).decode()) - try: - assert path.endswith(".wav") - finally: - _cleanup(path) - - def test_load_base64_non_alnum_falls_back_to_wav(self): - io = _AudioFileMediaIO() - path = io.load_base64("audio/foo-bar", base64.b64encode(b"\x00" * 10).decode()) - try: - assert path.endswith(".wav") - finally: - _cleanup(path) - - def test_load_base64_too_long_extension_falls_back_to_wav(self): - io = _AudioFileMediaIO() - path = io.load_base64("audio/verylongextension", base64.b64encode(b"\x00" * 10).decode()) - try: - assert path.endswith(".wav") - finally: - _cleanup(path) - - def test_load_file_returns_path_string(self, tmp_path): - audio_file = tmp_path / "test.wav" - audio_file.write_bytes(b"\x00" * 100) - io = _AudioFileMediaIO() - result = io.load_file(audio_file) - assert result == str(audio_file) - - -# --------------------------------------------------------------------------- -# decode_audio_url with data URLs (exercises real MediaConnector + MediaIO) -# --------------------------------------------------------------------------- - - class TestDecodeAudioUrlDataUrl: - """Tests for base64 data URL decoding — no mocks, real code path.""" + """Tests for base64 data URL decoding.""" def test_valid_mp3_data_url(self): audio_bytes = b"\xff\xfb\x90\x00" * 100 b64 = base64.b64encode(audio_bytes).decode() url = f"data:audio/mp3;base64,{b64}" - path = _run(decode_audio_url(url, _permissive_connector())) + path = _run(decode_audio_url(url)) try: assert os.path.isfile(path) assert path.endswith(".mp3") @@ -161,7 +46,7 @@ class TestDecodeAudioUrlDataUrl: b64 = base64.b64encode(audio_bytes).decode() url = f"data:audio/wav;base64,{b64}" - path = _run(decode_audio_url(url, _permissive_connector())) + path = _run(decode_audio_url(url)) try: assert path.endswith(".wav") with open(path, "rb") as f: @@ -169,103 +54,134 @@ class TestDecodeAudioUrlDataUrl: finally: _cleanup(path) + def test_valid_mpeg_data_url_uses_mp3_suffix(self): + audio_bytes = b"\xff\xfb\x90\x00" * 50 + b64 = base64.b64encode(audio_bytes).decode() + url = f"data:audio/mpeg;base64,{b64}" + + path = _run(decode_audio_url(url)) + try: + assert path.endswith(".mp3") + finally: + _cleanup(path) + + def test_valid_ogg_data_url(self): + audio_bytes = b"OggS" + b"\x00" * 100 + b64 = base64.b64encode(audio_bytes).decode() + url = f"data:audio/ogg;base64,{b64}" + + path = _run(decode_audio_url(url)) + try: + assert path.endswith(".ogg") + finally: + _cleanup(path) + def test_invalid_base64_raises(self): url = "data:audio/mp3;base64,!!!not-valid-base64!!!" - with pytest.raises(InvalidInputReferenceError, match="Incorrect padding"): - _run(decode_audio_url(url, _permissive_connector())) + + with pytest.raises(InvalidInputReferenceError, match="not valid base64"): + _run(decode_audio_url(url)) def test_empty_audio_data_raises(self): url = "data:audio/mp3;base64," + with pytest.raises(InvalidInputReferenceError, match="audio data is empty"): - _run(decode_audio_url(url, _permissive_connector())) + _run(decode_audio_url(url)) -# --------------------------------------------------------------------------- -# decode_audio_url with file:// URIs (exercises load_file through connector) -# --------------------------------------------------------------------------- +class TestDecodeAudioUrlSuffixSanitization: + """Tests for MIME extension sanitization (path traversal prevention).""" + + def test_path_traversal_in_mime_is_neutralized(self): + audio_bytes = b"\x00" * 100 + b64 = base64.b64encode(audio_bytes).decode() + url = f"data:audio/../..;base64,{b64}" + + path = _run(decode_audio_url(url)) + try: + # ".." contains non-alnum dots, so falls back to .wav + assert path.endswith(".wav") + assert os.path.dirname(path) == "/tmp" + finally: + _cleanup(path) + + def test_non_alnum_extension_falls_back_to_wav(self): + audio_bytes = b"\x00" * 100 + b64 = base64.b64encode(audio_bytes).decode() + url = f"data:audio/foo-bar;base64,{b64}" + + path = _run(decode_audio_url(url)) + try: + assert path.endswith(".wav") + finally: + _cleanup(path) + + def test_too_long_extension_falls_back_to_wav(self): + audio_bytes = b"\x00" * 100 + b64 = base64.b64encode(audio_bytes).decode() + url = f"data:audio/verylongextension;base64,{b64}" + + path = _run(decode_audio_url(url)) + try: + assert path.endswith(".wav") + finally: + _cleanup(path) + + def test_valid_short_alnum_extension_is_used(self): + audio_bytes = b"\x00" * 100 + b64 = base64.b64encode(audio_bytes).decode() + url = f"data:audio/flac;base64,{b64}" + + path = _run(decode_audio_url(url)) + try: + assert path.endswith(".flac") + finally: + _cleanup(path) -class TestDecodeAudioUrlFileUri: - """Tests using file:// URIs — exercises MediaConnector + load_file.""" +class TestDecodeAudioUrlHttp: + """Tests for HTTP URL decoding.""" - def test_file_uri_with_allowed_path(self, tmp_path): - audio_file = tmp_path / "test.wav" - audio_file.write_bytes(b"\xff\xfb\x90\x00" * 50) - file_uri = audio_file.as_uri() - connector = MediaConnector( - allowed_local_media_path=str(tmp_path), - allowed_media_domains=None, - ) - path = _run(decode_audio_url(file_uri, connector)) - assert path == str(audio_file) + def test_valid_http_url(self, monkeypatch): + audio_bytes = b"\xff\xfb\x90\x00" * 50 + fake_resp = httpx.Response(200, content=audio_bytes) - def test_file_uri_without_allowed_path_raises(self, tmp_path): - audio_file = tmp_path / "test.wav" - audio_file.write_bytes(b"\x00" * 50) - file_uri = audio_file.as_uri() - connector = MediaConnector( - allowed_local_media_path="", - allowed_media_domains=None, - ) - with pytest.raises(InvalidInputReferenceError): - _run(decode_audio_url(file_uri, connector)) + async def _mock_get(self, url): + return fake_resp - def test_file_uri_outside_allowed_path_raises(self, tmp_path): - audio_file = tmp_path / "test.wav" - audio_file.write_bytes(b"\x00" * 50) - file_uri = audio_file.as_uri() - other_dir = tmp_path / "other" - other_dir.mkdir() - connector = MediaConnector( - allowed_local_media_path=str(other_dir), - allowed_media_domains=None, - ) - with pytest.raises(InvalidInputReferenceError): - _run(decode_audio_url(file_uri, connector)) + monkeypatch.setattr(httpx.AsyncClient, "get", _mock_get) + monkeypatch.setattr(httpx.Response, "raise_for_status", lambda self: None) + path = _run(decode_audio_url("https://example.com/audio.mp3")) + try: + assert os.path.isfile(path) + assert path.endswith(".wav") + with open(path, "rb") as f: + assert f.read() == audio_bytes + finally: + _cleanup(path) -# --------------------------------------------------------------------------- -# decode_audio_url — invalid schemes and SSRF -# --------------------------------------------------------------------------- + def test_http_error_raises(self, monkeypatch): + async def _mock_get(self, url): + raise httpx.HTTPStatusError("Not Found", request=httpx.Request("GET", url), response=httpx.Response(404)) + + monkeypatch.setattr(httpx.AsyncClient, "get", _mock_get) + + with pytest.raises(InvalidInputReferenceError, match="failed to download audio"): + _run(decode_audio_url("https://example.com/missing.mp3")) class TestDecodeAudioUrlInvalid: """Tests for invalid URL schemes.""" def test_local_file_path_raises(self): - with pytest.raises(InvalidInputReferenceError, match="must be either a HTTP, data or file URL"): - _run(decode_audio_url("/path/to/local/file.mp3", _permissive_connector())) + with pytest.raises(InvalidInputReferenceError, match="must be an http.*URL or data URL"): + _run(decode_audio_url("/path/to/local/file.mp3")) def test_ftp_url_raises(self): - with pytest.raises(InvalidInputReferenceError, match="must be either a HTTP, data or file URL"): - _run(decode_audio_url("ftp://example.com/file.mp3", _permissive_connector())) + with pytest.raises(InvalidInputReferenceError, match="must be an http.*URL or data URL"): + _run(decode_audio_url("ftp://example.com/file.mp3")) def test_empty_string_raises(self): - with pytest.raises(InvalidInputReferenceError, match="must be either a HTTP, data or file URL"): - _run(decode_audio_url("", _permissive_connector())) - - -class TestDecodeAudioUrlSSRF: - """Tests for SSRF protection via MediaConnector domain restrictions.""" - - def test_restricted_domain_rejects_disallowed_url(self): - connector = _restricted_connector(allowed_domains=["trusted.example.com"]) - with pytest.raises(InvalidInputReferenceError, match="allowed domains"): - _run( - decode_audio_url( - "https://evil.internal/admin/audio.mp3", - connector, - ) - ) - - def test_data_url_bypasses_domain_restriction(self): - audio_bytes = b"\xff\xfb\x90\x00" * 50 - b64 = base64.b64encode(audio_bytes).decode() - url = f"data:audio/wav;base64,{b64}" - - connector = _restricted_connector(allowed_domains=["trusted.example.com"]) - path = _run(decode_audio_url(url, connector)) - try: - assert os.path.isfile(path) - finally: - _cleanup(path) + with pytest.raises(InvalidInputReferenceError, match="must be an http.*URL or data URL"): + _run(decode_audio_url("")) diff --git a/tests/entrypoints/openai_api/test_decode_video_url.py b/tests/entrypoints/openai_api/test_decode_video_url.py deleted file mode 100644 index 64ff7e2e0..000000000 --- a/tests/entrypoints/openai_api/test_decode_video_url.py +++ /dev/null @@ -1,210 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Unit tests for decode_video_url helper and _VideoFramesMediaIO.""" - -import asyncio -import base64 -import io - -import pytest -from PIL import Image -from vllm.multimodal.media import MediaConnector - -from vllm_omni.entrypoints.openai.errors import InvalidInputReferenceError -from vllm_omni.entrypoints.openai.video_api_utils import ( - VideoFrames, - _VideoFramesMediaIO, - decode_video_url, -) - -pytestmark = [pytest.mark.core_model, pytest.mark.cpu] - - -def _run(coro): - return asyncio.run(coro) - - -def _permissive_connector(): - return MediaConnector( - allowed_local_media_path="", - allowed_media_domains=None, - ) - - -def _restricted_connector(allowed_domains=None): - return MediaConnector( - allowed_local_media_path="", - allowed_media_domains=allowed_domains or [], - ) - - -def _make_mp4_bytes(width=4, height=4, num_frames=2): - """Create a minimal valid MP4 video using PyAV.""" - import av - - buf = io.BytesIO() - with av.open(buf, mode="w", format="mp4") as container: - stream = container.add_stream("libx264", rate=24) - stream.width = width - stream.height = height - stream.pix_fmt = "yuv420p" - stream.options = {"preset": "ultrafast"} - for i in range(num_frames): - img = Image.new("RGB", (width, height), color=(i * 40, 128, 200)) - frame = av.VideoFrame.from_image(img) - for packet in stream.encode(frame): - container.mux(packet) - for packet in stream.encode(): - container.mux(packet) - return buf.getvalue() - - -# --------------------------------------------------------------------------- -# _VideoFramesMediaIO direct tests -# --------------------------------------------------------------------------- - - -class TestVideoFramesMediaIO: - """Direct tests for _VideoFramesMediaIO methods.""" - - def test_load_bytes_decodes_video(self): - video_bytes = _make_mp4_bytes(num_frames=3) - media_io = _VideoFramesMediaIO() - result = media_io.load_bytes(video_bytes) - assert isinstance(result, VideoFrames) - assert len(result) >= 1 - assert all(isinstance(f, Image.Image) for f in result) - - def test_load_bytes_respects_max_frames(self): - video_bytes = _make_mp4_bytes(num_frames=5) - media_io = _VideoFramesMediaIO(max_frames=2) - result = media_io.load_bytes(video_bytes) - assert len(result) <= 2 - - def test_load_bytes_invalid_data_raises(self): - media_io = _VideoFramesMediaIO() - with pytest.raises(InvalidInputReferenceError, match="not a valid video"): - media_io.load_bytes(b"not a video") - - def test_load_base64_decodes_video(self): - video_bytes = _make_mp4_bytes() - b64 = base64.b64encode(video_bytes).decode() - media_io = _VideoFramesMediaIO() - result = media_io.load_base64("video/mp4", b64) - assert isinstance(result, VideoFrames) - assert len(result) >= 1 - - def test_load_file_decodes_video(self, tmp_path): - video_bytes = _make_mp4_bytes() - video_file = tmp_path / "test.mp4" - video_file.write_bytes(video_bytes) - media_io = _VideoFramesMediaIO() - result = media_io.load_file(video_file) - assert isinstance(result, VideoFrames) - assert len(result) >= 1 - - -# --------------------------------------------------------------------------- -# decode_video_url with data URLs (real MediaConnector + MediaIO) -# --------------------------------------------------------------------------- - - -class TestDecodeVideoUrlDataUrl: - """Tests for data URL decoding — no mocks, real code path.""" - - def test_valid_video_data_url(self): - video_bytes = _make_mp4_bytes() - b64 = base64.b64encode(video_bytes).decode() - url = f"data:video/mp4;base64,{b64}" - - result = _run(decode_video_url(url, _permissive_connector())) - assert isinstance(result, VideoFrames) - assert len(result) >= 1 - assert all(isinstance(f, Image.Image) for f in result) - - def test_invalid_video_data_url_raises(self): - b64 = base64.b64encode(b"not a video").decode() - url = f"data:video/mp4;base64,{b64}" - with pytest.raises(InvalidInputReferenceError, match="not a valid video"): - _run(decode_video_url(url, _permissive_connector())) - - -# --------------------------------------------------------------------------- -# decode_video_url with file:// URIs (exercises load_file through connector) -# --------------------------------------------------------------------------- - - -class TestDecodeVideoUrlFileUri: - """Tests using file:// URIs — exercises MediaConnector + load_file.""" - - def test_file_uri_with_allowed_path(self, tmp_path): - video_file = tmp_path / "test.mp4" - video_file.write_bytes(_make_mp4_bytes()) - file_uri = video_file.as_uri() - connector = MediaConnector( - allowed_local_media_path=str(tmp_path), - allowed_media_domains=None, - ) - result = _run(decode_video_url(file_uri, connector)) - assert isinstance(result, VideoFrames) - assert len(result) >= 1 - - def test_file_uri_without_allowed_path_raises(self, tmp_path): - video_file = tmp_path / "test.mp4" - video_file.write_bytes(_make_mp4_bytes()) - file_uri = video_file.as_uri() - connector = MediaConnector( - allowed_local_media_path="", - allowed_media_domains=None, - ) - with pytest.raises(InvalidInputReferenceError): - _run(decode_video_url(file_uri, connector)) - - -# --------------------------------------------------------------------------- -# decode_video_url — invalid schemes and SSRF -# --------------------------------------------------------------------------- - - -class TestDecodeVideoUrlInvalid: - """Tests for invalid URL schemes.""" - - def test_local_file_path_raises(self): - with pytest.raises(InvalidInputReferenceError, match="must be either a HTTP, data or file URL"): - _run(decode_video_url("/path/to/local/file.mp4", _permissive_connector())) - - def test_ftp_url_raises(self): - with pytest.raises(InvalidInputReferenceError, match="must be either a HTTP, data or file URL"): - _run(decode_video_url("ftp://example.com/file.mp4", _permissive_connector())) - - def test_empty_string_raises(self): - with pytest.raises(InvalidInputReferenceError, match="must be either a HTTP, data or file URL"): - _run(decode_video_url("", _permissive_connector())) - - -class TestDecodeVideoUrlSSRF: - """Tests for SSRF protection via MediaConnector domain restrictions.""" - - def test_restricted_domain_rejects_disallowed_url(self): - connector = _restricted_connector( - allowed_domains=["trusted.example.com"], - ) - with pytest.raises(InvalidInputReferenceError, match="allowed domains"): - _run( - decode_video_url( - "https://evil.internal/admin/video.mp4", - connector, - ) - ) - - def test_data_url_bypasses_domain_restriction(self): - video_bytes = _make_mp4_bytes() - b64 = base64.b64encode(video_bytes).decode() - url = f"data:video/mp4;base64,{b64}" - - connector = _restricted_connector( - allowed_domains=["trusted.example.com"], - ) - result = _run(decode_video_url(url, connector)) - assert isinstance(result, VideoFrames) - assert len(result) >= 1 diff --git a/tests/entrypoints/openai_api/test_image_server.py b/tests/entrypoints/openai_api/test_image_server.py index 49c43d7a9..31a82102a 100644 --- a/tests/entrypoints/openai_api/test_image_server.py +++ b/tests/entrypoints/openai_api/test_image_server.py @@ -13,7 +13,6 @@ import json from argparse import Namespace from http import HTTPStatus from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock import pytest from fastapi import FastAPI, HTTPException @@ -25,19 +24,12 @@ from vllm.entrypoints.openai.models.protocol import BaseModelPath from vllm.sampling_params import RequestOutputKind from vllm_omni.entrypoints.async_omni import AsyncOmni -from vllm_omni.entrypoints.openai.api_server import ( - _check_max_generated_image_size, - _DiffusionServingModels, - _load_input_images, - router, -) +from vllm_omni.entrypoints.openai.api_server import _check_max_generated_image_size, _DiffusionServingModels, router from vllm_omni.entrypoints.openai.image_api_utils import ( encode_image_base64, parse_size, ) -from vllm_omni.entrypoints.openai.protocol.videos import UrlImageReference from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat -from vllm_omni.entrypoints.openai.video_api_utils import decode_input_reference from vllm_omni.errors import GuardrailViolationError from vllm_omni.inputs.data import OmniDiffusionSamplingParams @@ -190,7 +182,6 @@ def mock_async_diffusion(mocker: MockerFixture): self.captured_sampling_params_list = None self.captured_prompt = None self.generate_calls = 0 - self.model_config = SimpleNamespace(allowed_local_media_path="", allowed_media_domains=None) async def generate(self, **kwargs): self.generate_calls += 1 @@ -280,10 +271,6 @@ def async_omni_test_client(): def get_diffusion_od_config(self): return self.od_config - @property - def model_config(self): - return SimpleNamespace(allowed_local_media_path="", allowed_media_domains=None) - app = FastAPI() app.include_router(router) @@ -348,10 +335,6 @@ def async_omni_rgba_test_client(): def get_diffusion_od_config(self): return self.od_config - @property - def model_config(self): - return SimpleNamespace(allowed_local_media_path="", allowed_media_domains=None) - app = FastAPI() app.include_router(router) @@ -416,10 +399,6 @@ def async_omni_stage_configs_only_client(): def get_diffusion_od_config(self): return self.od_config - @property - def model_config(self): - return SimpleNamespace(allowed_local_media_path="", allowed_media_domains=None) - app = FastAPI() app.include_router(router) @@ -456,24 +435,9 @@ def streaming_image_edit_client(): SamplingParams(temperature=0.1), OmniDiffusionSamplingParams(), ] - stage_clients = [ - SimpleNamespace(stage_type="llm", is_comprehension=True), - SimpleNamespace(stage_type="diffusion", is_comprehension=False), - ] - stage_vllm_configs = [ - SimpleNamespace( - model_config=SimpleNamespace( - allowed_local_media_path="", - allowed_media_domains=None, - ), - ), - None, - ] self.engine = SimpleNamespace( stage_configs=stage_configs, default_sampling_params_list=default_sampling_params_list, - stage_clients=stage_clients, - stage_vllm_configs=stage_vllm_configs, ) self.default_sampling_params_list = default_sampling_params_list self.captured_sampling_params_list = None @@ -765,10 +729,6 @@ def test_generate_images_async_omni_glm_image_sets_stage0_max_tokens(): def get_diffusion_od_config(self): return self.od_config - @property - def model_config(self): - return SimpleNamespace(allowed_local_media_path="", allowed_media_domains=None) - app = FastAPI() app.include_router(router) engine = FakeAsyncOmniClass() @@ -2176,107 +2136,3 @@ def test_image_edits_size_auto_preserves_bridge_size(async_omni_stage_configs_on assert captured_prompt["prompt"].count("") == 2, ( f"N=2 reference images must emit 2 placeholders in AR prompt; got {captured_prompt[KEY].count(IMG)} -- prompt: {captured_prompt[KEY]!r}" ) - - -# --------------------------------------------------------------------------- -# MediaConnector / SSRF protection tests -# --------------------------------------------------------------------------- - - -_DATA_IMAGE_URL = f"data:image/png;base64,{base64.b64encode(make_test_image_bytes((8, 8))).decode()}" - - -def _make_upload_image(): - upload = MagicMock() - upload.file = True - upload.read = AsyncMock(return_value=make_test_image_bytes((8, 8))) - return upload - - -class TestLoadInputImagesMediaConnector: - """SSRF protection via MediaConnector for _load_input_images and - decode_input_reference.""" - - @staticmethod - def _config( - domains=None, # allow all domains - local_path="", # disallow all local paths - ): - return SimpleNamespace( - allowed_local_media_path=local_path, - allowed_media_domains=domains, - ) - - @pytest.mark.parametrize( - "domains, url", - [ - # restricted + data url from allowlisted domain → accept (data: bypasses domain check) - (["example.com"], _DATA_IMAGE_URL), - # unrestricted + data url → accept - (None, _DATA_IMAGE_URL), - # restricted + file upload → accept (UploadFile bypasses MediaConnector) - (["example.com"], _make_upload_image()), - ], - ids=[ - "restricted-data-url", - "unrestricted-data-url", - "restricted-file-upload", - ], - ) - @pytest.mark.asyncio - async def test_accepted_inputs(self, domains, url): - config = self._config(domains=domains) - - images = await _load_input_images([url], config) - assert len(images) == 1 - assert isinstance(images[0], Image.Image) - - if isinstance(url, str): - ref = UrlImageReference(image_url=url) - image = await decode_input_reference(ref, None, None, config) - assert isinstance(image, Image.Image) - - @pytest.mark.asyncio - async def test_http_url_rejected_by_domain_filter(self): - """An http(s) URL whose domain is not in the allowlist must be rejected.""" - config = self._config(domains=["example.com"]) - http_url = "https://blocked.example.org/image.png" - - with pytest.raises(ValueError): - await _load_input_images([http_url], config) - - ref = UrlImageReference(image_url=http_url) - with pytest.raises(ValueError): - await decode_input_reference(ref, None, None, config) - - @pytest.mark.parametrize( - "allow_path", - [True, False], - ids=["allowed-path", "disallowed-path"], - ) - @pytest.mark.asyncio - async def test_file_uri_path_restriction(self, tmp_path, allow_path): - """file:// URIs are gated by allowed_local_media_path.""" - - img_path = tmp_path / "test.png" - img_path.write_bytes(make_test_image_bytes((8, 8))) - file_uri = img_path.as_uri() - config = self._config(local_path=str(tmp_path) if allow_path else "") - - # --- _load_input_images --- - if allow_path: - images = await _load_input_images([file_uri], config) - assert len(images) == 1 - assert isinstance(images[0], Image.Image) - else: - with pytest.raises(ValueError): - await _load_input_images([file_uri], config) - - # --- decode_input_reference --- - ref = UrlImageReference(image_url=file_uri) - if allow_path: - image = await decode_input_reference(ref, None, None, config) - assert isinstance(image, Image.Image) - else: - with pytest.raises(ValueError): - await decode_input_reference(ref, None, None, config) diff --git a/tests/entrypoints/openai_api/test_materialize_media_source.py b/tests/entrypoints/openai_api/test_materialize_media_source.py deleted file mode 100644 index a0ce824b3..000000000 --- a/tests/entrypoints/openai_api/test_materialize_media_source.py +++ /dev/null @@ -1,173 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Unit tests for audiox materialize_media_source and _TempFileMediaIO.""" - -import base64 -import os -from types import SimpleNamespace -from unittest.mock import patch - -import pytest - -from vllm_omni.transformers_utils.processors.audiox import ( - _TempFileMediaIO, - materialize_media_source, -) - -pytestmark = [pytest.mark.core_model, pytest.mark.cpu] - - -def _cleanup(path): - if path and os.path.exists(path): - os.unlink(path) - - -def _fake_forward_context(allowed_domains=None, allowed_local_media_path=""): - """Return a fake ForwardContext with model_config for SSRF testing.""" - return SimpleNamespace( - vllm_config=SimpleNamespace( - model_config=SimpleNamespace( - allowed_local_media_path=allowed_local_media_path, - allowed_media_domains=allowed_domains, - ), - ), - ) - - -# --------------------------------------------------------------------------- -# _TempFileMediaIO direct tests -# --------------------------------------------------------------------------- - - -class TestTempFileMediaIO: - """Direct tests for _TempFileMediaIO methods.""" - - def test_load_bytes_writes_temp_file(self): - data = b"\x00\x01\x02" * 50 - io = _TempFileMediaIO() - path = io.load_bytes(data) - try: - assert os.path.isfile(path) - assert path.endswith(".bin") - with open(path, "rb") as f: - assert f.read() == data - finally: - _cleanup(path) - - def test_load_base64_writes_temp_file(self): - data = b"\xff" * 100 - b64 = base64.b64encode(data).decode() - io = _TempFileMediaIO() - path = io.load_base64("video/mp4", b64) - try: - assert os.path.isfile(path) - with open(path, "rb") as f: - assert f.read() == data - finally: - _cleanup(path) - - def test_load_file_returns_path_string(self, tmp_path): - media_file = tmp_path / "video.mp4" - media_file.write_bytes(b"\x00" * 50) - io = _TempFileMediaIO() - result = io.load_file(media_file) - assert result == str(media_file) - - -# --------------------------------------------------------------------------- -# materialize_media_source -# --------------------------------------------------------------------------- - - -class TestMaterializeMediaSource: - """Tests for materialize_media_source — exercises real MediaConnector.""" - - def test_data_url_with_no_vllm_config(self): - data = b"\x00" * 50 - b64 = base64.b64encode(data).decode() - url = f"data:audio/wav;base64,{b64}" - ctx = SimpleNamespace(vllm_config=None) - - with patch( - "vllm_omni.diffusion.forward_context.get_forward_context", - return_value=ctx, - ): - path = materialize_media_source(url) - try: - assert os.path.isfile(path) - finally: - _cleanup(path) - - def test_data_url_materializes_to_temp_file(self): - data = b"\x00\x01\x02" * 50 - b64 = base64.b64encode(data).decode() - url = f"data:audio/wav;base64,{b64}" - - with patch( - "vllm_omni.diffusion.forward_context.get_forward_context", - return_value=_fake_forward_context(), - ): - path = materialize_media_source(url) - try: - assert os.path.isfile(path) - with open(path, "rb") as f: - assert f.read() == data - finally: - _cleanup(path) - - def test_local_path_with_allowed_path(self, tmp_path): - local_file = tmp_path / "media.bin" - local_file.write_bytes(b"\x00" * 50) - ctx = _fake_forward_context(allowed_local_media_path=str(tmp_path)) - with patch( - "vllm_omni.diffusion.forward_context.get_forward_context", - return_value=ctx, - ): - path = materialize_media_source(str(local_file)) - assert path == str(local_file) - - def test_local_path_without_allowed_path_raises(self, tmp_path): - local_file = tmp_path / "media.bin" - local_file.write_bytes(b"\x00" * 50) - ctx = _fake_forward_context(allowed_local_media_path="") - with patch( - "vllm_omni.diffusion.forward_context.get_forward_context", - return_value=ctx, - ): - with pytest.raises(RuntimeError, match="allowed-local-media-path"): - materialize_media_source(str(local_file)) - - def test_http_url_rejected_by_domain_filter(self): - ctx = _fake_forward_context(allowed_domains=["trusted.example.com"]) - with patch( - "vllm_omni.diffusion.forward_context.get_forward_context", - return_value=ctx, - ): - with pytest.raises(ValueError, match="allowed domains"): - materialize_media_source("https://evil.internal/media.bin") - - def test_file_uri_with_allowed_path(self, tmp_path): - media_file = tmp_path / "audio.wav" - media_file.write_bytes(b"\x00" * 50) - file_uri = media_file.as_uri() - - ctx = _fake_forward_context(allowed_local_media_path=str(tmp_path)) - with patch( - "vllm_omni.diffusion.forward_context.get_forward_context", - return_value=ctx, - ): - path = materialize_media_source(file_uri) - assert path == str(media_file) - - def test_file_uri_without_allowed_path_raises(self, tmp_path): - media_file = tmp_path / "audio.wav" - media_file.write_bytes(b"\x00" * 50) - file_uri = media_file.as_uri() - - ctx = _fake_forward_context(allowed_local_media_path="") - with patch( - "vllm_omni.diffusion.forward_context.get_forward_context", - return_value=ctx, - ): - with pytest.raises(RuntimeError, match="allowed-local-media-path"): - materialize_media_source(file_uri) diff --git a/tests/entrypoints/openai_api/test_video_server.py b/tests/entrypoints/openai_api/test_video_server.py index c4ac824da..3f1c5f8e5 100644 --- a/tests/entrypoints/openai_api/test_video_server.py +++ b/tests/entrypoints/openai_api/test_video_server.py @@ -68,10 +68,6 @@ class FakeAsyncOmni: self.default_sampling_params_list = [OmniDiffusionSamplingParams()] self.captured_prompt = None self.captured_sampling_params_list = None - self.model_config = SimpleNamespace( - allowed_local_media_path="", - allowed_media_domains=None, - ) async def generate(self, prompt, request_id, sampling_params_list): self.captured_prompt = prompt @@ -87,12 +83,6 @@ class BlockingVideoHandler: self.stage_configs = None self.started = threading.Event() self.cancelled = threading.Event() - self._engine_client = SimpleNamespace( - model_config=SimpleNamespace( - allowed_local_media_path="", - allowed_media_domains=None, - ), - ) def set_stage_configs_if_missing(self, stage_configs): if self.stage_configs is None: @@ -1869,77 +1859,3 @@ def test_worker_fps_multiplier_is_applied_to_sync_encoding(test_client, mocker: assert response.status_code == 200 assert response.content == b"fps-multiplied" assert fps_values == [16] - - -# --------------------------------------------------------------------------- -# MediaConnector / SSRF protection (end-to-end through /v1/videos) -# --------------------------------------------------------------------------- - - -@pytest.fixture -def restricted_test_client(): - """Test client whose engine restricts media to trusted.example.com.""" - engine = FakeAsyncOmni() - engine.model_config = SimpleNamespace( - allowed_local_media_path="", - allowed_media_domains=["trusted.example.com"], - ) - app = FastAPI() - app.include_router(router) - app.state.openai_serving_video = OmniOpenAIServingVideo.for_diffusion( - diffusion_engine=engine, - model_name="Wan-AI/Wan2.2-T2V-A14B-Diffusers", - ) - with TestClient(app) as client: - yield client - - -class TestVideoEndpointSSRF: - """SSRF protection via /v1/videos endpoint with domain-restricted engine.""" - - def test_blocked_image_reference_url_returns_400(self, restricted_test_client): - response = restricted_test_client.post( - "/v1/videos", - data={ - "prompt": "A fox.", - "image_reference": json.dumps({"image_url": "https://evil.internal/image.png"}), - }, - ) - assert response.status_code == 400 - assert "allowed domains" in response.json()["detail"].lower() - - def test_blocked_video_reference_url_returns_400(self, restricted_test_client): - response = restricted_test_client.post( - "/v1/videos", - data={ - "prompt": "Continue.", - "video_reference": json.dumps({"video_url": "https://evil.internal/video.mp4"}), - }, - ) - assert response.status_code == 400 - assert "allowed domains" in response.json()["detail"].lower() - - def test_blocked_audio_reference_url_returns_400(self, restricted_test_client): - response = restricted_test_client.post( - "/v1/videos", - data={ - "prompt": "Narrate.", - "audio_reference": json.dumps({"audio_url": "https://evil.internal/audio.mp3"}), - }, - ) - assert response.status_code == 400 - assert "allowed domains" in response.json()["detail"].lower() - - def test_data_url_bypasses_domain_restriction(self, restricted_test_client, mocker: MockerFixture): - mocker.patch( - "vllm_omni.entrypoints.openai.serving_video._encode_video_bytes", - return_value=b"fake-video", - ) - response = restricted_test_client.post( - "/v1/videos", - data={ - "prompt": "A fox.", - "image_reference": json.dumps({"image_url": _make_test_image_data_url((40, 24))}), - }, - ) - assert response.status_code == 200 diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py index 2c6a3d87a..3560f26b8 100755 --- a/vllm_omni/entrypoints/openai/api_server.py +++ b/vllm_omni/entrypoints/openai/api_server.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project import asyncio +import base64 import dataclasses import io import json @@ -19,6 +20,7 @@ from numbers import Integral from pathlib import Path from typing import Annotated, Any, Literal, cast +import httpx import numpy as np import vllm.envs as envs from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile, WebSocket @@ -83,7 +85,6 @@ from vllm.entrypoints.speech_to_text.translation.serving import ( OpenAIServingTranslation, ) from vllm.logger import init_logger -from vllm.multimodal.media import MediaConnector from vllm.tasks import POOLING_TASKS from vllm.tool_parsers import ToolParserManager from vllm.utils import random_uuid @@ -2036,23 +2037,17 @@ async def edit_images( # Match the offline path: RGB normalize when the caller opts into # Hunyuan-aware behavior. RGBA/P uploads otherwise diverge from offline. normalize_edit_images_rgb = bot_task is not None or sys_type is not None - pil_images = await _load_input_images( - input_images_list, - engine_client.model_config, - normalize_rgb=normalize_edit_images_rgb, - ) + pil_images = await _load_input_images(input_images_list, normalize_rgb=normalize_edit_images_rgb) prompt["multi_modal_data"] = {} prompt["multi_modal_data"]["image"] = pil_images if mask_image is not None: # Mask role is different (alpha channel matters); never normalize. - loaded = await _load_input_images([mask_image], engine_client.model_config, normalize_rgb=False) + loaded = await _load_input_images([mask_image], normalize_rgb=False) prompt["multi_modal_data"]["mask_image"] = loaded[0] if reference_image is not None: - loaded = await _load_input_images( - [reference_image], engine_client.model_config, normalize_rgb=normalize_edit_images_rgb - ) + loaded = await _load_input_images([reference_image], normalize_rgb=normalize_edit_images_rgb) prompt["multi_modal_data"]["reference_image"] = loaded[0] # 3 Build sample params @@ -2539,41 +2534,41 @@ def _extract_images_from_result(result: Any) -> list[Any]: async def _load_input_images( inputs: list[str], - model_config: Any, *, normalize_rgb: bool = True, ) -> list[Image.Image]: - """Load images from data URIs, http(s) URLs, file:// URIs, or UploadFiles. - - http(s) and ``file:`` URLs are fetched through vLLM's ``MediaConnector`` which - respects ``--allowed-media-domains`` and ``--allowed-local-media-path`` - to prevent SSRF. """ - + convert to PIL.Image.Image list + """ if isinstance(inputs, str): inputs = [inputs] - connector = MediaConnector( - allowed_local_media_path=model_config.allowed_local_media_path, - allowed_media_domains=model_config.allowed_media_domains, - ) - images: list[Image.Image] = [] for inp in inputs: - if isinstance(inp, str): - # http(s), data: and file: all handled by MediaConnector + # 1. URL + base64 + if isinstance(inp, str) and inp.startswith("data:image"): try: - # Use RGBA to preserve alpha for transparent PNG. - result = await connector.fetch_image_async( - inp, - image_mode="RGBA", - ) - images.append(result.media) + _, b64_data = inp.split(",", 1) + image_bytes = base64.b64decode(b64_data) + img = Image.open(io.BytesIO(image_bytes)) + images.append(img) except Exception as e: - raise ValueError(f"Failed to load image: {e}") from e + raise ValueError(f"Invalid base64 image: {e}") + + # 2. URL + elif isinstance(inp, str) and inp.startswith("http"): + async with httpx.AsyncClient(timeout=60) as client: + try: + resp = await client.get(inp) + resp.raise_for_status() + img = Image.open(io.BytesIO(resp.content)) + images.append(img) + except Exception as e: + raise ValueError(f"Failed to download image from URL {inp}: {e}") + + # 3. UploadFile elif hasattr(inp, "file"): - # UploadFile try: img_data = await inp.read() img = Image.open(io.BytesIO(img_data)) @@ -3025,7 +3020,6 @@ async def _parse_video_form( request.image_reference, request.video_reference, input_reference_bytes, - model_config=handler._engine_client.model_config, max_video_frames=decode_spec.max_frames, video_keep=decode_spec.keep, ) @@ -3037,15 +3031,8 @@ async def _parse_video_form( reference_audio: ReferenceAudio | None = None if request.audio_reference is not None: - audio_connector = MediaConnector( - allowed_local_media_path=handler._engine_client.model_config.allowed_local_media_path, - allowed_media_domains=handler._engine_client.model_config.allowed_media_domains, - ) try: - audio_path = await decode_audio_url( - request.audio_reference.audio_url, - audio_connector, - ) + audio_path = await decode_audio_url(request.audio_reference.audio_url) except InvalidInputReferenceError as exc: raise HTTPException(400, detail=str(exc)) from exc reference_audio = ReferenceAudio(path=audio_path) diff --git a/vllm_omni/entrypoints/openai/video_api_utils.py b/vllm_omni/entrypoints/openai/video_api_utils.py index ee5791dee..aa6479189 100644 --- a/vllm_omni/entrypoints/openai/video_api_utils.py +++ b/vllm_omni/entrypoints/openai/video_api_utils.py @@ -10,14 +10,12 @@ import base64 import binascii from collections import deque from io import BytesIO -from pathlib import Path from typing import Any, Literal +import httpx import numpy as np import torch from PIL import Image, UnidentifiedImageError -from vllm.multimodal.media import MediaConnector -from vllm.multimodal.media.base import MediaIO from vllm_omni.entrypoints.openai.errors import InvalidInputReferenceError from vllm_omni.entrypoints.openai.protocol.videos import ( @@ -147,12 +145,22 @@ def _decode_base64_image(input_reference: str, *, source: str) -> Image.Image: raise InvalidInputReferenceError(f"Invalid {source}: image data is empty.") -async def decode_image_url(image_url: str, connector: MediaConnector) -> Image.Image: - """Fetch and decode an image URL using MediaConnector for SSRF protection.""" - try: - return (await connector.fetch_image_async(image_url)).media - except Exception as exc: - raise InvalidInputReferenceError(f"Invalid image_reference.image_url: {exc}") from exc +async def decode_image_url(image_url: str) -> Image.Image: + if image_url.startswith("data:image"): + return _decode_base64_image(image_url, source="image_reference.image_url") + + if image_url.startswith(("http://", "https://")): + async with httpx.AsyncClient(timeout=60) as client: + try: + response = await client.get(image_url) + response.raise_for_status() + except httpx.HTTPError as exc: + raise InvalidInputReferenceError( + "Invalid image_reference.image_url: failed to download image." + ) from exc + return _decode_image_bytes(response.content, source="image_reference.image_url") + + raise InvalidInputReferenceError("Invalid image_reference.image_url: must be an http(s) URL or data URL.") def _decode_base64_video( @@ -176,117 +184,96 @@ def _decode_base64_video( raise InvalidInputReferenceError(f"Invalid {source}: video data is empty.") -class _VideoFramesMediaIO(MediaIO[VideoFrames]): - """MediaIO that decodes video bytes into VideoFrames with frame extraction.""" - - def __init__( - self, - *, - source: str = "video_reference.video_url", - max_frames: int | None = None, - keep: Literal["first", "last"] = "first", - ) -> None: - self._source = source - self._max_frames = max_frames - self._keep = keep - - def load_bytes(self, data: bytes) -> VideoFrames: - return _decode_video_bytes( - data, - source=self._source, - max_frames=self._max_frames, - keep=self._keep, +async def decode_video_url( + video_url: str, + *, + max_frames: int | None = None, + keep: Literal["first", "last"] = "first", +) -> VideoFrames: + if video_url.startswith("data:video"): + return _decode_base64_video( + video_url, + source="video_reference.video_url", + max_frames=max_frames, + keep=keep, ) - def load_base64(self, media_type: str, data: str) -> VideoFrames: - return self.load_bytes(base64.b64decode(data)) + if video_url.startswith(("http://", "https://")): + async with httpx.AsyncClient(timeout=60) as client: + try: + response = await client.get(video_url) + response.raise_for_status() + except httpx.HTTPError as exc: + raise InvalidInputReferenceError( + "Invalid video_reference.video_url: failed to download video." + ) from exc + return _decode_video_bytes( + response.content, + source="video_reference.video_url", + max_frames=max_frames, + keep=keep, + ) - def load_file(self, filepath: Path) -> VideoFrames: - return self.load_bytes(filepath.read_bytes()) + raise InvalidInputReferenceError("Invalid video_reference.video_url: must be an http(s) URL or data URL.") -class _AudioFileMediaIO(MediaIO[str]): - """MediaIO that writes audio bytes to a temp file, returns the path.""" +async def decode_audio_url(audio_url: str) -> str: + """Decode an audio URL or data-URL to a temporary file path.""" + import tempfile - def load_bytes(self, data: bytes) -> str: - return self._write_temp(data, suffix=".wav") + audio_bytes: bytes | None = None - def load_base64(self, media_type: str, data: str) -> str: - suffix = ".wav" - ext = media_type.split("/")[-1] if media_type else "" + if audio_url.startswith("data:audio"): + _, b64_data = audio_url.split(",", 1) + try: + audio_bytes = base64.b64decode(b64_data) + except (binascii.Error, ValueError) as exc: + raise InvalidInputReferenceError( + "Invalid audio_reference.audio_url: audio data is not valid base64." + ) from exc + elif audio_url.startswith(("http://", "https://")): + async with httpx.AsyncClient(timeout=60) as client: + try: + response = await client.get(audio_url) + response.raise_for_status() + except httpx.HTTPError as exc: + raise InvalidInputReferenceError( + "Invalid audio_reference.audio_url: failed to download audio." + ) from exc + audio_bytes = response.content + else: + raise InvalidInputReferenceError("Invalid audio_reference.audio_url: must be an http(s) URL or data URL.") + + if not audio_bytes: + raise InvalidInputReferenceError("Invalid audio_reference: audio data is empty.") + + suffix = ".wav" + if audio_url.startswith("data:audio/"): + mime = audio_url.split(";")[0].removeprefix("data:") + ext = mime.split("/")[-1] if ext in ("mpeg", "mp3"): suffix = ".mp3" elif ext == "wav": suffix = ".wav" elif ext.isalnum() and len(ext) <= 8: suffix = f".{ext}" - return self._write_temp(base64.b64decode(data), suffix=suffix) - def load_file(self, filepath: Path) -> str: - return str(filepath) - - @staticmethod - def _write_temp(data: bytes, *, suffix: str) -> str: - import tempfile - - if not data: - raise InvalidInputReferenceError("Invalid audio_reference: audio data is empty.") - tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) - tmp.write(data) - tmp.close() - return tmp.name - - -async def decode_video_url( - video_url: str, - connector: MediaConnector, - *, - max_frames: int | None = None, - keep: Literal["first", "last"] = "first", -) -> VideoFrames: - """Fetch and decode a video URL using MediaConnector for SSRF protection.""" - try: - return await connector.load_from_url_async( - video_url, - _VideoFramesMediaIO(max_frames=max_frames, keep=keep), - ) - except InvalidInputReferenceError: - raise - except Exception as exc: - raise InvalidInputReferenceError(f"Invalid video_reference.video_url: {exc}") from exc - - -async def decode_audio_url( - audio_url: str, - connector: MediaConnector, -) -> str: - """Fetch and decode an audio URL using MediaConnector for SSRF protection.""" - try: - return await connector.load_from_url_async( - audio_url, - _AudioFileMediaIO(), - ) - except InvalidInputReferenceError: - raise - except Exception as exc: - raise InvalidInputReferenceError(f"Invalid audio_reference.audio_url: {exc}") from exc + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) + tmp.write(audio_bytes) + tmp.close() + return tmp.name async def decode_input_reference( image_reference: ImageReference | None, video_reference: VideoReference | None, input_reference_bytes: bytes | None, - model_config: Any, *, max_video_frames: int | None = None, video_keep: Literal["first", "last"] = "first", ) -> Image.Image | VideoFrames | None: - """Decode media input from multipart bytes, data URLs, or typed references. + """Decode media input from multipart bytes, data URLs, or typed references.""" - http(s) image and video URLs are fetched through vLLM's - ``MediaConnector`` which respects ``--allowed-media-domains`` and - ``--allowed-local-media-path`` to prevent SSRF. - """ provided = sum(item is not None for item in (input_reference_bytes, image_reference, video_reference)) if provided > 1: raise InvalidInputReferenceError("Provide only one of input_reference, image_reference, or video_reference.") @@ -300,22 +287,13 @@ async def decode_input_reference( ) if isinstance(image_reference, UrlImageReference): - connector = MediaConnector( - allowed_local_media_path=model_config.allowed_local_media_path, - allowed_media_domains=model_config.allowed_media_domains, - ) - return await decode_image_url(image_reference.image_url, connector) + return await decode_image_url(image_reference.image_url) elif isinstance(image_reference, FileImageReference): raise InvalidInputReferenceError("Invalid image_reference: file_id is not supported yet.") if isinstance(video_reference, UrlVideoReference): - connector = MediaConnector( - allowed_local_media_path=model_config.allowed_local_media_path, - allowed_media_domains=model_config.allowed_media_domains, - ) return await decode_video_url( video_reference.video_url, - connector, max_frames=max_video_frames, keep=video_keep, ) diff --git a/vllm_omni/transformers_utils/processors/audiox.py b/vllm_omni/transformers_utils/processors/audiox.py index 8839ffac9..b0e53cfc7 100644 --- a/vllm_omni/transformers_utils/processors/audiox.py +++ b/vllm_omni/transformers_utils/processors/audiox.py @@ -14,9 +14,9 @@ from __future__ import annotations import base64 import os import tempfile -from pathlib import Path from typing import Any from urllib.parse import urlparse +from urllib.request import urlopen import av import numpy as np @@ -26,8 +26,6 @@ import torch.nn.functional as F import torchaudio.functional as taF from einops import rearrange from torchvision.io import read_image -from vllm.multimodal.media import MediaConnector -from vllm.multimodal.media.base import MediaIO # AudioX task taxonomy. Tasks beginning with "v" require a video input; tasks containing # "t" carry a text prompt. tv2*/v2* share the same conditioning pathways. @@ -53,51 +51,29 @@ def normalize_prompts(prompts: list[Any]) -> list[dict[str, Any]]: return out -class _TempFileMediaIO(MediaIO[str]): - """MediaIO that writes fetched bytes to a temp file, returns the path.""" - - def load_bytes(self, data: bytes) -> str: - return self._write_temp(data) - - def load_base64(self, media_type: str, data: str) -> str: - return self._write_temp(base64.b64decode(data)) - - def load_file(self, filepath: Path) -> str: - return str(filepath) - - @staticmethod - def _write_temp(data: bytes) -> str: - f = tempfile.NamedTemporaryFile(prefix="audiox_media_", suffix=".bin", delete=False) - f.write(data) - f.close() - return f.name - - -def _get_media_connector() -> MediaConnector: - """Build a MediaConnector from the current forward context's model config.""" - from vllm_omni.diffusion.forward_context import get_forward_context - - ctx = get_forward_context() - model_config = ctx.vllm_config.model_config if ctx.vllm_config else None - return MediaConnector( - allowed_local_media_path=getattr(model_config, "allowed_local_media_path", ""), - allowed_media_domains=getattr(model_config, "allowed_media_domains", None), - ) - - def materialize_media_source(source: str) -> str: """Return a local filesystem path for ``source``. Accepts a local path, a ``data:;base64,...`` URI, or an ``http(s)://`` URL. - All inputs are routed through vLLM's ``MediaConnector`` which respects - ``--allowed-media-domains`` and ``--allowed-local-media-path`` to prevent - SSRF. Bare local paths are converted to ``file://`` URIs so that the - same path restriction applies. + Anything non-local is fetched into a NamedTemporaryFile and that path is returned; + callers don't need to clean the tempfile up (the OS does on exit). """ - if not urlparse(source).scheme: - source = Path(source).resolve().as_uri() - connector = _get_media_connector() - return connector.load_from_url(source, _TempFileMediaIO()) + if source.startswith("data:"): + _, _, payload = source.partition(",") + raw = base64.b64decode(payload) + f = tempfile.NamedTemporaryFile(prefix="audiox_media_", suffix=".bin", delete=False) + f.write(raw) + f.close() + return f.name + parsed = urlparse(source) + if parsed.scheme in ("http", "https"): + with urlopen(source) as resp: + data = resp.read() + f = tempfile.NamedTemporaryFile(prefix="audiox_media_", suffix=".bin", delete=False) + f.write(data) + f.close() + return f.name + return source def _load_video_path_pyav(