mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
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
This commit is contained in:
parent
85d5ac2b5c
commit
8f812f5636
5 changed files with 424 additions and 2 deletions
161
litellm/llms/hosted_vllm/videos/transformation.py
Normal file
161
litellm/llms/hosted_vllm/videos/transformation.py
Normal file
|
|
@ -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
|
||||
|
|
@ -1180,7 +1180,8 @@
|
|||
"files": true,
|
||||
"rerank": true,
|
||||
"a2a": true,
|
||||
"interactions": true
|
||||
"interactions": true,
|
||||
"video_generations": true
|
||||
}
|
||||
},
|
||||
"huggingface": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1277,7 +1277,8 @@
|
|||
"files": true,
|
||||
"rerank": true,
|
||||
"a2a": true,
|
||||
"interactions": true
|
||||
"interactions": true,
|
||||
"video_generations": true
|
||||
}
|
||||
},
|
||||
"huggingface": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
Loading…
Add table
Reference in a new issue