From 8f812f5636877e9717ca6e4ac8de43e31e998918 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Mon, 24 Aug 2026 17:10:44 -0400 Subject: [PATCH 1/6] feat(hosted_vllm): add vLLM-Omni videos API Route hosted_vllm video generation through /v1/videos as multipart form data so Omni extra fields such as width and extra_params reach the server instead of a JSON body Omni rejects --- .../llms/hosted_vllm/videos/transformation.py | 161 +++++++++++ .../provider_endpoints_support_backup.json | 3 +- litellm/utils.py | 4 + provider_endpoints_support.json | 3 +- .../test_hosted_vllm_video_transformation.py | 255 ++++++++++++++++++ 5 files changed, 424 insertions(+), 2 deletions(-) create mode 100644 litellm/llms/hosted_vllm/videos/transformation.py create mode 100644 tests/test_litellm/llms/hosted_vllm/videos/test_hosted_vllm_video_transformation.py diff --git a/litellm/llms/hosted_vllm/videos/transformation.py b/litellm/llms/hosted_vllm/videos/transformation.py new file mode 100644 index 00000000000..4f3957f0d93 --- /dev/null +++ b/litellm/llms/hosted_vllm/videos/transformation.py @@ -0,0 +1,161 @@ +"""Video generation for Hosted VLLM (vLLM-Omni OpenAI-compatible /v1/videos).""" + +import json +import mimetypes +from collections.abc import Mapping +from io import BufferedReader +from typing import Final + +from httpx._types import FileTypes, RequestFiles + +from litellm.images.utils import ImageEditRequestUtils +from litellm.llms.openai.videos.transformation import OpenAIVideoConfig +from litellm.secret_managers.main import get_secret_str +from litellm.types.router import GenericLiteLLMParams +from litellm.types.videos.main import VideoCreateOptionalRequestParams + +_EXCLUDED_FORM_KEYS: Final = frozenset( + { + "model", + "prompt", + "extra_headers", + "extra_query", + "extra_body", + "timeout", + "custom_llm_provider", + "input_reference", + "characters", + } +) + +_VLLM_OMNI_VIDEO_PARAMS: Final = ( + "image_reference", + "video_reference", + "audio_reference", + "width", + "height", + "num_frames", + "fps", + "num_inference_steps", + "guidance_scale", + "guidance_scale_2", + "boundary_ratio", + "flow_shift", + "true_cfg_scale", + "seed", + "generate_sound", + "sound_duration", + "negative_prompt", + "enable_frame_interpolation", + "frame_interpolation_exp", + "frame_interpolation_scale", + "frame_interpolation_model_path", + "lora", + "extra_params", + "aspect_ratio", +) + + +def _serialize_form_value(value: object) -> str: + if isinstance(value, str): + return value + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (Mapping, list)): + return json.dumps(value) + return str(value) + + +def _input_reference_file(reference: object) -> tuple[str, FileTypes]: + if isinstance(reference, BufferedReader): + reader_name: Final = reference.name + reader_type: Final = mimetypes.guess_type(reader_name)[0] or ImageEditRequestUtils.get_image_content_type( + reference + ) + return ("input_reference", (reader_name, reference, reader_type)) + + fallback_name: Final = getattr(reference, "name", None) or "input_reference.png" + fallback_type: Final = mimetypes.guess_type(str(fallback_name))[0] or ImageEditRequestUtils.get_image_content_type( + reference + ) + return ("input_reference", (str(fallback_name), reference, fallback_type)) + + +class HostedVLLMVideoConfig(OpenAIVideoConfig): + """ + vLLM-Omni videos API is OpenAI-compatible but requires multipart/form-data. + + https://docs.vllm.ai/projects/vllm-omni/en/latest/serving/videos_api/ + """ + + def get_supported_openai_params(self, model: str) -> list: # mutable-ok: BaseVideoConfig contract + return [ # mutable-ok: BaseVideoConfig returns list + *super().get_supported_openai_params(model), + *_VLLM_OMNI_VIDEO_PARAMS, + ] + + def map_openai_params( + self, + video_create_optional_params: VideoCreateOptionalRequestParams, + model: str, + drop_params: bool, + ) -> dict: # mutable-ok: BaseVideoConfig contract; extra_body merge mutates this dict + return { # mutable-ok: VideoGenerationRequestUtils.update/pop extra_body onto this mapping + key: value for key, value in video_create_optional_params.items() if value is not None + } + + def validate_environment( + self, + headers: dict, # mutable-ok: BaseVideoConfig contract + model: str, + api_key: str | None = None, + litellm_params: GenericLiteLLMParams | None = None, + ) -> dict: # mutable-ok: BaseVideoConfig contract + resolved_key: Final = ( + (litellm_params.api_key if litellm_params is not None else None) + or api_key + or get_secret_str("HOSTED_VLLM_API_KEY") + or "fake-api-key" + ) + return {**headers, "Authorization": f"Bearer {resolved_key}"} # mutable-ok: httpx headers are a dict + + def get_complete_url( + self, + model: str, + api_base: str | None, + litellm_params: dict, # mutable-ok: BaseVideoConfig contract + ) -> str: + resolved_api_base: Final = api_base or get_secret_str("HOSTED_VLLM_API_BASE") + if resolved_api_base is None: + raise ValueError( + "api_base not set for Hosted VLLM videos API. " + "Set via api_base parameter or HOSTED_VLLM_API_BASE environment variable" + ) + trimmed: Final = resolved_api_base.rstrip("/") + if trimmed.endswith("/v1"): + return f"{trimmed}/videos" + return f"{trimmed}/v1/videos" + + def transform_video_create_request( + self, + model: str, + prompt: str, + api_base: str, + video_create_optional_request_params: dict, # mutable-ok: BaseVideoConfig contract + litellm_params: GenericLiteLLMParams, + headers: dict, # mutable-ok: BaseVideoConfig contract + ) -> tuple[dict, RequestFiles, str]: # mutable-ok: BaseVideoConfig contract + input_reference: Final = video_create_optional_request_params.get("input_reference") + form_files: Final = tuple( + (key, (None, _serialize_form_value(value))) + for key, value in video_create_optional_request_params.items() + if key not in _EXCLUDED_FORM_KEYS and value is not None + ) + reference_files: Final = (_input_reference_file(input_reference),) if input_reference is not None else () + files: Final = ( + ("model", (None, model)), + ("prompt", (None, prompt)), + *form_files, + *reference_files, + ) + return {}, files, api_base # mutable-ok: empty data dict; files carry the multipart fields diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json index 86c14fb4cd8..ead26ab65c5 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -1180,7 +1180,8 @@ "files": true, "rerank": true, "a2a": true, - "interactions": true + "interactions": true, + "video_generations": true } }, "huggingface": { diff --git a/litellm/utils.py b/litellm/utils.py index 5b2ef93edb3..9e91c742131 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -8929,6 +8929,10 @@ class ProviderConfigManager: from litellm.llms.runwayml.videos.transformation import RunwayMLVideoConfig return RunwayMLVideoConfig() + elif LlmProviders.HOSTED_VLLM == provider: + from litellm.llms.hosted_vllm.videos.transformation import HostedVLLMVideoConfig + + return HostedVLLMVideoConfig() return None @staticmethod diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 1d8d374c2c4..7c7d508856f 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -1277,7 +1277,8 @@ "files": true, "rerank": true, "a2a": true, - "interactions": true + "interactions": true, + "video_generations": true } }, "huggingface": { diff --git a/tests/test_litellm/llms/hosted_vllm/videos/test_hosted_vllm_video_transformation.py b/tests/test_litellm/llms/hosted_vllm/videos/test_hosted_vllm_video_transformation.py new file mode 100644 index 00000000000..d0bbb0ccd7a --- /dev/null +++ b/tests/test_litellm/llms/hosted_vllm/videos/test_hosted_vllm_video_transformation.py @@ -0,0 +1,255 @@ +"""Tests for hosted_vllm video generation (vLLM-Omni /v1/videos).""" + +import json +from io import BytesIO +from unittest.mock import MagicMock, patch + +import pytest + +import litellm +from litellm.llms.hosted_vllm.videos.transformation import ( + HostedVLLMVideoConfig, + _serialize_form_value, +) +from litellm.types.router import GenericLiteLLMParams +from litellm.types.utils import LlmProviders +from litellm.types.videos.main import VideoObject +from litellm.utils import ProviderConfigManager + + +def _form_fields(files: list) -> dict[str, str]: + return {name: value[1] for name, value in files if value[0] is None} + + +def test_provider_config_registration(): + config = ProviderConfigManager.get_provider_video_config( + model="hosted_vllm/MiniMax-H3", + provider=LlmProviders.HOSTED_VLLM, + ) + + assert config is not None + assert isinstance(config, HostedVLLMVideoConfig) + + +def test_get_complete_url_appends_videos(): + config = HostedVLLMVideoConfig() + + assert ( + config.get_complete_url(model="MiniMax-H3", api_base="http://localhost:8091", litellm_params={}) + == "http://localhost:8091/v1/videos" + ) + assert ( + config.get_complete_url(model="MiniMax-H3", api_base="http://localhost:8091/v1", litellm_params={}) + == "http://localhost:8091/v1/videos" + ) + assert ( + config.get_complete_url(model="MiniMax-H3", api_base="http://localhost:8091/v1/", litellm_params={}) + == "http://localhost:8091/v1/videos" + ) + + +def test_get_complete_url_requires_api_base(): + config = HostedVLLMVideoConfig() + + with pytest.raises(ValueError, match="api_base not set"): + config.get_complete_url(model="MiniMax-H3", api_base=None, litellm_params={}) + + +def test_validate_environment_defaults_to_fake_api_key(): + config = HostedVLLMVideoConfig() + + headers = config.validate_environment( + headers={}, + model="MiniMax-H3", + litellm_params=GenericLiteLLMParams(), + ) + + assert headers.get("Authorization") == "Bearer fake-api-key" + + +def test_validate_environment_uses_provided_api_key(): + config = HostedVLLMVideoConfig() + + headers = config.validate_environment( + headers={"X-Test": "1"}, + model="MiniMax-H3", + litellm_params=GenericLiteLLMParams(api_key="my-custom-key"), + ) + + assert headers.get("Authorization") == "Bearer my-custom-key" + assert headers.get("X-Test") == "1" + + +def test_transform_video_create_request_uses_multipart_form_fields(): + """vLLM-Omni rejects JSON create bodies. Extra Omni fields must be form parts.""" + config = HostedVLLMVideoConfig() + extra_params = {"task": "t2va", "duration": 10.0, "audio_flow_shift": 3.0} + + data, files, url = config.transform_video_create_request( + model="MiniMax-H3", + prompt="three cats march into a bedroom playing tiny brass instruments", + api_base="http://localhost:8091/v1/videos", + video_create_optional_request_params={ + "width": 1280, + "height": 720, + "fps": 24, + "num_inference_steps": 20, + "flow_shift": 12, + "seed": 1101, + "aspect_ratio": "16:9", + "extra_params": extra_params, + "extra_headers": {"X-Ignored": "yes"}, + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert data == {} + assert url == "http://localhost:8091/v1/videos" + assert files + fields = _form_fields(files) + assert fields["model"] == "MiniMax-H3" + assert fields["prompt"] == "three cats march into a bedroom playing tiny brass instruments" + assert fields["width"] == "1280" + assert fields["height"] == "720" + assert fields["fps"] == "24" + assert fields["num_inference_steps"] == "20" + assert fields["flow_shift"] == "12" + assert fields["seed"] == "1101" + assert fields["aspect_ratio"] == "16:9" + assert json.loads(fields["extra_params"]) == extra_params + assert "extra_headers" not in fields + + +def test_transform_video_create_request_keeps_openai_size_and_seconds(): + config = HostedVLLMVideoConfig() + + _, files, _ = config.transform_video_create_request( + model="Wan2.2", + prompt="a mountain lake at sunrise", + api_base="http://localhost:8091/v1/videos", + video_create_optional_request_params={"seconds": "8", "size": "1280x720"}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + fields = _form_fields(files) + assert fields["seconds"] == "8" + assert fields["size"] == "1280x720" + + +def test_transform_video_create_request_attaches_input_reference_file(): + config = HostedVLLMVideoConfig() + reference = BytesIO(b"fake-png") + reference.name = "input.png" + + data, files, _ = config.transform_video_create_request( + model="Wan2.2", + prompt="animate this image", + api_base="http://localhost:8091/v1/videos", + video_create_optional_request_params={"input_reference": reference, "width": 832}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert data == {} + fields = _form_fields(files) + assert fields["width"] == "832" + assert "input_reference" not in fields + reference_parts = [value for name, value in files if name == "input_reference"] + assert len(reference_parts) == 1 + filename, content, content_type = reference_parts[0] + assert filename == "input.png" + assert content is reference + assert content_type == "image/png" + + +def test_serialize_form_value_does_not_quote_plain_strings(): + assert _serialize_form_value("16:9") == "16:9" + assert _serialize_form_value(True) == "true" + assert _serialize_form_value({"task": "t2va"}) == json.dumps({"task": "t2va"}) + + +def test_map_openai_params_passes_through_omni_fields(): + config = HostedVLLMVideoConfig() + + mapped = config.map_openai_params( + video_create_optional_params={ + "width": 1280, + "extra_params": {"task": "t2va"}, + "aspect_ratio": "16:9", + "extra_body": None, + }, + model="MiniMax-H3", + drop_params=False, + ) + + assert mapped["width"] == 1280 + assert mapped["extra_params"] == {"task": "t2va"} + assert mapped["aspect_ratio"] == "16:9" + assert "extra_body" not in mapped + + +def test_get_supported_openai_params_includes_omni_extensions(): + config = HostedVLLMVideoConfig() + supported = config.get_supported_openai_params("MiniMax-H3") + + assert "prompt" in supported + assert "input_reference" in supported + assert "width" in supported + assert "extra_params" in supported + assert "aspect_ratio" in supported + assert "image_reference" in supported + assert "audio_reference" in supported + + +def _mock_http_client(response_body: dict) -> MagicMock: + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {"content-type": "application/json"} + mock_response.json.return_value = response_body + mock_response.text = json.dumps(response_body) + mock_client.post.return_value = mock_response + return mock_client + + +def test_video_generation_posts_multipart_not_json(): + mock_client = _mock_http_client( + { + "id": "video-123", + "object": "video", + "status": "queued", + "created_at": 1701234567, + } + ) + + with patch( + "litellm.llms.custom_httpx.llm_http_handler._get_httpx_client", + return_value=mock_client, + ): + response = litellm.video_generation( + model="hosted_vllm/MiniMax-H3", + prompt="three cats march into a bedroom playing tiny brass instruments", + api_base="http://localhost:8091", + api_key="test-key", + extra_body={ + "width": 1280, + "height": 720, + "fps": 24, + "extra_params": {"task": "t2va", "duration": 10.0}, + }, + ) + + assert isinstance(response, VideoObject) + assert response.status == "queued" + mock_client.post.assert_called_once() + post_kwargs = mock_client.post.call_args.kwargs + assert post_kwargs["url"] == "http://localhost:8091/v1/videos" + assert post_kwargs.get("json") is None + assert post_kwargs["files"] + fields = _form_fields(post_kwargs["files"]) + assert fields["prompt"] == "three cats march into a bedroom playing tiny brass instruments" + assert fields["width"] == "1280" + assert json.loads(fields["extra_params"]) == {"task": "t2va", "duration": 10.0} + assert post_kwargs["headers"]["Authorization"] == "Bearer test-key" From 01006b713a275eacc4253f27ea66f2ee4a932b0f Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Mon, 24 Aug 2026 17:16:40 -0400 Subject: [PATCH 2/6] refactor(hosted_vllm): register videos adapter via llms getter Keep Hosted VLLM video construction under litellm/llms and have ProviderConfigManager call the getter instead of importing the config class --- litellm/llms/hosted_vllm/videos/__init__.py | 9 +++++++++ litellm/utils.py | 4 ++-- .../videos/test_hosted_vllm_video_transformation.py | 2 ++ 3 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 litellm/llms/hosted_vllm/videos/__init__.py diff --git a/litellm/llms/hosted_vllm/videos/__init__.py b/litellm/llms/hosted_vllm/videos/__init__.py new file mode 100644 index 00000000000..89aa5ef2e8b --- /dev/null +++ b/litellm/llms/hosted_vllm/videos/__init__.py @@ -0,0 +1,9 @@ +from litellm.llms.base_llm.videos.transformation import BaseVideoConfig + +from .transformation import HostedVLLMVideoConfig + +__all__ = ("HostedVLLMVideoConfig",) + + +def get_hosted_vllm_video_config(model: str | None) -> BaseVideoConfig: + return HostedVLLMVideoConfig() diff --git a/litellm/utils.py b/litellm/utils.py index 9e91c742131..b026d33271f 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -8930,9 +8930,9 @@ class ProviderConfigManager: return RunwayMLVideoConfig() elif LlmProviders.HOSTED_VLLM == provider: - from litellm.llms.hosted_vllm.videos.transformation import HostedVLLMVideoConfig + from litellm.llms.hosted_vllm.videos import get_hosted_vllm_video_config - return HostedVLLMVideoConfig() + return get_hosted_vllm_video_config(model) return None @staticmethod diff --git a/tests/test_litellm/llms/hosted_vllm/videos/test_hosted_vllm_video_transformation.py b/tests/test_litellm/llms/hosted_vllm/videos/test_hosted_vllm_video_transformation.py index d0bbb0ccd7a..de266db6700 100644 --- a/tests/test_litellm/llms/hosted_vllm/videos/test_hosted_vllm_video_transformation.py +++ b/tests/test_litellm/llms/hosted_vllm/videos/test_hosted_vllm_video_transformation.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch import pytest import litellm +from litellm.llms.hosted_vllm.videos import get_hosted_vllm_video_config from litellm.llms.hosted_vllm.videos.transformation import ( HostedVLLMVideoConfig, _serialize_form_value, @@ -29,6 +30,7 @@ def test_provider_config_registration(): assert config is not None assert isinstance(config, HostedVLLMVideoConfig) + assert isinstance(get_hosted_vllm_video_config("MiniMax-H3"), HostedVLLMVideoConfig) def test_get_complete_url_appends_videos(): From a5779151e00d358c51cb41f6f17d6929806aa927 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Mon, 24 Aug 2026 17:31:34 -0400 Subject: [PATCH 3/6] fix(hosted_vllm): inline Omni media URLs through SSRF-safe fetch Fetch image, video, and audio reference HTTP URLs with safe_get and replace them with data URLs so Omni never requests user-controlled hosts --- .../llms/hosted_vllm/videos/transformation.py | 71 ++++++- .../test_hosted_vllm_video_transformation.py | 178 +++++++++++++----- 2 files changed, 205 insertions(+), 44 deletions(-) diff --git a/litellm/llms/hosted_vllm/videos/transformation.py b/litellm/llms/hosted_vllm/videos/transformation.py index 4f3957f0d93..6ac210d35c8 100644 --- a/litellm/llms/hosted_vllm/videos/transformation.py +++ b/litellm/llms/hosted_vllm/videos/transformation.py @@ -1,14 +1,19 @@ """Video generation for Hosted VLLM (vLLM-Omni OpenAI-compatible /v1/videos).""" +import base64 import json import mimetypes from collections.abc import Mapping from io import BufferedReader +from types import MappingProxyType from typing import Final +from urllib.parse import urlparse from httpx._types import FileTypes, RequestFiles from litellm.images.utils import ImageEditRequestUtils +from litellm.litellm_core_utils.url_utils import SSRFError, safe_get +from litellm.llms.custom_httpx.http_handler import HTTPHandler from litellm.llms.openai.videos.transformation import OpenAIVideoConfig from litellm.secret_managers.main import get_secret_str from litellm.types.router import GenericLiteLLMParams @@ -55,17 +60,74 @@ _VLLM_OMNI_VIDEO_PARAMS: Final = ( "aspect_ratio", ) +_REFERENCE_URL_KEYS: Final = MappingProxyType( + { + "image_reference": "image_url", + "video_reference": "video_url", + "audio_reference": "audio_url", + } +) + def _serialize_form_value(value: object) -> str: if isinstance(value, str): return value if isinstance(value, bool): return "true" if value else "false" - if isinstance(value, (Mapping, list)): + if isinstance(value, (Mapping, list, tuple)): return json.dumps(value) return str(value) +def _maybe_json(value: object) -> object: + if not isinstance(value, str): + return value + stripped: Final = value.strip() + if not stripped or stripped[0] not in "{[": + return value + return json.loads(stripped) + + +def _content_type_from_headers(headers: Mapping[str, object]) -> str: + raw: Final = headers.get("content-type", "application/octet-stream") + if not isinstance(raw, str) or not raw.strip(): + return "application/octet-stream" + return raw.split(";", 1)[0].strip() or "application/octet-stream" + + +def _fetch_url_as_data_url(url: str, client: HTTPHandler) -> str: + response: Final = safe_get(client, url) + response.raise_for_status() + encoded: Final = base64.b64encode(response.content).decode("ascii") + return f"data:{_content_type_from_headers(response.headers)};base64,{encoded}" + + +def _inline_reference_item(url_key: str, item: object, client: HTTPHandler) -> object: + if not isinstance(item, Mapping): + return item + url: Final = item.get(url_key) + if not isinstance(url, str): + return item + scheme: Final = urlparse(url).scheme.lower() + if scheme in ("", "data"): + return item + if scheme not in ("http", "https"): + raise SSRFError(f"URL scheme '{scheme}' is not allowed") + return {**item, url_key: _fetch_url_as_data_url(url, client)} # mutable-ok: JSON form field serialized immediately + + +def _inline_media_reference(field_name: str, value: object, client: HTTPHandler) -> object: + url_key: Final = _REFERENCE_URL_KEYS.get(field_name) + if url_key is None: + return value + parsed: Final = _maybe_json(value) + if isinstance(parsed, list): + return tuple(_inline_reference_item(url_key, item, client) for item in parsed) + if isinstance(parsed, Mapping): + return _inline_reference_item(url_key, parsed, client) + return value + + def _input_reference_file(reference: object) -> tuple[str, FileTypes]: if isinstance(reference, BufferedReader): reader_name: Final = reference.name @@ -88,6 +150,10 @@ class HostedVLLMVideoConfig(OpenAIVideoConfig): https://docs.vllm.ai/projects/vllm-omni/en/latest/serving/videos_api/ """ + def __init__(self, media_http_client: HTTPHandler | None = None) -> None: + super().__init__() + self._media_http_client = media_http_client + def get_supported_openai_params(self, model: str) -> list: # mutable-ok: BaseVideoConfig contract return [ # mutable-ok: BaseVideoConfig returns list *super().get_supported_openai_params(model), @@ -145,9 +211,10 @@ class HostedVLLMVideoConfig(OpenAIVideoConfig): litellm_params: GenericLiteLLMParams, headers: dict, # mutable-ok: BaseVideoConfig contract ) -> tuple[dict, RequestFiles, str]: # mutable-ok: BaseVideoConfig contract + media_client: Final = self._media_http_client or HTTPHandler(concurrent_limit=1) input_reference: Final = video_create_optional_request_params.get("input_reference") form_files: Final = tuple( - (key, (None, _serialize_form_value(value))) + (key, (None, _serialize_form_value(_inline_media_reference(key, value, media_client)))) for key, value in video_create_optional_request_params.items() if key not in _EXCLUDED_FORM_KEYS and value is not None ) diff --git a/tests/test_litellm/llms/hosted_vllm/videos/test_hosted_vllm_video_transformation.py b/tests/test_litellm/llms/hosted_vllm/videos/test_hosted_vllm_video_transformation.py index de266db6700..abd52fd0ae1 100644 --- a/tests/test_litellm/llms/hosted_vllm/videos/test_hosted_vllm_video_transformation.py +++ b/tests/test_litellm/llms/hosted_vllm/videos/test_hosted_vllm_video_transformation.py @@ -1,12 +1,15 @@ """Tests for hosted_vllm video generation (vLLM-Omni /v1/videos).""" +import base64 import json from io import BytesIO -from unittest.mock import MagicMock, patch +import httpx import pytest import litellm +from litellm.litellm_core_utils.url_utils import SSRFError +from litellm.llms.custom_httpx.http_handler import HTTPHandler from litellm.llms.hosted_vllm.videos import get_hosted_vllm_video_config from litellm.llms.hosted_vllm.videos.transformation import ( HostedVLLMVideoConfig, @@ -205,53 +208,144 @@ def test_get_supported_openai_params_includes_omni_extensions(): assert "audio_reference" in supported -def _mock_http_client(response_body: dict) -> MagicMock: - mock_client = MagicMock() - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.headers = {"content-type": "application/json"} - mock_response.json.return_value = response_body - mock_response.text = json.dumps(response_body) - mock_client.post.return_value = mock_response - return mock_client +def _http_handler_for(handler) -> HTTPHandler: + return HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(handler))) def test_video_generation_posts_multipart_not_json(): - mock_client = _mock_http_client( - { - "id": "video-123", - "object": "video", - "status": "queued", - "created_at": 1701234567, - } - ) + captured: list[httpx.Request] = [] - with patch( - "litellm.llms.custom_httpx.llm_http_handler._get_httpx_client", - return_value=mock_client, - ): - response = litellm.video_generation( - model="hosted_vllm/MiniMax-H3", - prompt="three cats march into a bedroom playing tiny brass instruments", - api_base="http://localhost:8091", - api_key="test-key", - extra_body={ - "width": 1280, - "height": 720, - "fps": 24, - "extra_params": {"task": "t2va", "duration": 10.0}, + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response( + 200, + json={ + "id": "video-123", + "object": "video", + "status": "queued", + "created_at": 1701234567, }, ) + response = litellm.video_generation( + model="hosted_vllm/MiniMax-H3", + prompt="three cats march into a bedroom playing tiny brass instruments", + api_base="http://localhost:8091", + api_key="test-key", + client=_http_handler_for(handler), + extra_body={ + "width": 1280, + "height": 720, + "fps": 24, + "extra_params": {"task": "t2va", "duration": 10.0}, + }, + ) + assert isinstance(response, VideoObject) assert response.status == "queued" - mock_client.post.assert_called_once() - post_kwargs = mock_client.post.call_args.kwargs - assert post_kwargs["url"] == "http://localhost:8091/v1/videos" - assert post_kwargs.get("json") is None - assert post_kwargs["files"] - fields = _form_fields(post_kwargs["files"]) - assert fields["prompt"] == "three cats march into a bedroom playing tiny brass instruments" - assert fields["width"] == "1280" - assert json.loads(fields["extra_params"]) == {"task": "t2va", "duration": 10.0} - assert post_kwargs["headers"]["Authorization"] == "Bearer test-key" + assert len(captured) == 1 + request = captured[0] + assert str(request.url) == "http://localhost:8091/v1/videos" + assert request.headers["authorization"] == "Bearer test-key" + body = request.content + assert b'name="prompt"' in body + assert b"three cats march into a bedroom playing tiny brass instruments" in body + assert b'name="width"' in body + assert b"1280" in body + assert b'name="extra_params"' in body + assert b"t2va" in body + assert request.headers.get("content-type", "").startswith("multipart/form-data") + + +def test_http_image_reference_is_inlined_as_data_url(): + png_bytes = b"fake-png" + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.host == "1.1.1.1" + return httpx.Response(200, content=png_bytes, headers={"content-type": "image/png"}) + + config = HostedVLLMVideoConfig(media_http_client=_http_handler_for(handler)) + _, files, _ = config.transform_video_create_request( + model="MiniMax-H3", + prompt="a person singing", + api_base="http://localhost:8091/v1/videos", + video_create_optional_request_params={ + "image_reference": {"image_url": "http://1.1.1.1/face.png"}, + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + payload = json.loads(_form_fields(files)["image_reference"]) + assert payload["image_url"] == "data:image/png;base64," + base64.b64encode(png_bytes).decode("ascii") + + +def test_http_audio_reference_json_string_is_inlined_as_data_url(): + audio_bytes = b"fake-mp3" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=audio_bytes, headers={"content-type": "audio/mpeg"}) + + config = HostedVLLMVideoConfig(media_http_client=_http_handler_for(handler)) + _, files, _ = config.transform_video_create_request( + model="MiniMax-H3", + prompt="a person singing", + api_base="http://localhost:8091/v1/videos", + video_create_optional_request_params={ + "audio_reference": '{"audio_url": "http://1.1.1.1/speech.mp3"}', + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + payload = json.loads(_form_fields(files)["audio_reference"]) + assert payload["audio_url"].startswith("data:audio/mpeg;base64,") + assert base64.b64decode(payload["audio_url"].split(",", 1)[1]) == audio_bytes + + +def test_data_url_image_reference_is_not_fetched(): + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError(f"unexpected fetch of {request.url}") + + data_url = "data:image/png;base64,AAAA" + config = HostedVLLMVideoConfig(media_http_client=_http_handler_for(handler)) + _, files, _ = config.transform_video_create_request( + model="MiniMax-H3", + prompt="a person singing", + api_base="http://localhost:8091/v1/videos", + video_create_optional_request_params={"image_reference": {"image_url": data_url}}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert json.loads(_form_fields(files)["image_reference"])["image_url"] == data_url + + +def test_metadata_url_in_image_reference_is_rejected(): + config = HostedVLLMVideoConfig() + with pytest.raises(SSRFError, match="blocked address"): + config.transform_video_create_request( + model="MiniMax-H3", + prompt="a person singing", + api_base="http://localhost:8091/v1/videos", + video_create_optional_request_params={ + "image_reference": {"image_url": "http://169.254.169.254/latest/meta-data/"}, + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + +def test_file_scheme_media_reference_is_rejected(): + config = HostedVLLMVideoConfig() + with pytest.raises(SSRFError, match="scheme"): + config.transform_video_create_request( + model="MiniMax-H3", + prompt="a person singing", + api_base="http://localhost:8091/v1/videos", + video_create_optional_request_params={ + "video_reference": {"video_url": "file:///etc/passwd"}, + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) From 89b67607d935bb22f7a2c92c7027c0f69b464832 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Mon, 24 Aug 2026 17:36:45 -0400 Subject: [PATCH 4/6] fix(ci): flatten multipart form fields without recursion Walk nested form values with a depth-capped stack so the recursive-function detector stops failing after the internal staging merge --- .../litellm_core_utils/llm_request_utils.py | 77 ++++++++++++------- .../test_llm_request_utils.py | 14 +++- 2 files changed, 59 insertions(+), 32 deletions(-) diff --git a/litellm/litellm_core_utils/llm_request_utils.py b/litellm/litellm_core_utils/llm_request_utils.py index c833d57b6a9..c6bf798d179 100644 --- a/litellm/litellm_core_utils/llm_request_utils.py +++ b/litellm/litellm_core_utils/llm_request_utils.py @@ -2,6 +2,7 @@ from collections.abc import Mapping from typing import Final import litellm +from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH def _form_field_value(value: object) -> str: @@ -13,18 +14,27 @@ def _form_field_value(value: object) -> str: def _flatten_form_field(key: str, value: object) -> tuple[tuple[str, str], ...]: - if isinstance(value, Mapping): - return tuple( - item for subkey, subvalue in value.items() for item in _flatten_form_field(f"{key}[{subkey}]", subvalue) - ) - if isinstance(value, (list, tuple)): - return tuple(item for entry in value for item in _flatten_form_field(f"{key}[]", entry)) - if value is None: - return () - serialized: Final = _form_field_value(value) - if not serialized: - return () - return ((key, serialized),) + work: Final[list[tuple[str, object, int]]] = [(key, value, 0)] # mutable-ok: depth-capped stack, avoids recursion + out: Final[list[tuple[str, str]]] = [] # mutable-ok: local accumulator + while work: + current_key, current_value, depth = work.pop() + if depth > DEFAULT_MAX_RECURSE_DEPTH: + raise ValueError("form field nesting exceeds max depth") + if isinstance(current_value, Mapping): + work.extend( + (f"{current_key}[{subkey}]", subvalue, depth + 1) + for subkey, subvalue in reversed(tuple(current_value.items())) + ) + continue + if isinstance(current_value, (list, tuple)): + work.extend((f"{current_key}[]", entry, depth + 1) for entry in reversed(tuple(current_value))) + continue + if current_value is None: + continue + serialized = _form_field_value(current_value) + if serialized: + out.append((current_key, serialized)) + return tuple(out) def _is_form_scalar(value: object) -> bool: @@ -32,23 +42,32 @@ def _is_form_scalar(value: object) -> bool: def _flatten_form_data_field(key: str, value: object) -> tuple[tuple[str, str | tuple[str, ...]], ...]: - if isinstance(value, Mapping): - return tuple( - item - for subkey, subvalue in value.items() - for item in _flatten_form_data_field(f"{key}[{subkey}]", subvalue) - ) - if isinstance(value, (list, tuple)): - if all(_is_form_scalar(entry) for entry in value): - serialized_fields: Final = tuple(field for entry in value if (field := _form_field_value(entry))) - return ((key, serialized_fields),) if serialized_fields else () - return tuple(item for entry in value for item in _flatten_form_data_field(f"{key}[]", entry)) - if value is None: - return () - serialized: Final = _form_field_value(value) - if not serialized: - return () - return ((key, serialized),) + work: Final[list[tuple[str, object, int]]] = [(key, value, 0)] # mutable-ok: depth-capped stack, avoids recursion + out: Final[list[tuple[str, str | tuple[str, ...]]]] = [] # mutable-ok: local accumulator + while work: + current_key, current_value, depth = work.pop() + if depth > DEFAULT_MAX_RECURSE_DEPTH: + raise ValueError("form field nesting exceeds max depth") + if isinstance(current_value, Mapping): + work.extend( + (f"{current_key}[{subkey}]", subvalue, depth + 1) + for subkey, subvalue in reversed(tuple(current_value.items())) + ) + continue + if isinstance(current_value, (list, tuple)): + if all(_is_form_scalar(entry) for entry in current_value): + serialized_fields = tuple(field for entry in current_value if (field := _form_field_value(entry))) + if serialized_fields: + out.append((current_key, serialized_fields)) + continue + work.extend((f"{current_key}[]", entry, depth + 1) for entry in reversed(tuple(current_value))) + continue + if current_value is None: + continue + serialized = _form_field_value(current_value) + if serialized: + out.append((current_key, serialized)) + return tuple(out) def flatten_form_field_values(*sources: Mapping[str, object] | None) -> tuple[tuple[str, str | tuple[str, ...]], ...]: diff --git a/tests/test_litellm/litellm_core_utils/test_llm_request_utils.py b/tests/test_litellm/litellm_core_utils/test_llm_request_utils.py index 3a09702de45..765c47547ce 100644 --- a/tests/test_litellm/litellm_core_utils/test_llm_request_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_llm_request_utils.py @@ -1,4 +1,5 @@ import httpx +import pytest from litellm.litellm_core_utils.llm_request_utils import ( flatten_form_field_values, @@ -80,9 +81,7 @@ def test_flatten_form_field_values_later_source_wins_on_collision(): def test_flatten_form_field_values_keeps_scalar_lists_as_repeated_fields(): - assert flatten_form_field_values( - {"loras": ["a", "b", "c"], "generation_config": {"tags": [1, 2]}, "seed": 42} - ) == ( + assert flatten_form_field_values({"loras": ["a", "b", "c"], "generation_config": {"tags": [1, 2]}, "seed": 42}) == ( ("loras", ("a", "b", "c")), ("generation_config[tags]", ("1", "2")), ("seed", "42"), @@ -97,3 +96,12 @@ def test_flatten_form_field_values_scalar_list_survives_update_into_multipart(): assert names.count("loras") == 2 assert names.count("model") == 1 + + +def test_flatten_form_field_values_rejects_over_deep_nesting(): + nested: object = "leaf" + for _ in range(102): + nested = {"k": nested} + assert isinstance(nested, dict) + with pytest.raises(ValueError, match="max depth"): + flatten_form_field_values(nested) From 984b4b596829179d04c2c2398c4e7661576ec9c5 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Mon, 24 Aug 2026 17:41:49 -0400 Subject: [PATCH 5/6] fix(hosted_vllm): cap remote media downloads on video create Stream image, video, and audio URL bodies with Content-Length and byte caps so an authenticated caller cannot exhaust worker memory --- litellm/constants.py | 2 + .../llms/hosted_vllm/videos/transformation.py | 118 ++++++++++++++++-- .../test_hosted_vllm_video_transformation.py | 57 +++++++++ 3 files changed, 168 insertions(+), 9 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index d6a00294004..c8ece1bc8f4 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -69,6 +69,8 @@ DEFAULT_IMAGE_HEIGHT: Final = int(os.getenv("DEFAULT_IMAGE_HEIGHT", 300)) # Maps to OpenAI's 50 MB payload limit - requests with images exceeding this size will be rejected # Set MAX_IMAGE_URL_DOWNLOAD_SIZE_MB=0 to disable image URL handling entirely MAX_IMAGE_URL_DOWNLOAD_SIZE_MB: Final = float(os.getenv("MAX_IMAGE_URL_DOWNLOAD_SIZE_MB", 50)) +MAX_VIDEO_MEDIA_URLS_PER_REQUEST: Final = get_env_int("MAX_VIDEO_MEDIA_URLS_PER_REQUEST", 10) +MAX_VIDEO_MEDIA_URL_TOTAL_DOWNLOAD_SIZE_MB: Final = float(os.getenv("MAX_VIDEO_MEDIA_URL_TOTAL_DOWNLOAD_SIZE_MB", 100)) MAX_SIZE_PER_ITEM_IN_MEMORY_CACHE_IN_KB: Final = int( os.getenv("MAX_SIZE_PER_ITEM_IN_MEMORY_CACHE_IN_KB", 1024) ) # 1MB = 1024KB diff --git a/litellm/llms/hosted_vllm/videos/transformation.py b/litellm/llms/hosted_vllm/videos/transformation.py index 6ac210d35c8..16f1397be8a 100644 --- a/litellm/llms/hosted_vllm/videos/transformation.py +++ b/litellm/llms/hosted_vllm/videos/transformation.py @@ -4,13 +4,20 @@ import base64 import json import mimetypes from collections.abc import Mapping +from dataclasses import dataclass from io import BufferedReader from types import MappingProxyType from typing import Final from urllib.parse import urlparse +from httpx import Response from httpx._types import FileTypes, RequestFiles +from litellm.constants import ( + MAX_IMAGE_URL_DOWNLOAD_SIZE_MB, + MAX_VIDEO_MEDIA_URL_TOTAL_DOWNLOAD_SIZE_MB, + MAX_VIDEO_MEDIA_URLS_PER_REQUEST, +) from litellm.images.utils import ImageEditRequestUtils from litellm.litellm_core_utils.url_utils import SSRFError, safe_get from litellm.llms.custom_httpx.http_handler import HTTPHandler @@ -19,6 +26,9 @@ from litellm.secret_managers.main import get_secret_str from litellm.types.router import GenericLiteLLMParams from litellm.types.videos.main import VideoCreateOptionalRequestParams +_BYTES_PER_MB: Final = 1024 * 1024 +_STREAM_CHUNK_SIZE: Final = 8192 + _EXCLUDED_FORM_KEYS: Final = frozenset( { "model", @@ -69,6 +79,28 @@ _REFERENCE_URL_KEYS: Final = MappingProxyType( ) +@dataclass(slots=True) +class _MediaDownloadBudget: + remaining_urls: int + remaining_bytes: int + max_bytes_per_url: int + + def consume_url_slot(self) -> None: + if self.max_bytes_per_url <= 0: + raise ValueError("remote media URL download is disabled (MAX_IMAGE_URL_DOWNLOAD_SIZE_MB=0)") + if self.remaining_urls < 1: + raise ValueError("too many remote media URL references on one video request") + self.remaining_urls -= 1 + + def max_read_bytes(self) -> int: + return min(self.max_bytes_per_url, self.remaining_bytes) + + def consume_bytes(self, nbytes: int) -> None: + if nbytes > self.remaining_bytes: + raise ValueError("remote media download exceeded the per-request size limit") + self.remaining_bytes -= nbytes + + def _serialize_form_value(value: object) -> str: if isinstance(value, str): return value @@ -95,14 +127,41 @@ def _content_type_from_headers(headers: Mapping[str, object]) -> str: return raw.split(";", 1)[0].strip() or "application/octet-stream" -def _fetch_url_as_data_url(url: str, client: HTTPHandler) -> str: +def _declared_content_length(headers: Mapping[str, object]) -> int | None: + raw: Final = headers.get("content-length") + if not isinstance(raw, str): + return None + stripped: Final = raw.strip() + if not stripped.isdigit(): + return None + return int(stripped) + + +def _read_capped_body(response: Response, max_bytes: int) -> bytes: + declared: Final = _declared_content_length(response.headers) + if declared is not None and declared > max_bytes: + response.close() + raise ValueError("remote media URL Content-Length exceeds the maximum allowed size") + body: Final = bytearray() # mutable-ok: streaming accumulator with a hard cap + for chunk in response.iter_bytes(chunk_size=_STREAM_CHUNK_SIZE): + body.extend(chunk) + if len(body) > max_bytes: + response.close() + raise ValueError("remote media download exceeded the maximum allowed size") + return bytes(body) + + +def _fetch_url_as_data_url(url: str, client: HTTPHandler, budget: _MediaDownloadBudget) -> str: + budget.consume_url_slot() response: Final = safe_get(client, url) response.raise_for_status() - encoded: Final = base64.b64encode(response.content).decode("ascii") + content: Final = _read_capped_body(response, budget.max_read_bytes()) + budget.consume_bytes(len(content)) + encoded: Final = base64.b64encode(content).decode("ascii") return f"data:{_content_type_from_headers(response.headers)};base64,{encoded}" -def _inline_reference_item(url_key: str, item: object, client: HTTPHandler) -> object: +def _inline_reference_item(url_key: str, item: object, client: HTTPHandler, budget: _MediaDownloadBudget) -> object: if not isinstance(item, Mapping): return item url: Final = item.get(url_key) @@ -113,18 +172,23 @@ def _inline_reference_item(url_key: str, item: object, client: HTTPHandler) -> o return item if scheme not in ("http", "https"): raise SSRFError(f"URL scheme '{scheme}' is not allowed") - return {**item, url_key: _fetch_url_as_data_url(url, client)} # mutable-ok: JSON form field serialized immediately + return { # mutable-ok: JSON form field serialized immediately + **item, + url_key: _fetch_url_as_data_url(url, client, budget), + } -def _inline_media_reference(field_name: str, value: object, client: HTTPHandler) -> object: +def _inline_media_reference( + field_name: str, value: object, client: HTTPHandler, budget: _MediaDownloadBudget +) -> object: url_key: Final = _REFERENCE_URL_KEYS.get(field_name) if url_key is None: return value parsed: Final = _maybe_json(value) if isinstance(parsed, list): - return tuple(_inline_reference_item(url_key, item, client) for item in parsed) + return tuple(_inline_reference_item(url_key, item, client, budget) for item in parsed) if isinstance(parsed, Mapping): - return _inline_reference_item(url_key, parsed, client) + return _inline_reference_item(url_key, parsed, client, budget) return value @@ -150,9 +214,41 @@ class HostedVLLMVideoConfig(OpenAIVideoConfig): https://docs.vllm.ai/projects/vllm-omni/en/latest/serving/videos_api/ """ - def __init__(self, media_http_client: HTTPHandler | None = None) -> None: + def __init__( + self, + media_http_client: HTTPHandler | None = None, + *, + max_media_bytes_per_url: int | None = None, + max_media_bytes_per_request: int | None = None, + max_media_urls_per_request: int | None = None, + ) -> None: super().__init__() self._media_http_client = media_http_client + self._max_media_bytes_per_url = max_media_bytes_per_url + self._max_media_bytes_per_request = max_media_bytes_per_request + self._max_media_urls_per_request = max_media_urls_per_request + + def _media_budget(self) -> _MediaDownloadBudget: + per_url: Final = ( + self._max_media_bytes_per_url + if self._max_media_bytes_per_url is not None + else int(MAX_IMAGE_URL_DOWNLOAD_SIZE_MB * _BYTES_PER_MB) + ) + per_request: Final = ( + self._max_media_bytes_per_request + if self._max_media_bytes_per_request is not None + else int(MAX_VIDEO_MEDIA_URL_TOTAL_DOWNLOAD_SIZE_MB * _BYTES_PER_MB) + ) + url_slots: Final = ( + self._max_media_urls_per_request + if self._max_media_urls_per_request is not None + else MAX_VIDEO_MEDIA_URLS_PER_REQUEST + ) + return _MediaDownloadBudget( + remaining_urls=url_slots, + remaining_bytes=per_request, + max_bytes_per_url=per_url, + ) def get_supported_openai_params(self, model: str) -> list: # mutable-ok: BaseVideoConfig contract return [ # mutable-ok: BaseVideoConfig returns list @@ -212,9 +308,13 @@ class HostedVLLMVideoConfig(OpenAIVideoConfig): headers: dict, # mutable-ok: BaseVideoConfig contract ) -> tuple[dict, RequestFiles, str]: # mutable-ok: BaseVideoConfig contract media_client: Final = self._media_http_client or HTTPHandler(concurrent_limit=1) + media_budget: Final = self._media_budget() input_reference: Final = video_create_optional_request_params.get("input_reference") form_files: Final = tuple( - (key, (None, _serialize_form_value(_inline_media_reference(key, value, media_client)))) + ( + key, + (None, _serialize_form_value(_inline_media_reference(key, value, media_client, media_budget))), + ) for key, value in video_create_optional_request_params.items() if key not in _EXCLUDED_FORM_KEYS and value is not None ) diff --git a/tests/test_litellm/llms/hosted_vllm/videos/test_hosted_vllm_video_transformation.py b/tests/test_litellm/llms/hosted_vllm/videos/test_hosted_vllm_video_transformation.py index abd52fd0ae1..be02e03833b 100644 --- a/tests/test_litellm/llms/hosted_vllm/videos/test_hosted_vllm_video_transformation.py +++ b/tests/test_litellm/llms/hosted_vllm/videos/test_hosted_vllm_video_transformation.py @@ -349,3 +349,60 @@ def test_file_scheme_media_reference_is_rejected(): litellm_params=GenericLiteLLMParams(), headers={}, ) + + +def _transform_with_references(config: HostedVLLMVideoConfig, **references: object): + return config.transform_video_create_request( + model="MiniMax-H3", + prompt="a person singing", + api_base="http://localhost:8091/v1/videos", + video_create_optional_request_params=references, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + +def test_oversized_content_length_is_rejected_before_body(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + content=b"", + headers={"content-type": "video/mp4", "content-length": str(51 * 1024 * 1024)}, + ) + + config = HostedVLLMVideoConfig(media_http_client=_http_handler_for(handler)) + with pytest.raises(ValueError, match="Content-Length"): + _transform_with_references(config, video_reference={"video_url": "http://1.1.1.1/clip.mp4"}) + + +def test_streamed_body_over_per_url_cap_is_rejected(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + content=iter((b"1234", b"5678")), + headers={"content-type": "image/png"}, + ) + + config = HostedVLLMVideoConfig( + media_http_client=_http_handler_for(handler), + max_media_bytes_per_url=4, + max_media_bytes_per_request=4, + ) + with pytest.raises(ValueError, match="exceeded the maximum allowed size"): + _transform_with_references(config, image_reference={"image_url": "http://1.1.1.1/face.png"}) + + +def test_too_many_remote_media_urls_are_rejected(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=b"ok", headers={"content-type": "image/png"}) + + config = HostedVLLMVideoConfig( + media_http_client=_http_handler_for(handler), + max_media_urls_per_request=1, + ) + with pytest.raises(ValueError, match="too many remote media URL references"): + _transform_with_references( + config, + image_reference={"image_url": "http://1.1.1.1/a.png"}, + audio_reference={"audio_url": "http://1.1.1.1/b.mp3"}, + ) From 999d23dfce262a7da202ff1341f011156bfa5317 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Mon, 24 Aug 2026 17:59:52 -0400 Subject: [PATCH 6/6] fix(hosted_vllm): forward Omni media URLs instead of downloading them OpenAI videos never fetches input URLs or invents download size caps. Drop MAX_VIDEO_MEDIA_* and the fetch-and-inline path. Keep MAX_IMAGE_URL_DOWNLOAD_SIZE_MB for chat image handling. Validate Omni reference URLs, then send them as form fields the way OpenAI videos sends input_reference as a file --- litellm/constants.py | 2 - .../litellm_core_utils/llm_request_utils.py | 42 ++-- .../llms/hosted_vllm/videos/transformation.py | 198 ++++-------------- .../test_hosted_vllm_video_transformation.py | 156 +++----------- 4 files changed, 93 insertions(+), 305 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index c8ece1bc8f4..d6a00294004 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -69,8 +69,6 @@ DEFAULT_IMAGE_HEIGHT: Final = int(os.getenv("DEFAULT_IMAGE_HEIGHT", 300)) # Maps to OpenAI's 50 MB payload limit - requests with images exceeding this size will be rejected # Set MAX_IMAGE_URL_DOWNLOAD_SIZE_MB=0 to disable image URL handling entirely MAX_IMAGE_URL_DOWNLOAD_SIZE_MB: Final = float(os.getenv("MAX_IMAGE_URL_DOWNLOAD_SIZE_MB", 50)) -MAX_VIDEO_MEDIA_URLS_PER_REQUEST: Final = get_env_int("MAX_VIDEO_MEDIA_URLS_PER_REQUEST", 10) -MAX_VIDEO_MEDIA_URL_TOTAL_DOWNLOAD_SIZE_MB: Final = float(os.getenv("MAX_VIDEO_MEDIA_URL_TOTAL_DOWNLOAD_SIZE_MB", 100)) MAX_SIZE_PER_ITEM_IN_MEMORY_CACHE_IN_KB: Final = int( os.getenv("MAX_SIZE_PER_ITEM_IN_MEMORY_CACHE_IN_KB", 1024) ) # 1MB = 1024KB diff --git a/litellm/litellm_core_utils/llm_request_utils.py b/litellm/litellm_core_utils/llm_request_utils.py index c6bf798d179..04824a5bf39 100644 --- a/litellm/litellm_core_utils/llm_request_utils.py +++ b/litellm/litellm_core_utils/llm_request_utils.py @@ -14,27 +14,31 @@ def _form_field_value(value: object) -> str: def _flatten_form_field(key: str, value: object) -> tuple[tuple[str, str], ...]: - work: Final[list[tuple[str, object, int]]] = [(key, value, 0)] # mutable-ok: depth-capped stack, avoids recursion - out: Final[list[tuple[str, str]]] = [] # mutable-ok: local accumulator - while work: - current_key, current_value, depth = work.pop() + pending_fields: Final[ # mutable-ok: depth-capped stack walks nested JSON into multipart names + list[tuple[str, object, int]] + ] = [ # mutable-ok: depth-capped stack walks nested JSON into multipart names + (key, value, 0) + ] + flat_fields: Final[list[tuple[str, str]]] = [] # mutable-ok: local accumulator + while pending_fields: + current_key, current_value, depth = pending_fields.pop() if depth > DEFAULT_MAX_RECURSE_DEPTH: raise ValueError("form field nesting exceeds max depth") if isinstance(current_value, Mapping): - work.extend( + pending_fields.extend( (f"{current_key}[{subkey}]", subvalue, depth + 1) for subkey, subvalue in reversed(tuple(current_value.items())) ) continue if isinstance(current_value, (list, tuple)): - work.extend((f"{current_key}[]", entry, depth + 1) for entry in reversed(tuple(current_value))) + pending_fields.extend((f"{current_key}[]", entry, depth + 1) for entry in reversed(tuple(current_value))) continue if current_value is None: continue serialized = _form_field_value(current_value) if serialized: - out.append((current_key, serialized)) - return tuple(out) + flat_fields.append((current_key, serialized)) + return tuple(flat_fields) def _is_form_scalar(value: object) -> bool: @@ -42,14 +46,18 @@ def _is_form_scalar(value: object) -> bool: def _flatten_form_data_field(key: str, value: object) -> tuple[tuple[str, str | tuple[str, ...]], ...]: - work: Final[list[tuple[str, object, int]]] = [(key, value, 0)] # mutable-ok: depth-capped stack, avoids recursion - out: Final[list[tuple[str, str | tuple[str, ...]]]] = [] # mutable-ok: local accumulator - while work: - current_key, current_value, depth = work.pop() + pending_fields: Final[ # mutable-ok: depth-capped stack walks nested JSON into multipart names + list[tuple[str, object, int]] + ] = [ # mutable-ok: depth-capped stack walks nested JSON into multipart names + (key, value, 0) + ] + flat_fields: Final[list[tuple[str, str | tuple[str, ...]]]] = [] # mutable-ok: local accumulator + while pending_fields: + current_key, current_value, depth = pending_fields.pop() if depth > DEFAULT_MAX_RECURSE_DEPTH: raise ValueError("form field nesting exceeds max depth") if isinstance(current_value, Mapping): - work.extend( + pending_fields.extend( (f"{current_key}[{subkey}]", subvalue, depth + 1) for subkey, subvalue in reversed(tuple(current_value.items())) ) @@ -58,16 +66,16 @@ def _flatten_form_data_field(key: str, value: object) -> tuple[tuple[str, str | if all(_is_form_scalar(entry) for entry in current_value): serialized_fields = tuple(field for entry in current_value if (field := _form_field_value(entry))) if serialized_fields: - out.append((current_key, serialized_fields)) + flat_fields.append((current_key, serialized_fields)) continue - work.extend((f"{current_key}[]", entry, depth + 1) for entry in reversed(tuple(current_value))) + pending_fields.extend((f"{current_key}[]", entry, depth + 1) for entry in reversed(tuple(current_value))) continue if current_value is None: continue serialized = _form_field_value(current_value) if serialized: - out.append((current_key, serialized)) - return tuple(out) + flat_fields.append((current_key, serialized)) + return tuple(flat_fields) def flatten_form_field_values(*sources: Mapping[str, object] | None) -> tuple[tuple[str, str | tuple[str, ...]], ...]: diff --git a/litellm/llms/hosted_vllm/videos/transformation.py b/litellm/llms/hosted_vllm/videos/transformation.py index 16f1397be8a..96cbfc3cf70 100644 --- a/litellm/llms/hosted_vllm/videos/transformation.py +++ b/litellm/llms/hosted_vllm/videos/transformation.py @@ -1,34 +1,21 @@ """Video generation for Hosted VLLM (vLLM-Omni OpenAI-compatible /v1/videos).""" -import base64 import json -import mimetypes from collections.abc import Mapping -from dataclasses import dataclass from io import BufferedReader from types import MappingProxyType from typing import Final from urllib.parse import urlparse -from httpx import Response from httpx._types import FileTypes, RequestFiles -from litellm.constants import ( - MAX_IMAGE_URL_DOWNLOAD_SIZE_MB, - MAX_VIDEO_MEDIA_URL_TOTAL_DOWNLOAD_SIZE_MB, - MAX_VIDEO_MEDIA_URLS_PER_REQUEST, -) from litellm.images.utils import ImageEditRequestUtils -from litellm.litellm_core_utils.url_utils import SSRFError, safe_get -from litellm.llms.custom_httpx.http_handler import HTTPHandler +from litellm.litellm_core_utils.url_utils import SSRFError, validate_url from litellm.llms.openai.videos.transformation import OpenAIVideoConfig from litellm.secret_managers.main import get_secret_str from litellm.types.router import GenericLiteLLMParams from litellm.types.videos.main import VideoCreateOptionalRequestParams -_BYTES_PER_MB: Final = 1024 * 1024 -_STREAM_CHUNK_SIZE: Final = 8192 - _EXCLUDED_FORM_KEYS: Final = frozenset( { "model", @@ -79,28 +66,6 @@ _REFERENCE_URL_KEYS: Final = MappingProxyType( ) -@dataclass(slots=True) -class _MediaDownloadBudget: - remaining_urls: int - remaining_bytes: int - max_bytes_per_url: int - - def consume_url_slot(self) -> None: - if self.max_bytes_per_url <= 0: - raise ValueError("remote media URL download is disabled (MAX_IMAGE_URL_DOWNLOAD_SIZE_MB=0)") - if self.remaining_urls < 1: - raise ValueError("too many remote media URL references on one video request") - self.remaining_urls -= 1 - - def max_read_bytes(self) -> int: - return min(self.max_bytes_per_url, self.remaining_bytes) - - def consume_bytes(self, nbytes: int) -> None: - if nbytes > self.remaining_bytes: - raise ValueError("remote media download exceeded the per-request size limit") - self.remaining_bytes -= nbytes - - def _serialize_form_value(value: object) -> str: if isinstance(value, str): return value @@ -120,91 +85,46 @@ def _maybe_json(value: object) -> object: return json.loads(stripped) -def _content_type_from_headers(headers: Mapping[str, object]) -> str: - raw: Final = headers.get("content-type", "application/octet-stream") - if not isinstance(raw, str) or not raw.strip(): - return "application/octet-stream" - return raw.split(";", 1)[0].strip() or "application/octet-stream" - - -def _declared_content_length(headers: Mapping[str, object]) -> int | None: - raw: Final = headers.get("content-length") - if not isinstance(raw, str): - return None - stripped: Final = raw.strip() - if not stripped.isdigit(): - return None - return int(stripped) - - -def _read_capped_body(response: Response, max_bytes: int) -> bytes: - declared: Final = _declared_content_length(response.headers) - if declared is not None and declared > max_bytes: - response.close() - raise ValueError("remote media URL Content-Length exceeds the maximum allowed size") - body: Final = bytearray() # mutable-ok: streaming accumulator with a hard cap - for chunk in response.iter_bytes(chunk_size=_STREAM_CHUNK_SIZE): - body.extend(chunk) - if len(body) > max_bytes: - response.close() - raise ValueError("remote media download exceeded the maximum allowed size") - return bytes(body) - - -def _fetch_url_as_data_url(url: str, client: HTTPHandler, budget: _MediaDownloadBudget) -> str: - budget.consume_url_slot() - response: Final = safe_get(client, url) - response.raise_for_status() - content: Final = _read_capped_body(response, budget.max_read_bytes()) - budget.consume_bytes(len(content)) - encoded: Final = base64.b64encode(content).decode("ascii") - return f"data:{_content_type_from_headers(response.headers)};base64,{encoded}" - - -def _inline_reference_item(url_key: str, item: object, client: HTTPHandler, budget: _MediaDownloadBudget) -> object: - if not isinstance(item, Mapping): - return item - url: Final = item.get(url_key) - if not isinstance(url, str): - return item +def _reject_unsafe_media_url(url: str) -> None: scheme: Final = urlparse(url).scheme.lower() if scheme in ("", "data"): - return item + return if scheme not in ("http", "https"): raise SSRFError(f"URL scheme '{scheme}' is not allowed") - return { # mutable-ok: JSON form field serialized immediately - **item, - url_key: _fetch_url_as_data_url(url, client, budget), - } + validate_url(url) -def _inline_media_reference( - field_name: str, value: object, client: HTTPHandler, budget: _MediaDownloadBudget -) -> object: +def _reject_unsafe_urls_in_item(url_key: str, item: object) -> None: + if not isinstance(item, Mapping): + return + url: Final = item.get(url_key) + if isinstance(url, str): + _reject_unsafe_media_url(url) + + +def _reject_unsafe_media_urls(field_name: str, value: object) -> None: url_key: Final = _REFERENCE_URL_KEYS.get(field_name) if url_key is None: - return value + return parsed: Final = _maybe_json(value) if isinstance(parsed, list): - return tuple(_inline_reference_item(url_key, item, client, budget) for item in parsed) + for item in parsed: + _reject_unsafe_urls_in_item(url_key, item) + return if isinstance(parsed, Mapping): - return _inline_reference_item(url_key, parsed, client, budget) - return value + _reject_unsafe_urls_in_item(url_key, parsed) + + +def _form_value(key: str, value: object) -> str: + _reject_unsafe_media_urls(key, value) + return _serialize_form_value(value) def _input_reference_file(reference: object) -> tuple[str, FileTypes]: + content_type: Final = ImageEditRequestUtils.get_image_content_type(reference) if isinstance(reference, BufferedReader): - reader_name: Final = reference.name - reader_type: Final = mimetypes.guess_type(reader_name)[0] or ImageEditRequestUtils.get_image_content_type( - reference - ) - return ("input_reference", (reader_name, reference, reader_type)) - - fallback_name: Final = getattr(reference, "name", None) or "input_reference.png" - fallback_type: Final = mimetypes.guess_type(str(fallback_name))[0] or ImageEditRequestUtils.get_image_content_type( - reference - ) - return ("input_reference", (str(fallback_name), reference, fallback_type)) + return ("input_reference", (reference.name, reference, content_type)) + return ("input_reference", ("input_reference.png", reference, content_type)) class HostedVLLMVideoConfig(OpenAIVideoConfig): @@ -214,42 +134,6 @@ class HostedVLLMVideoConfig(OpenAIVideoConfig): https://docs.vllm.ai/projects/vllm-omni/en/latest/serving/videos_api/ """ - def __init__( - self, - media_http_client: HTTPHandler | None = None, - *, - max_media_bytes_per_url: int | None = None, - max_media_bytes_per_request: int | None = None, - max_media_urls_per_request: int | None = None, - ) -> None: - super().__init__() - self._media_http_client = media_http_client - self._max_media_bytes_per_url = max_media_bytes_per_url - self._max_media_bytes_per_request = max_media_bytes_per_request - self._max_media_urls_per_request = max_media_urls_per_request - - def _media_budget(self) -> _MediaDownloadBudget: - per_url: Final = ( - self._max_media_bytes_per_url - if self._max_media_bytes_per_url is not None - else int(MAX_IMAGE_URL_DOWNLOAD_SIZE_MB * _BYTES_PER_MB) - ) - per_request: Final = ( - self._max_media_bytes_per_request - if self._max_media_bytes_per_request is not None - else int(MAX_VIDEO_MEDIA_URL_TOTAL_DOWNLOAD_SIZE_MB * _BYTES_PER_MB) - ) - url_slots: Final = ( - self._max_media_urls_per_request - if self._max_media_urls_per_request is not None - else MAX_VIDEO_MEDIA_URLS_PER_REQUEST - ) - return _MediaDownloadBudget( - remaining_urls=url_slots, - remaining_bytes=per_request, - max_bytes_per_url=per_url, - ) - def get_supported_openai_params(self, model: str) -> list: # mutable-ok: BaseVideoConfig contract return [ # mutable-ok: BaseVideoConfig returns list *super().get_supported_openai_params(model), @@ -307,22 +191,16 @@ class HostedVLLMVideoConfig(OpenAIVideoConfig): litellm_params: GenericLiteLLMParams, headers: dict, # mutable-ok: BaseVideoConfig contract ) -> tuple[dict, RequestFiles, str]: # mutable-ok: BaseVideoConfig contract - media_client: Final = self._media_http_client or HTTPHandler(concurrent_limit=1) - media_budget: Final = self._media_budget() + data: Final = { # mutable-ok: BaseVideoConfig contract returns a data dict + "model": model, + "prompt": prompt, + **{ # mutable-ok: spread remaining Omni form fields into that data dict + key: _form_value(key, value) + for key, value in video_create_optional_request_params.items() + if key not in _EXCLUDED_FORM_KEYS and value is not None + }, + } input_reference: Final = video_create_optional_request_params.get("input_reference") - form_files: Final = tuple( - ( - key, - (None, _serialize_form_value(_inline_media_reference(key, value, media_client, media_budget))), - ) - for key, value in video_create_optional_request_params.items() - if key not in _EXCLUDED_FORM_KEYS and value is not None - ) - reference_files: Final = (_input_reference_file(input_reference),) if input_reference is not None else () - files: Final = ( - ("model", (None, model)), - ("prompt", (None, prompt)), - *form_files, - *reference_files, - ) - return {}, files, api_base # mutable-ok: empty data dict; files carry the multipart fields + if input_reference is None: + return data, (), api_base + return data, (_input_reference_file(input_reference),), api_base diff --git a/tests/test_litellm/llms/hosted_vllm/videos/test_hosted_vllm_video_transformation.py b/tests/test_litellm/llms/hosted_vllm/videos/test_hosted_vllm_video_transformation.py index be02e03833b..eb90430f303 100644 --- a/tests/test_litellm/llms/hosted_vllm/videos/test_hosted_vllm_video_transformation.py +++ b/tests/test_litellm/llms/hosted_vllm/videos/test_hosted_vllm_video_transformation.py @@ -1,6 +1,5 @@ """Tests for hosted_vllm video generation (vLLM-Omni /v1/videos).""" -import base64 import json from io import BytesIO @@ -21,10 +20,6 @@ from litellm.types.videos.main import VideoObject from litellm.utils import ProviderConfigManager -def _form_fields(files: list) -> dict[str, str]: - return {name: value[1] for name, value in files if value[0] is None} - - def test_provider_config_registration(): config = ProviderConfigManager.get_provider_video_config( model="hosted_vllm/MiniMax-H3", @@ -109,27 +104,25 @@ def test_transform_video_create_request_uses_multipart_form_fields(): headers={}, ) - assert data == {} assert url == "http://localhost:8091/v1/videos" - assert files - fields = _form_fields(files) - assert fields["model"] == "MiniMax-H3" - assert fields["prompt"] == "three cats march into a bedroom playing tiny brass instruments" - assert fields["width"] == "1280" - assert fields["height"] == "720" - assert fields["fps"] == "24" - assert fields["num_inference_steps"] == "20" - assert fields["flow_shift"] == "12" - assert fields["seed"] == "1101" - assert fields["aspect_ratio"] == "16:9" - assert json.loads(fields["extra_params"]) == extra_params - assert "extra_headers" not in fields + assert files == () + assert data["model"] == "MiniMax-H3" + assert data["prompt"] == "three cats march into a bedroom playing tiny brass instruments" + assert data["width"] == "1280" + assert data["height"] == "720" + assert data["fps"] == "24" + assert data["num_inference_steps"] == "20" + assert data["flow_shift"] == "12" + assert data["seed"] == "1101" + assert data["aspect_ratio"] == "16:9" + assert json.loads(data["extra_params"]) == extra_params + assert "extra_headers" not in data def test_transform_video_create_request_keeps_openai_size_and_seconds(): config = HostedVLLMVideoConfig() - _, files, _ = config.transform_video_create_request( + data, files, _ = config.transform_video_create_request( model="Wan2.2", prompt="a mountain lake at sunrise", api_base="http://localhost:8091/v1/videos", @@ -138,9 +131,9 @@ def test_transform_video_create_request_keeps_openai_size_and_seconds(): headers={}, ) - fields = _form_fields(files) - assert fields["seconds"] == "8" - assert fields["size"] == "1280x720" + assert files == () + assert data["seconds"] == "8" + assert data["size"] == "1280x720" def test_transform_video_create_request_attaches_input_reference_file(): @@ -157,14 +150,12 @@ def test_transform_video_create_request_attaches_input_reference_file(): headers={}, ) - assert data == {} - fields = _form_fields(files) - assert fields["width"] == "832" - assert "input_reference" not in fields + assert data["width"] == "832" + assert "input_reference" not in data reference_parts = [value for name, value in files if name == "input_reference"] assert len(reference_parts) == 1 filename, content, content_type = reference_parts[0] - assert filename == "input.png" + assert filename == "input_reference.png" assert content is reference assert content_type == "image/png" @@ -257,15 +248,9 @@ def test_video_generation_posts_multipart_not_json(): assert request.headers.get("content-type", "").startswith("multipart/form-data") -def test_http_image_reference_is_inlined_as_data_url(): - png_bytes = b"fake-png" - - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.host == "1.1.1.1" - return httpx.Response(200, content=png_bytes, headers={"content-type": "image/png"}) - - config = HostedVLLMVideoConfig(media_http_client=_http_handler_for(handler)) - _, files, _ = config.transform_video_create_request( +def test_http_image_reference_is_forwarded_not_downloaded(): + config = HostedVLLMVideoConfig() + data, files, _ = config.transform_video_create_request( model="MiniMax-H3", prompt="a person singing", api_base="http://localhost:8091/v1/videos", @@ -276,40 +261,15 @@ def test_http_image_reference_is_inlined_as_data_url(): headers={}, ) - payload = json.loads(_form_fields(files)["image_reference"]) - assert payload["image_url"] == "data:image/png;base64," + base64.b64encode(png_bytes).decode("ascii") + assert files == () + payload = json.loads(data["image_reference"]) + assert payload["image_url"] == "http://1.1.1.1/face.png" -def test_http_audio_reference_json_string_is_inlined_as_data_url(): - audio_bytes = b"fake-mp3" - - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, content=audio_bytes, headers={"content-type": "audio/mpeg"}) - - config = HostedVLLMVideoConfig(media_http_client=_http_handler_for(handler)) - _, files, _ = config.transform_video_create_request( - model="MiniMax-H3", - prompt="a person singing", - api_base="http://localhost:8091/v1/videos", - video_create_optional_request_params={ - "audio_reference": '{"audio_url": "http://1.1.1.1/speech.mp3"}', - }, - litellm_params=GenericLiteLLMParams(), - headers={}, - ) - - payload = json.loads(_form_fields(files)["audio_reference"]) - assert payload["audio_url"].startswith("data:audio/mpeg;base64,") - assert base64.b64decode(payload["audio_url"].split(",", 1)[1]) == audio_bytes - - -def test_data_url_image_reference_is_not_fetched(): - def handler(request: httpx.Request) -> httpx.Response: - raise AssertionError(f"unexpected fetch of {request.url}") - +def test_data_url_image_reference_is_forwarded(): data_url = "data:image/png;base64,AAAA" - config = HostedVLLMVideoConfig(media_http_client=_http_handler_for(handler)) - _, files, _ = config.transform_video_create_request( + config = HostedVLLMVideoConfig() + data, files, _ = config.transform_video_create_request( model="MiniMax-H3", prompt="a person singing", api_base="http://localhost:8091/v1/videos", @@ -318,7 +278,8 @@ def test_data_url_image_reference_is_not_fetched(): headers={}, ) - assert json.loads(_form_fields(files)["image_reference"])["image_url"] == data_url + assert files == () + assert json.loads(data["image_reference"])["image_url"] == data_url def test_metadata_url_in_image_reference_is_rejected(): @@ -349,60 +310,3 @@ def test_file_scheme_media_reference_is_rejected(): litellm_params=GenericLiteLLMParams(), headers={}, ) - - -def _transform_with_references(config: HostedVLLMVideoConfig, **references: object): - return config.transform_video_create_request( - model="MiniMax-H3", - prompt="a person singing", - api_base="http://localhost:8091/v1/videos", - video_create_optional_request_params=references, - litellm_params=GenericLiteLLMParams(), - headers={}, - ) - - -def test_oversized_content_length_is_rejected_before_body(): - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, - content=b"", - headers={"content-type": "video/mp4", "content-length": str(51 * 1024 * 1024)}, - ) - - config = HostedVLLMVideoConfig(media_http_client=_http_handler_for(handler)) - with pytest.raises(ValueError, match="Content-Length"): - _transform_with_references(config, video_reference={"video_url": "http://1.1.1.1/clip.mp4"}) - - -def test_streamed_body_over_per_url_cap_is_rejected(): - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, - content=iter((b"1234", b"5678")), - headers={"content-type": "image/png"}, - ) - - config = HostedVLLMVideoConfig( - media_http_client=_http_handler_for(handler), - max_media_bytes_per_url=4, - max_media_bytes_per_request=4, - ) - with pytest.raises(ValueError, match="exceeded the maximum allowed size"): - _transform_with_references(config, image_reference={"image_url": "http://1.1.1.1/face.png"}) - - -def test_too_many_remote_media_urls_are_rejected(): - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, content=b"ok", headers={"content-type": "image/png"}) - - config = HostedVLLMVideoConfig( - media_http_client=_http_handler_for(handler), - max_media_urls_per_request=1, - ) - with pytest.raises(ValueError, match="too many remote media URL references"): - _transform_with_references( - config, - image_reference={"image_url": "http://1.1.1.1/a.png"}, - audio_reference={"audio_url": "http://1.1.1.1/b.mp3"}, - )