From 54e6b25ddb849b881e4fb870b1b36a3a5822fb20 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Wed, 1 Apr 2026 11:33:49 +0530 Subject: [PATCH 1/2] =?UTF-8?q?feat(gemini):=20Veo=20Lite=20pricing,=20siz?= =?UTF-8?q?e=E2=86=92resolution,=20usage=20video=5Fresolution=20for=20cost?= =?UTF-8?q?=20tiers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- litellm/cost_calculator.py | 22 +- litellm/llms/gemini/videos/transformation.py | 55 +- litellm/llms/openai/cost_calculation.py | 43 +- .../llms/vertex_ai/videos/transformation.py | 5 +- ...odel_prices_and_context_window_backup.json | 15 + litellm/types/router.py | 1 + litellm/types/utils.py | 4 + litellm/utils.py | 3 + model_prices_and_context_window.json | 15 + .../test_gemini_video_transformation.py | 409 +++++++----- tests/test_litellm/test_video_generation.py | 606 +++++++++++------- 11 files changed, 769 insertions(+), 409 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 29d28b8c896..4b9c3891401 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -1144,15 +1144,16 @@ def completion_cost( # noqa: PLR0915 if isinstance(usage_obj, BaseModel) and not _is_known_usage_objects( usage_obj=usage_obj ): + _usage_for_dump = cast(BaseModel, usage_obj) setattr( completion_response, "usage", - litellm.Usage(**usage_obj.model_dump()), + litellm.Usage(**_usage_for_dump.model_dump()), ) if usage_obj is None: _usage = {} elif isinstance(usage_obj, BaseModel): - _usage = usage_obj.model_dump() + _usage = cast(BaseModel, usage_obj).model_dump() else: _usage = usage_obj @@ -1279,14 +1280,20 @@ def completion_cost( # noqa: PLR0915 _video_model_info = _metadata.get("model_info", None) usage_obj = getattr(completion_response, "usage", None) + duration_seconds: Optional[float] = None + video_resolution: Optional[str] = None if completion_response is not None and usage_obj: # Handle both dict and Pydantic Usage object if isinstance(usage_obj, dict): duration_seconds = usage_obj.get("duration_seconds", None) + _vr = usage_obj.get("video_resolution", None) else: duration_seconds = getattr( usage_obj, "duration_seconds", None ) + _vr = getattr(usage_obj, "video_resolution", None) + if _vr is not None: + video_resolution = str(_vr).strip().lower() if duration_seconds is not None: # Calculate cost based on video duration using video-specific cost calculation @@ -1299,6 +1306,7 @@ def completion_cost( # noqa: PLR0915 duration_seconds=duration_seconds, custom_llm_provider=custom_llm_provider, model_info=_video_model_info, + video_resolution=video_resolution, ) # Fallback to default video cost calculation if no duration available return default_video_cost_calculator( @@ -1306,6 +1314,7 @@ def completion_cost( # noqa: PLR0915 duration_seconds=0.0, # Default to 0 if no duration available custom_llm_provider=custom_llm_provider, model_info=_video_model_info, + video_resolution=video_resolution, ) elif call_type in _SPEECH_CALL_TYPES: prompt_characters = litellm.utils._count_characters(text=prompt) @@ -1626,7 +1635,7 @@ def get_response_cost_from_hidden_params( hidden_params: Union[dict, BaseModel], ) -> Optional[float]: if isinstance(hidden_params, BaseModel): - _hidden_params_dict = hidden_params.model_dump() + _hidden_params_dict = cast(BaseModel, hidden_params).model_dump() else: _hidden_params_dict = hidden_params @@ -1963,6 +1972,7 @@ def default_video_cost_calculator( duration_seconds: float, custom_llm_provider: Optional[str] = None, model_info: Optional[ModelInfo] = None, + video_resolution: Optional[str] = None, ) -> float: """ Default video cost calculator for video generation @@ -1974,6 +1984,7 @@ def default_video_cost_calculator( model_info (Optional[ModelInfo]): Deployment-level model info containing custom video pricing. When provided, used before falling back to the global litellm.model_cost lookup. + video_resolution (Optional[str]): From usage (e.g. ``720p``, ``1080p``) for tiered per-second pricing. Returns: float: Cost in USD for the video generation @@ -2027,8 +2038,9 @@ def default_video_cost_calculator( if video_cost_per_second is not None: return video_cost_per_second * duration_seconds - # Fallback to general output cost per second - output_cost_per_second = cost_info.get("output_cost_per_second") + from litellm.llms.openai.cost_calculation import _video_output_cost_per_second + + output_cost_per_second = _video_output_cost_per_second(cost_info, video_resolution) if output_cost_per_second is not None: return output_cost_per_second * duration_seconds diff --git a/litellm/llms/gemini/videos/transformation.py b/litellm/llms/gemini/videos/transformation.py index 122cc954836..99feb8eb6b5 100644 --- a/litellm/llms/gemini/videos/transformation.py +++ b/litellm/llms/gemini/videos/transformation.py @@ -54,6 +54,16 @@ def _convert_image_to_gemini_format(image_file) -> Dict[str, str]: return {"bytesBase64Encoded": base64_encoded, "mimeType": mime_type} +def _usage_video_resolution_from_parameters( + parameters: Dict[str, Any] +) -> Optional[str]: + """Normalize Veo ``parameters.resolution`` for usage and cost tracking.""" + res = parameters.get("resolution") + if res is None or res == "": + return None + return str(res).strip().lower() + + class GeminiVideoConfig(BaseVideoConfig): """ Configuration class for Gemini (Veo) video generation. @@ -65,6 +75,13 @@ class GeminiVideoConfig(BaseVideoConfig): 4. Download video using file API """ + _OPENAI_VIDEO_SIZE_TO_ASPECT_RATIO: Dict[str, str] = { + "1280x720": "16:9", + "1920x1080": "16:9", + "720x1280": "9:16", + "1080x1920": "9:16", + } + def __init__(self): super().__init__() @@ -88,6 +105,8 @@ class GeminiVideoConfig(BaseVideoConfig): - prompt → prompt - input_reference → image - size → aspectRatio (e.g., "1280x720" → "16:9") + - size → resolution when inferable ("1280x720"/"720x1280" → "720p", + "1920x1080"/"1080x1920" → "1080p"); skipped if ``resolution`` is already set - seconds → durationSeconds (defaults to 4 seconds if not provided) All other params are passed through as-is to support Gemini-specific parameters. @@ -113,6 +132,10 @@ class GeminiVideoConfig(BaseVideoConfig): aspect_ratio = self._convert_size_to_aspect_ratio(size) if aspect_ratio: mapped_params["aspectRatio"] = aspect_ratio + if not video_create_optional_params.get("resolution"): + inferred_resolution = self._convert_size_to_resolution(size) + if inferred_resolution is not None: + mapped_params["resolution"] = inferred_resolution # Map seconds to durationSeconds, default to 4 seconds (matching OpenAI) if "seconds" in video_create_optional_params: @@ -143,14 +166,27 @@ class GeminiVideoConfig(BaseVideoConfig): if not size: return None - aspect_ratio_map = { - "1280x720": "16:9", - "1920x1080": "16:9", - "720x1280": "9:16", - "1080x1920": "9:16", - } + return self._OPENAI_VIDEO_SIZE_TO_ASPECT_RATIO.get(size, "16:9") - return aspect_ratio_map.get(size, "16:9") + def _convert_size_to_resolution(self, size: str) -> Optional[str]: + """ + Map OpenAI ``size`` (WxH) to Veo ``resolution`` for presets in + ``_OPENAI_VIDEO_SIZE_TO_ASPECT_RATIO`` (720p / 1080p from the smaller edge). + + Unknown sizes return None so the API default applies (no forced resolution). + """ + if not size or size not in self._OPENAI_VIDEO_SIZE_TO_ASPECT_RATIO: + return None + try: + w_str, h_str = size.split("x", 1) + smaller = min(int(w_str), int(h_str)) + except (ValueError, TypeError): + return None + if smaller == 720: + return "720p" + if smaller == 1080: + return "1080p" + return None def validate_environment( self, @@ -279,7 +315,7 @@ class GeminiVideoConfig(BaseVideoConfig): We return this as a VideoObject with: - id: operation name (used for polling) - status: "processing" - - usage: includes duration_seconds for cost calculation + - usage: includes duration_seconds and optional video_resolution for cost calculation """ response_data = raw_response.json() @@ -319,6 +355,9 @@ class GeminiVideoConfig(BaseVideoConfig): usage_data["duration_seconds"] = float(duration) except (ValueError, TypeError): pass + video_resolution = _usage_video_resolution_from_parameters(parameters) + if video_resolution is not None: + usage_data["video_resolution"] = video_resolution video_obj.usage = usage_data return video_obj diff --git a/litellm/llms/openai/cost_calculation.py b/litellm/llms/openai/cost_calculation.py index ac1e4a6b08f..504163062c1 100644 --- a/litellm/llms/openai/cost_calculation.py +++ b/litellm/llms/openai/cost_calculation.py @@ -3,7 +3,7 @@ Helper util for handling openai-specific cost calculation - e.g.: prompt caching """ -from typing import Literal, Optional, Tuple +from typing import Any, Literal, Mapping, Optional, Tuple from litellm._logging import verbose_logger from litellm.litellm_core_utils.llm_cost_calc.utils import generic_cost_per_token @@ -128,11 +128,48 @@ def cost_per_second( return prompt_cost, completion_cost +def _video_resolution_to_cost_field_suffix(resolution: str) -> Optional[str]: + """Map usage resolution to a safe suffix for ``output_cost_per_second_`` keys.""" + r = resolution.strip().lower() + if not r: + return None + safe = "".join(c for c in r if c.isalnum() or c == "_") + if not safe or len(safe) > 24: + return None + return safe + + +def _video_output_cost_per_second( + model_info: Mapping[str, Any], + video_resolution: Optional[str], +) -> Optional[float]: + """ + Per-second video output rate from model_info. + + If ``video_resolution`` is set (e.g. ``1080p``, ``720p``, ``4k``), looks up + ``output_cost_per_second_`` first (e.g. ``output_cost_per_second_1080p``), + then falls back to ``output_cost_per_second``. + """ + r = (video_resolution or "").strip().lower() + if r: + suffix = _video_resolution_to_cost_field_suffix(r) + if suffix is not None: + tier_key = f"output_cost_per_second_{suffix}" + tier_rate = model_info.get(tier_key) + if tier_rate is not None: + return float(tier_rate) + out = model_info.get("output_cost_per_second") + if out is not None: + return float(out) + return None + + def video_generation_cost( model: str, duration_seconds: float, custom_llm_provider: Optional[str] = None, model_info: Optional[ModelInfo] = None, + video_resolution: Optional[str] = None, ) -> float: """ Calculates the cost for video generation based on duration in seconds. @@ -144,6 +181,7 @@ def video_generation_cost( - model_info: Optional[dict], deployment-level model info containing custom video pricing. When provided, skips the global get_model_info() lookup so that deployment-specific pricing is used. + - video_resolution: Optional resolution label from usage (e.g. ``720p``, ``1080p``). Returns: float - total_cost_in_usd @@ -162,8 +200,7 @@ def video_generation_cost( ) return video_cost_per_second * duration_seconds - # Fallback to general output cost per second - output_cost_per_second = model_info.get("output_cost_per_second") + output_cost_per_second = _video_output_cost_per_second(model_info, video_resolution) if output_cost_per_second is not None: verbose_logger.debug( f"For model={model} - output_cost_per_second: {output_cost_per_second}; duration: {duration_seconds}" diff --git a/litellm/llms/vertex_ai/videos/transformation.py b/litellm/llms/vertex_ai/videos/transformation.py index 1c24d657c16..83bd9eda674 100644 --- a/litellm/llms/vertex_ai/videos/transformation.py +++ b/litellm/llms/vertex_ai/videos/transformation.py @@ -344,7 +344,7 @@ class VertexAIVideoConfig(BaseVideoConfig, VertexBase): We return this as a VideoObject with: - id: operation name (used for polling) - status: "processing" - - usage: includes duration_seconds for cost calculation + - usage: includes duration_seconds and optional video_resolution for cost calculation """ response_data = raw_response.json() @@ -375,6 +375,9 @@ class VertexAIVideoConfig(BaseVideoConfig, VertexBase): usage_data["duration_seconds"] = float(duration) except (ValueError, TypeError): pass + res = parameters.get("resolution") + if res is not None and str(res).strip() != "": + usage_data["video_resolution"] = str(res).strip().lower() video_obj.usage = usage_data return video_obj diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index d4f986edd9b..635d8356735 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -15977,6 +15977,21 @@ "video" ] }, + "gemini/veo-3.1-lite-generate-preview": { + "litellm_provider": "gemini", + "max_input_tokens": 1024, + "max_tokens": 1024, + "mode": "video_generation", + "output_cost_per_second": 0.05, + "output_cost_per_second_1080p": 0.08, + "source": "https://ai.google.dev/gemini-api/docs/video", + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, "gemini/veo-3.1-fast-generate-001": { "litellm_provider": "gemini", "max_input_tokens": 1024, diff --git a/litellm/types/router.py b/litellm/types/router.py index 4257628e7cb..125e8ba46c4 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -338,6 +338,7 @@ class LiteLLMParamsTypedDict(TypedDict, total=False): output_cost_per_token: Optional[float] input_cost_per_second: Optional[float] output_cost_per_second: Optional[float] + output_cost_per_second_1080p: Optional[float] num_retries: Optional[int] ## MOCK RESPONSES ## mock_response: Optional[Union[str, ModelResponse, Exception]] diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 82557513a8a..1163912281e 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -232,6 +232,9 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False): output_cost_per_video_per_second: Optional[float] # only for vertex ai models output_cost_per_audio_per_second: Optional[float] # only for vertex ai models output_cost_per_second: Optional[float] # for OpenAI Speech models + output_cost_per_second_1080p: Optional[ + float + ] # video_generation tier: key output_cost_per_second_ (e.g. 1080p, 720p) ocr_cost_per_page: Optional[float] # for OCR models annotation_cost_per_page: Optional[float] # for OCR models search_context_cost_per_query: Optional[ @@ -2962,6 +2965,7 @@ class CustomPricingLiteLLMParams(BaseModel): output_cost_per_token: Optional[float] = None input_cost_per_second: Optional[float] = None output_cost_per_second: Optional[float] = None + output_cost_per_second_1080p: Optional[float] = None input_cost_per_pixel: Optional[float] = None output_cost_per_pixel: Optional[float] = None diff --git a/litellm/utils.py b/litellm/utils.py index e2dac1c9f62..69b24f29fd8 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -5812,6 +5812,9 @@ def _get_model_info_helper( # noqa: PLR0915 "output_cost_per_token_above_272k_tokens", None ), output_cost_per_second=_model_info.get("output_cost_per_second", None), + output_cost_per_second_1080p=_model_info.get( + "output_cost_per_second_1080p", None + ), output_cost_per_video_per_second=_model_info.get( "output_cost_per_video_per_second", None ), diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index d4f986edd9b..635d8356735 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -15977,6 +15977,21 @@ "video" ] }, + "gemini/veo-3.1-lite-generate-preview": { + "litellm_provider": "gemini", + "max_input_tokens": 1024, + "max_tokens": 1024, + "mode": "video_generation", + "output_cost_per_second": 0.05, + "output_cost_per_second_1080p": 0.08, + "source": "https://ai.google.dev/gemini-api/docs/video", + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, "gemini/veo-3.1-fast-generate-001": { "litellm_provider": "gemini", "max_input_tokens": 1024, diff --git a/tests/test_litellm/llms/gemini/videos/test_gemini_video_transformation.py b/tests/test_litellm/llms/gemini/videos/test_gemini_video_transformation.py index 868983f9085..5c483523707 100644 --- a/tests/test_litellm/llms/gemini/videos/test_gemini_video_transformation.py +++ b/tests/test_litellm/llms/gemini/videos/test_gemini_video_transformation.py @@ -25,7 +25,7 @@ class TestGeminiVideoConfig: def test_get_supported_openai_params(self): """Test that correct params are supported.""" params = self.config.get_supported_openai_params("veo-3.0-generate-preview") - + assert "model" in params assert "prompt" in params assert "input_reference" in params @@ -38,24 +38,24 @@ class TestGeminiVideoConfig: result = self.config.validate_environment( headers=headers, model="veo-3.0-generate-preview", - api_key="test-api-key-123" + api_key="test-api-key-123", ) - + assert "x-goog-api-key" in result assert result["x-goog-api-key"] == "test-api-key-123" assert "Content-Type" in result assert result["Content-Type"] == "application/json" - @patch.dict('os.environ', {}, clear=True) + @patch.dict("os.environ", {}, clear=True) def test_validate_environment_missing_api_key(self): """Test that missing API key raises error.""" headers = {} - - with pytest.raises(ValueError, match="GEMINI_API_KEY or GOOGLE_API_KEY is required"): + + with pytest.raises( + ValueError, match="GEMINI_API_KEY or GOOGLE_API_KEY is required" + ): self.config.validate_environment( - headers=headers, - model="veo-3.0-generate-preview", - api_key=None + headers=headers, model="veo-3.0-generate-preview", api_key=None ) def test_get_complete_url(self): @@ -63,20 +63,18 @@ class TestGeminiVideoConfig: url = self.config.get_complete_url( model="gemini/veo-3.0-generate-preview", api_base="https://generativelanguage.googleapis.com", - litellm_params={} + litellm_params={}, ) - + expected = "https://generativelanguage.googleapis.com/v1beta/models/veo-3.0-generate-preview:predictLongRunning" assert url == expected def test_get_complete_url_default_api_base(self): """Test URL construction with default API base.""" url = self.config.get_complete_url( - model="gemini/veo-3.0-generate-preview", - api_base=None, - litellm_params={} + model="gemini/veo-3.0-generate-preview", api_base=None, litellm_params={} ) - + assert url.startswith("https://generativelanguage.googleapis.com") assert "veo-3.0-generate-preview:predictLongRunning" in url @@ -84,32 +82,32 @@ class TestGeminiVideoConfig: """Test transformation of video creation request.""" prompt = "A cat playing with a ball of yarn" api_base = "https://generativelanguage.googleapis.com/v1beta/models/veo-3.0-generate-preview:predictLongRunning" - + data, files, url = self.config.transform_video_create_request( model="veo-3.0-generate-preview", prompt=prompt, api_base=api_base, video_create_optional_request_params={}, litellm_params=GenericLiteLLMParams(), - headers={} + headers={}, ) - + # Check Veo format assert "instances" in data assert len(data["instances"]) == 1 assert data["instances"][0]["prompt"] == prompt - + # Check no files are uploaded assert files == [] - + # URL should be returned as-is for Gemini assert url == api_base - + def test_transform_video_create_request_with_params(self): """Test transformation with optional parameters.""" prompt = "A cat playing with a ball of yarn" api_base = "https://generativelanguage.googleapis.com/v1beta/models/veo-3.0-generate-preview:predictLongRunning" - + data, files, url = self.config.transform_video_create_request( model="veo-3.0-generate-preview", prompt=prompt, @@ -117,38 +115,39 @@ class TestGeminiVideoConfig: video_create_optional_request_params={ "aspectRatio": "16:9", "durationSeconds": 8, - "resolution": "1080p" + "resolution": "1080p", }, litellm_params=GenericLiteLLMParams(), - headers={} + headers={}, ) - + # Check Veo format with instances and parameters separated instance = data["instances"][0] assert instance["prompt"] == prompt - + # Parameters should be in a separate object assert "parameters" in data assert data["parameters"]["aspectRatio"] == "16:9" assert data["parameters"]["durationSeconds"] == 8 assert data["parameters"]["resolution"] == "1080p" - + def test_map_openai_params(self): """Test parameter mapping from OpenAI format to Veo format.""" openai_params = { "size": "1280x720", "seconds": "8", - "input_reference": "test_image.jpg" + "input_reference": "test_image.jpg", } - + mapped = self.config.map_openai_params( video_create_optional_params=openai_params, model="veo-3.0-generate-preview", - drop_params=False + drop_params=False, ) - + # Check mappings (prompt is not mapped, it's passed separately) assert mapped["aspectRatio"] == "16:9" # 1280x720 is landscape + assert mapped["resolution"] == "720p" assert mapped["durationSeconds"] == 8 assert mapped["image"] == "test_image.jpg" @@ -157,14 +156,15 @@ class TestGeminiVideoConfig: openai_params = { "size": "1280x720", } - + mapped = self.config.map_openai_params( video_create_optional_params=openai_params, model="veo-3.0-generate-preview", - drop_params=False + drop_params=False, ) - + assert mapped["aspectRatio"] == "16:9" + assert mapped["resolution"] == "720p" assert "durationSeconds" not in mapped def test_map_openai_params_with_gemini_specific_params(self): @@ -175,19 +175,20 @@ class TestGeminiVideoConfig: "video": {"bytesBase64Encoded": "abc123", "mimeType": "video/mp4"}, "negativePrompt": "no people", "referenceImages": [{"bytesBase64Encoded": "xyz789"}], - "personGeneration": "allow" + "personGeneration": "allow", } - + mapped = self.config.map_openai_params( video_create_optional_params=params_with_gemini_specific, model="veo-3.1-generate-preview", - drop_params=False + drop_params=False, ) - + # Check OpenAI params are mapped assert mapped["aspectRatio"] == "16:9" + assert mapped["resolution"] == "720p" assert mapped["durationSeconds"] == 8 - + # Check Gemini-specific params are passed through assert "video" in mapped assert mapped["video"]["bytesBase64Encoded"] == "abc123" @@ -198,73 +199,106 @@ class TestGeminiVideoConfig: def test_map_openai_params_with_extra_body(self): """Test that extra_body params are merged and extra_body is removed.""" from litellm.videos.utils import VideoGenerationRequestUtils - + params_with_extra_body = { "seconds": "4", "extra_body": { "negativePrompt": "no people", "personGeneration": "allow", - "resolution": "1080p" - } + "resolution": "1080p", + }, } - + mapped = VideoGenerationRequestUtils.get_optional_params_video_generation( model="veo-3.0-generate-preview", video_generation_provider_config=self.config, - video_generation_optional_params=params_with_extra_body + video_generation_optional_params=params_with_extra_body, ) - + # Check OpenAI params are mapped assert mapped["durationSeconds"] == 4 - + # Check extra_body params are merged assert mapped["negativePrompt"] == "no people" assert mapped["personGeneration"] == "allow" assert mapped["resolution"] == "1080p" - + # Check extra_body itself is removed assert "extra_body" not in mapped - + def test_convert_size_to_aspect_ratio(self): """Test size to aspect ratio conversion.""" # Landscape assert self.config._convert_size_to_aspect_ratio("1280x720") == "16:9" assert self.config._convert_size_to_aspect_ratio("1920x1080") == "16:9" - + # Portrait assert self.config._convert_size_to_aspect_ratio("720x1280") == "9:16" assert self.config._convert_size_to_aspect_ratio("1080x1920") == "9:16" - + # Invalid (defaults to 16:9) assert self.config._convert_size_to_aspect_ratio("invalid") == "16:9" # Empty string returns None (no size specified) assert self.config._convert_size_to_aspect_ratio("") is None + def test_convert_size_to_resolution(self): + """OpenAI WxH maps to Veo resolution when height is 720 or 1080.""" + assert self.config._convert_size_to_resolution("1280x720") == "720p" + assert self.config._convert_size_to_resolution("720x1280") == "720p" + assert self.config._convert_size_to_resolution("1920x1080") == "1080p" + assert self.config._convert_size_to_resolution("1080x1920") == "1080p" + assert self.config._convert_size_to_resolution("invalid") is None + assert self.config._convert_size_to_resolution("") is None + + def test_map_openai_params_size_does_not_override_explicit_resolution(self): + """Explicit resolution wins; size still maps aspect ratio.""" + openai_params = { + "size": "1280x720", + "resolution": "1080p", + "seconds": "8", + } + mapped = self.config.map_openai_params( + video_create_optional_params=openai_params, + model="veo-3.0-generate-preview", + drop_params=False, + ) + assert mapped["aspectRatio"] == "16:9" + assert mapped["resolution"] == "1080p" + assert mapped["durationSeconds"] == 8 + + def test_map_openai_params_1080p_landscape_size(self): + openai_params = {"size": "1920x1080", "seconds": "8"} + mapped = self.config.map_openai_params( + video_create_optional_params=openai_params, + model="veo-3.0-generate-preview", + drop_params=False, + ) + assert mapped["aspectRatio"] == "16:9" + assert mapped["resolution"] == "1080p" + assert mapped["durationSeconds"] == 8 + def test_transform_video_create_response(self): """Test transformation of video creation response.""" # Mock response mock_response = Mock(spec=httpx.Response) mock_response.json.return_value = { "name": "operations/generate_1234567890", - "metadata": { - "createTime": "2024-11-04T10:00:00.123456Z" - } + "metadata": {"createTime": "2024-11-04T10:00:00.123456Z"}, } - + result = self.config.transform_video_create_response( model="veo-3.0-generate-preview", raw_response=mock_response, logging_obj=self.mock_logging_obj, - custom_llm_provider="gemini" + custom_llm_provider="gemini", ) - + assert isinstance(result, VideoObject) # ID is base64 encoded with provider info assert result.id.startswith("video_") assert result.status == "processing" assert result.object == "video" - def test_transform_video_create_response_with_cost_tracking(self): """Test that duration is captured for cost tracking.""" # Mock response @@ -272,67 +306,87 @@ class TestGeminiVideoConfig: mock_response.json.return_value = { "name": "operations/generate_1234567890", } - + # Request data with durationSeconds in parameters request_data = { "instances": [{"prompt": "A test video"}], - "parameters": { - "durationSeconds": 5, - "aspectRatio": "16:9" - } + "parameters": {"durationSeconds": 5, "aspectRatio": "16:9"}, } - + result = self.config.transform_video_create_response( model="gemini/veo-3.0-generate-preview", raw_response=mock_response, logging_obj=self.mock_logging_obj, custom_llm_provider="gemini", - request_data=request_data + request_data=request_data, ) - + assert isinstance(result, VideoObject) assert result.usage is not None, "Usage should be set" assert "duration_seconds" in result.usage, "duration_seconds should be in usage" - assert result.usage["duration_seconds"] == 5.0, f"Expected 5.0, got {result.usage['duration_seconds']}" + assert ( + result.usage["duration_seconds"] == 5.0 + ), f"Expected 5.0, got {result.usage['duration_seconds']}" - def test_transform_video_create_response_cost_tracking_with_different_durations(self): + def test_transform_video_create_response_usage_includes_video_resolution(self): + """Resolution from request parameters is copied into usage for cost tracking.""" + mock_response = Mock(spec=httpx.Response) + mock_response.json.return_value = {"name": "operations/generate_1234567890"} + request_data = { + "instances": [{"prompt": "Test"}], + "parameters": {"durationSeconds": 8, "resolution": "1080P"}, + } + result = self.config.transform_video_create_response( + model="gemini/veo-3.1-lite-generate-preview", + raw_response=mock_response, + logging_obj=self.mock_logging_obj, + custom_llm_provider="gemini", + request_data=request_data, + ) + assert result.usage is not None + assert result.usage["video_resolution"] == "1080p" + assert result.usage["duration_seconds"] == 8.0 + + def test_transform_video_create_response_cost_tracking_with_different_durations( + self, + ): """Test cost tracking with different duration values.""" # Mock response mock_response = Mock(spec=httpx.Response) mock_response.json.return_value = { "name": "operations/generate_1234567890", } - + # Test with 8 seconds request_data_8s = { "instances": [{"prompt": "Test"}], - "parameters": {"durationSeconds": 8} + "parameters": {"durationSeconds": 8}, } - + result_8s = self.config.transform_video_create_response( model="gemini/veo-3.1-generate-preview", raw_response=mock_response, logging_obj=self.mock_logging_obj, custom_llm_provider="gemini", - request_data=request_data_8s + request_data=request_data_8s, ) - + assert result_8s.usage["duration_seconds"] == 8.0 - + # Test with 4 seconds request_data_4s = { "instances": [{"prompt": "Test"}], - "parameters": {"durationSeconds": 4} + "parameters": {"durationSeconds": 4}, } - + result_4s = self.config.transform_video_create_response( model="gemini/veo-3.1-fast-generate-preview", raw_response=mock_response, logging_obj=self.mock_logging_obj, custom_llm_provider="gemini", - request_data=request_data_4s + request_data=request_data_4s, ) - + assert result_4s.usage["duration_seconds"] == 4.0 def test_transform_video_create_response_cost_tracking_no_duration(self): @@ -342,40 +396,40 @@ class TestGeminiVideoConfig: mock_response.json.return_value = { "name": "operations/generate_1234567890", } - + # Request data without durationSeconds (should default to 8 seconds for Google Veo) request_data = { "instances": [{"prompt": "A test video"}], - "parameters": { - "aspectRatio": "16:9" - } + "parameters": {"aspectRatio": "16:9"}, } - + result = self.config.transform_video_create_response( model="gemini/veo-3.0-generate-preview", raw_response=mock_response, logging_obj=self.mock_logging_obj, custom_llm_provider="gemini", - request_data=request_data + request_data=request_data, ) - + assert isinstance(result, VideoObject) # When no duration is provided, it defaults to 8 seconds (Google Veo default) assert result.usage is not None assert "duration_seconds" in result.usage - assert result.usage["duration_seconds"] == 8.0, "Should default to 8 seconds when not provided (Google Veo default)" + assert ( + result.usage["duration_seconds"] == 8.0 + ), "Should default to 8 seconds when not provided (Google Veo default)" def test_transform_video_status_retrieve_request(self): """Test transformation of status retrieve request.""" video_id = "gemini::operations/generate_1234567890::veo-3.0" - + url, params = self.config.transform_video_status_retrieve_request( video_id=video_id, api_base="https://generativelanguage.googleapis.com", litellm_params=GenericLiteLLMParams(), - headers={} + headers={}, ) - + assert "operations/generate_1234567890" in url assert "v1beta" in url assert params == {} @@ -386,17 +440,15 @@ class TestGeminiVideoConfig: mock_response.json.return_value = { "name": "operations/generate_1234567890", "done": False, - "metadata": { - "createTime": "2024-11-04T10:00:00.123456Z" - } + "metadata": {"createTime": "2024-11-04T10:00:00.123456Z"}, } - + result = self.config.transform_video_status_retrieve_response( raw_response=mock_response, logging_obj=self.mock_logging_obj, - custom_llm_provider="gemini" + custom_llm_provider="gemini", ) - + assert isinstance(result, VideoObject) assert result.status == "processing" @@ -406,36 +458,28 @@ class TestGeminiVideoConfig: mock_response.json.return_value = { "name": "operations/generate_1234567890", "done": True, - "metadata": { - "createTime": "2024-11-04T10:00:00.123456Z" - }, + "metadata": {"createTime": "2024-11-04T10:00:00.123456Z"}, "response": { "generateVideoResponse": { - "generatedSamples": [ - { - "video": { - "uri": "files/abc123xyz" - } - } - ] + "generatedSamples": [{"video": {"uri": "files/abc123xyz"}}] } - } + }, } - + result = self.config.transform_video_status_retrieve_response( raw_response=mock_response, logging_obj=self.mock_logging_obj, - custom_llm_provider="gemini" + custom_llm_provider="gemini", ) - + assert isinstance(result, VideoObject) assert result.status == "completed" - @patch('litellm.module_level_client') + @patch("litellm.module_level_client") def test_transform_video_content_request(self, mock_client): """Test transformation of content download request.""" video_id = "gemini::operations/generate_1234567890::veo-3.0" - + # Mock the status response mock_status_response = Mock(spec=httpx.Response) mock_status_response.json.return_value = { @@ -443,26 +487,20 @@ class TestGeminiVideoConfig: "done": True, "response": { "generateVideoResponse": { - "generatedSamples": [ - { - "video": { - "uri": "files/abc123xyz" - } - } - ] + "generatedSamples": [{"video": {"uri": "files/abc123xyz"}}] } - } + }, } mock_status_response.raise_for_status = Mock() mock_client.get.return_value = mock_status_response - + url, params = self.config.transform_video_content_request( video_id=video_id, api_base="https://generativelanguage.googleapis.com", litellm_params=GenericLiteLLMParams(), - headers={} + headers={}, ) - + # Should return download URL (may or may not include :download suffix) assert "files/abc123xyz" in url # Params are empty for Gemini file URIs @@ -471,16 +509,13 @@ class TestGeminiVideoConfig: def test_transform_video_content_response_bytes(self): """Test transformation of content response (returns bytes directly).""" mock_response = Mock(spec=httpx.Response) - mock_response.headers = httpx.Headers({ - "content-type": "video/mp4" - }) + mock_response.headers = httpx.Headers({"content-type": "video/mp4"}) mock_response.content = b"fake_video_data" - + result = self.config.transform_video_content_response( - raw_response=mock_response, - logging_obj=self.mock_logging_obj + raw_response=mock_response, logging_obj=self.mock_logging_obj ) - + assert result == b"fake_video_data" def test_video_remix_not_supported(self): @@ -491,7 +526,7 @@ class TestGeminiVideoConfig: prompt="test prompt", api_base="https://test.com", litellm_params=GenericLiteLLMParams(), - headers={} + headers={}, ) def test_video_list_not_supported(self): @@ -500,7 +535,7 @@ class TestGeminiVideoConfig: self.config.transform_video_list_request( api_base="https://test.com", litellm_params=GenericLiteLLMParams(), - headers={} + headers={}, ) def test_video_delete_not_supported(self): @@ -510,7 +545,7 @@ class TestGeminiVideoConfig: video_id="test_id", api_base="https://test.com", litellm_params=GenericLiteLLMParams(), - headers={} + headers={}, ) @@ -521,7 +556,7 @@ class TestGeminiVideoIntegration: """Test full workflow with mocked responses.""" config = GeminiVideoConfig() mock_logging_obj = Mock() - + # Step 1: Create request with parameters prompt = "A beautiful sunset over mountains" api_base = "https://generativelanguage.googleapis.com/v1beta/models/veo-3.0-generate-preview:predictLongRunning" @@ -531,69 +566,59 @@ class TestGeminiVideoIntegration: api_base=api_base, video_create_optional_request_params={ "aspectRatio": "16:9", - "durationSeconds": 8 + "durationSeconds": 8, }, litellm_params=GenericLiteLLMParams(), - headers={} + headers={}, ) - + # Verify instances and parameters structure assert data["instances"][0]["prompt"] == prompt assert data["parameters"]["aspectRatio"] == "16:9" assert data["parameters"]["durationSeconds"] == 8 - + # Step 2: Parse create response mock_create_response = Mock(spec=httpx.Response) mock_create_response.json.return_value = { "name": "operations/generate_abc123", - "metadata": { - "createTime": "2024-11-04T10:00:00.123456Z" - } + "metadata": {"createTime": "2024-11-04T10:00:00.123456Z"}, } - + video_obj = config.transform_video_create_response( model="veo-3.0-generate-preview", raw_response=mock_create_response, logging_obj=mock_logging_obj, - custom_llm_provider="gemini" + custom_llm_provider="gemini", ) - + assert video_obj.status == "processing" assert video_obj.id.startswith("video_") - + # Step 3: Check status (completed) mock_status_response = Mock(spec=httpx.Response) mock_status_response.json.return_value = { "name": "operations/generate_abc123", "done": True, - "metadata": { - "createTime": "2024-11-04T10:00:00.123456Z" - }, + "metadata": {"createTime": "2024-11-04T10:00:00.123456Z"}, "response": { "generateVideoResponse": { - "generatedSamples": [ - { - "video": { - "uri": "files/video123" - } - } - ] + "generatedSamples": [{"video": {"uri": "files/video123"}}] } - } + }, } - + status_obj = config.transform_video_status_retrieve_response( raw_response=mock_status_response, logging_obj=mock_logging_obj, - custom_llm_provider="gemini" + custom_llm_provider="gemini", ) - + assert status_obj.status == "completed" class TestGeminiVideoCostTracking: """Test cost tracking for Gemini video generation.""" - + def test_cost_calculation_with_duration(self): """Test that cost is calculated correctly using duration from usage.""" # Test VEO 2.0 ($0.35/second) @@ -604,8 +629,10 @@ class TestGeminiVideoCostTracking: model_info={"output_cost_per_second": 0.35}, ) expected_veo2 = 0.35 * 5.0 # $1.75 - assert abs(cost_veo2 - expected_veo2) < 0.001, f"Expected ${expected_veo2}, got ${cost_veo2}" - + assert ( + abs(cost_veo2 - expected_veo2) < 0.001 + ), f"Expected ${expected_veo2}, got ${cost_veo2}" + # Test VEO 3.0 ($0.75/second) cost_veo3 = video_generation_cost( model="gemini/veo-3.0-generate-preview", @@ -614,8 +641,10 @@ class TestGeminiVideoCostTracking: model_info={"output_cost_per_second": 0.75}, ) expected_veo3 = 0.75 * 8.0 # $6.00 - assert abs(cost_veo3 - expected_veo3) < 0.001, f"Expected ${expected_veo3}, got ${cost_veo3}" - + assert ( + abs(cost_veo3 - expected_veo3) < 0.001 + ), f"Expected ${expected_veo3}, got ${cost_veo3}" + # Test VEO 3.1 Standard ($0.40/second) cost_veo31 = video_generation_cost( model="gemini/veo-3.1-generate-preview", @@ -624,8 +653,10 @@ class TestGeminiVideoCostTracking: model_info={"output_cost_per_second": 0.40}, ) expected_veo31 = 0.40 * 10.0 # $4.00 - assert abs(cost_veo31 - expected_veo31) < 0.001, f"Expected ${expected_veo31}, got ${cost_veo31}" - + assert ( + abs(cost_veo31 - expected_veo31) < 0.001 + ), f"Expected ${expected_veo31}, got ${cost_veo31}" + # Test VEO 3.1 Fast ($0.15/second) cost_veo31_fast = video_generation_cost( model="gemini/veo-3.1-fast-generate-preview", @@ -634,39 +665,64 @@ class TestGeminiVideoCostTracking: model_info={"output_cost_per_second": 0.15}, ) expected_veo31_fast = 0.15 * 6.0 # $0.90 - assert abs(cost_veo31_fast - expected_veo31_fast) < 0.001, f"Expected ${expected_veo31_fast}, got ${cost_veo31_fast}" - + assert ( + abs(cost_veo31_fast - expected_veo31_fast) < 0.001 + ), f"Expected ${expected_veo31_fast}, got ${cost_veo31_fast}" + + def test_cost_calculation_veo_lite_1080p_tier(self): + """Veo 3.1 Lite uses output_cost_per_second_1080p when video_resolution is 1080p.""" + model_info = { + "output_cost_per_second": 0.05, + "output_cost_per_second_1080p": 0.08, + } + cost_720 = video_generation_cost( + model="gemini/veo-3.1-lite-generate-preview", + duration_seconds=10.0, + custom_llm_provider="gemini", + model_info=model_info, + video_resolution="720p", + ) + cost_1080 = video_generation_cost( + model="gemini/veo-3.1-lite-generate-preview", + duration_seconds=10.0, + custom_llm_provider="gemini", + model_info=model_info, + video_resolution="1080p", + ) + assert abs(cost_720 - 0.5) < 0.001 + assert abs(cost_1080 - 0.8) < 0.001 + def test_cost_calculation_end_to_end(self): """Test complete cost tracking flow: request -> response -> cost calculation.""" config = GeminiVideoConfig() mock_logging_obj = Mock() - + # Create request with duration request_data = { "instances": [{"prompt": "A beautiful sunset"}], - "parameters": {"durationSeconds": 5} + "parameters": {"durationSeconds": 5}, } - + # Mock response mock_response = Mock(spec=httpx.Response) mock_response.json.return_value = { "name": "operations/generate_test123", } - + # Transform response video_obj = config.transform_video_create_response( model="gemini/veo-3.0-generate-preview", raw_response=mock_response, logging_obj=mock_logging_obj, custom_llm_provider="gemini", - request_data=request_data + request_data=request_data, ) - + # Verify usage has duration assert video_obj.usage is not None assert "duration_seconds" in video_obj.usage duration = video_obj.usage["duration_seconds"] - + # Calculate cost using the duration from usage cost = video_generation_cost( model="gemini/veo-3.0-generate-preview", @@ -674,12 +730,13 @@ class TestGeminiVideoCostTracking: custom_llm_provider="gemini", model_info={"output_cost_per_second": 0.75}, ) - + # Verify cost calculation (VEO 3.0 is $0.75/second) expected_cost = 0.75 * 5.0 # $3.75 - assert abs(cost - expected_cost) < 0.001, f"Expected ${expected_cost}, got ${cost}" + assert ( + abs(cost - expected_cost) < 0.001 + ), f"Expected ${expected_cost}, got ${cost}" if __name__ == "__main__": pytest.main([__file__, "-v"]) - diff --git a/tests/test_litellm/test_video_generation.py b/tests/test_litellm/test_video_generation.py index b65db466b9f..b0eb2438b95 100644 --- a/tests/test_litellm/test_video_generation.py +++ b/tests/test_litellm/test_video_generation.py @@ -47,10 +47,10 @@ class TestVideoGeneration: "created_at": 1712697600, "model": "sora-2", "size": "720x1280", - "seconds": "8" - } + "seconds": "8", + }, ) - + assert isinstance(response, VideoObject) assert response.id == "video_123" assert response.status == "queued" @@ -68,17 +68,17 @@ class TestVideoGeneration: "completed_at": 1712697660, "model": "sora-2", "size": "1280x720", - "seconds": "10" + "seconds": "10", } - + response = video_generation( prompt="A beautiful sunset over the ocean", model="sora-2", seconds="10", size="1280x720", - mock_response=mock_data + mock_response=mock_data, ) - + assert isinstance(response, VideoObject) assert response.id == "video_456" assert response.status == "completed" @@ -94,26 +94,34 @@ class TestVideoGeneration: status="processing", created_at=1712697600, model="sora-2", - progress=50 + progress=50, ) - + # Mock the async_video_generation_handler to return the mock_response async_mock = AsyncMock(return_value=mock_response) - with patch.object(videos_main.base_llm_http_handler, 'async_video_generation_handler', async_mock): - with patch.object(videos_main.base_llm_http_handler, 'video_generation_handler', side_effect=lambda **kwargs: async_mock(**kwargs)): + with patch.object( + videos_main.base_llm_http_handler, + "async_video_generation_handler", + async_mock, + ): + with patch.object( + videos_main.base_llm_http_handler, + "video_generation_handler", + side_effect=lambda **kwargs: async_mock(**kwargs), + ): import asyncio - + async def test_async(): response = await avideo_generation( prompt="A cat playing with a ball", model="sora-2", seconds="5", - size="720x1280" + size="720x1280", ) return response - + response = asyncio.run(test_async()) - + assert isinstance(response, VideoObject) assert response.id == "video_async_123" assert response.status == "processing" @@ -125,25 +133,31 @@ class TestVideoGeneration: response = video_generation( prompt="Test video", model="sora-2", - mock_response={"id": "test", "object": "video", "status": "queued", "created_at": 1712697600} + mock_response={ + "id": "test", + "object": "video", + "status": "queued", + "created_at": 1712697600, + }, ) - + assert isinstance(response, VideoObject) assert response.id == "test" def test_video_generation_error_handling(self): """Test video generation error handling.""" - with patch.object(videos_main.base_llm_http_handler, 'video_generation_handler', side_effect=Exception("API Error")): + with patch.object( + videos_main.base_llm_http_handler, + "video_generation_handler", + side_effect=Exception("API Error"), + ): with pytest.raises(Exception): - video_generation( - prompt="Test video", - model="sora-2" - ) + video_generation(prompt="Test video", model="sora-2") def test_video_generation_provider_config(self): """Test video generation provider configuration.""" config = OpenAIVideoConfig() - + # Test supported parameters supported_params = config.get_supported_openai_params("sora-2") assert "prompt" in supported_params @@ -154,20 +168,17 @@ class TestVideoGeneration: def test_video_generation_request_transformation(self): """Test video generation request transformation.""" config = OpenAIVideoConfig() - + # Test request transformation data, files, returned_api_base = config.transform_video_create_request( model="sora-2", prompt="Test video prompt", api_base="https://api.openai.com/v1/videos", - video_create_optional_request_params={ - "seconds": "8", - "size": "720x1280" - }, + video_create_optional_request_params={"seconds": "8", "size": "720x1280"}, litellm_params=MagicMock(), - headers={} + headers={}, ) - + assert data["model"] == "sora-2" assert data["prompt"] == "Test video prompt" assert data["seconds"] == "8" @@ -206,7 +217,7 @@ class TestVideoGeneration: def test_video_generation_response_transformation(self): """Test video generation response transformation.""" config = OpenAIVideoConfig() - + # Mock HTTP response mock_http_response = MagicMock() mock_http_response.json.return_value = { @@ -216,15 +227,13 @@ class TestVideoGeneration: "created_at": 1712697600, "model": "sora-2", "size": "1280x720", - "seconds": "12" + "seconds": "12", } - + response = config.transform_video_create_response( - model="sora-2", - raw_response=mock_http_response, - logging_obj=MagicMock() + model="sora-2", raw_response=mock_http_response, logging_obj=MagicMock() ) - + assert isinstance(response, VideoObject) assert response.id == "video_789" assert response.status == "completed" @@ -241,7 +250,9 @@ class TestVideoGeneration: # Try alternative paths alt_paths = [ os.path.join(os.path.dirname(__file__), "..", "..", cost_map_path), - os.path.join(os.path.dirname(__file__), "..", "..", "..", cost_map_path), + os.path.join( + os.path.dirname(__file__), "..", "..", "..", cost_map_path + ), ] for path in alt_paths: if os.path.exists(path): @@ -249,17 +260,15 @@ class TestVideoGeneration: break else: pytest.skip("model_prices_and_context_window.json not found") - + with open(cost_map_path, "r") as f: litellm.model_cost = json.load(f) - + # Test with sora-2 model cost = default_video_cost_calculator( - model="openai/sora-2", - duration_seconds=10.0, - custom_llm_provider="openai" + model="openai/sora-2", duration_seconds=10.0, custom_llm_provider="openai" ) - + # Should calculate cost based on duration (10 seconds * $0.10 per second = $1.00) assert cost == 1.0 @@ -269,7 +278,7 @@ class TestVideoGeneration: default_video_cost_calculator( model="unknown-model", duration_seconds=5.0, - custom_llm_provider="openai" + custom_llm_provider="openai", ) def test_video_generation_cost_with_custom_model_info(self): @@ -306,6 +315,22 @@ class TestVideoGeneration: ) assert cost == 0.5 + def test_video_generation_cost_1080p_tier_via_default_calculator(self): + """default_video_cost_calculator uses output_cost_per_second_1080p when requested.""" + from litellm.cost_calculator import default_video_cost_calculator + + model_info = { + "output_cost_per_second": 0.05, + "output_cost_per_second_1080p": 0.08, + } + cost = default_video_cost_calculator( + model="my-custom-video-model", + duration_seconds=10.0, + model_info=model_info, + video_resolution="1080p", + ) + assert cost == 0.8 + def test_video_generation_cost_custom_pricing_through_completion_cost(self): """Test that custom video pricing flows through completion_cost via litellm_logging_obj. @@ -343,14 +368,44 @@ class TestVideoGeneration: ) assert cost == 0.5 + def test_completion_cost_video_generation_1080p_tier(self): + """create_video cost uses output_cost_per_second_1080p when usage.video_resolution is 1080p.""" + from litellm.cost_calculator import completion_cost + + mock_response = MagicMock() + mock_response.usage = MagicMock() + mock_response.usage.duration_seconds = 10.0 + mock_response.usage.video_resolution = "1080p" + type(mock_response)._hidden_params = {} + + mock_logging_obj = MagicMock() + mock_logging_obj.litellm_params = { + "metadata": { + "model_info": { + "output_cost_per_second": 0.05, + "output_cost_per_second_1080p": 0.08, + } + } + } + + cost = completion_cost( + completion_response=mock_response, + model="gemini/veo-3.1-lite-generate-preview", + call_type="create_video", + custom_llm_provider="gemini", + custom_pricing=True, + litellm_logging_obj=mock_logging_obj, + ) + assert abs(cost - 0.8) < 0.001 + def test_video_generation_with_files(self): """Test video generation with file uploads.""" config = OpenAIVideoConfig() - + # Mock file data mock_file = MagicMock() mock_file.read.return_value = b"fake_image_data" - + data, files, returned_api_base = config.transform_video_create_request( model="sora-2", prompt="Test video with image", @@ -358,12 +413,12 @@ class TestVideoGeneration: video_create_optional_request_params={ "input_reference": mock_file, "seconds": "8", - "size": "720x1280" + "size": "720x1280", }, litellm_params=MagicMock(), - headers={} + headers={}, ) - + assert data["model"] == "sora-2" assert data["prompt"] == "Test video with image" assert len(files) > 0 # Should have files when input_reference is provided @@ -371,14 +426,12 @@ class TestVideoGeneration: def test_video_generation_environment_validation(self): """Test video generation environment validation.""" config = OpenAIVideoConfig() - + # Test environment validation headers = config.validate_environment( - headers={}, - model="sora-2", - api_key="test-api-key" + headers={}, model="sora-2", api_key="test-api-key" ) - + assert "Authorization" in headers assert headers["Authorization"] == "Bearer test-api-key" @@ -386,36 +439,44 @@ class TestVideoGeneration: """Test that video generation handler uses api_key from litellm_params when function parameter is None.""" handler = BaseLLMHTTPHandler() config = OpenAIVideoConfig() - + # Mock the validate_environment method to capture the api_key passed to it - with patch.object(config, 'validate_environment') as mock_validate: + with patch.object(config, "validate_environment") as mock_validate: mock_validate.return_value = {"Authorization": "Bearer deployment-api-key"} - + # Mock the transform and HTTP client - with patch.object(config, 'transform_video_create_request') as mock_transform: - mock_transform.return_value = ({"model": "sora-2", "prompt": "test"}, [], "https://api.openai.com/v1/videos") - + with patch.object( + config, "transform_video_create_request" + ) as mock_transform: + mock_transform.return_value = ( + {"model": "sora-2", "prompt": "test"}, + [], + "https://api.openai.com/v1/videos", + ) + # Mock the transform_video_create_response to avoid needing a real response - with patch.object(config, 'transform_video_create_response') as mock_transform_response: + with patch.object( + config, "transform_video_create_response" + ) as mock_transform_response: mock_video_object = MagicMock() mock_video_object.id = "video_123" mock_video_object.object = "video" mock_video_object.status = "queued" mock_transform_response.return_value = mock_video_object - + mock_response = MagicMock() mock_response.json.return_value = { "id": "video_123", "object": "video", "status": "queued", "created_at": 1712697600, - "model": "sora-2" + "model": "sora-2", } mock_response.status_code = 200 - + mock_client = MagicMock() mock_client.post.return_value = mock_response - + with patch( "litellm.llms.custom_httpx.llm_http_handler._get_httpx_client", return_value=mock_client, @@ -426,13 +487,16 @@ class TestVideoGeneration: video_generation_provider_config=config, video_generation_optional_request_params={}, custom_llm_provider="openai", - litellm_params={"api_key": "deployment-api-key", "api_base": "https://api.openai.com/v1"}, + litellm_params={ + "api_key": "deployment-api-key", + "api_base": "https://api.openai.com/v1", + }, logging_obj=MagicMock(), timeout=5.0, api_key=None, # Function parameter is None _is_async=False, ) - + # Verify validate_environment was called with api_key from litellm_params mock_validate.assert_called_once() call_args = mock_validate.call_args @@ -441,31 +505,29 @@ class TestVideoGeneration: def test_video_generation_url_generation(self): """Test video generation URL generation.""" config = OpenAIVideoConfig() - + # Test URL generation url = config.get_complete_url( - model="sora-2", - api_base="https://api.openai.com/v1", - litellm_params={} + model="sora-2", api_base="https://api.openai.com/v1", litellm_params={} ) - + assert url == "https://api.openai.com/v1/videos" def test_video_generation_parameter_mapping(self): """Test video generation parameter mapping.""" config = OpenAIVideoConfig() - + # Test parameter mapping mapped_params = config.map_openai_params( video_create_optional_params={ "seconds": "8", "size": "720x1280", - "user": "test-user" + "user": "test-user", }, model="sora-2", - drop_params=False + drop_params=False, ) - + assert mapped_params["seconds"] == "8" assert mapped_params["size"] == "720x1280" assert mapped_params["user"] == "test-user" @@ -481,13 +543,10 @@ class TestVideoGeneration: video_generation_provider_config=OpenAIVideoConfig(), video_generation_optional_params={ "seconds": "8", - "extra_body": { - "vertex_ai_param": "value", - "gemini_param": "value2" - } - } + "extra_body": {"vertex_ai_param": "value", "gemini_param": "value2"}, + }, ) - + # extra_body params should be merged into the result assert result["seconds"] == "8" assert result["vertex_ai_param"] == "value" @@ -503,20 +562,20 @@ class TestVideoGeneration: object="video", status="completed", created_at=1712697600, - model="sora-2" + model="sora-2", ) - + assert video_obj.id == "test_id" assert video_obj.object == "video" assert video_obj.status == "completed" - + # Test dictionary-like access assert video_obj["id"] == "test_id" assert video_obj["status"] == "completed" assert "id" in video_obj assert video_obj.get("id") == "test_id" assert video_obj.get("nonexistent", "default") == "default" - + # Test JSON serialization json_data = video_obj.json() assert json_data["id"] == "test_id" @@ -526,22 +585,19 @@ class TestVideoGeneration: """Test video generation response types.""" # Test VideoResponse video_obj = VideoObject( - id="test_id", - object="video", - status="completed", - created_at=1712697600 + id="test_id", object="video", status="completed", created_at=1712697600 ) - + response = VideoResponse(data=[video_obj]) - + assert len(response.data) == 1 assert response.data[0].id == "test_id" - + # Test dictionary-like access assert response["data"][0]["id"] == "test_id" assert "data" in response assert response.get("data")[0]["id"] == "test_id" - + # Test JSON serialization json_data = response.json() assert len(json_data["data"]) == 1 @@ -562,10 +618,10 @@ class TestVideoGeneration: "model": "sora-2", "progress": 100, "size": "720x1280", - "seconds": "8" - } + "seconds": "8", + }, ) - + assert isinstance(response, VideoObject) assert response.id == "video_123" assert response.status == "completed" @@ -582,15 +638,13 @@ class TestVideoGeneration: "model": "sora-2", "progress": 75, "size": "1280x720", - "seconds": "10" + "seconds": "10", } - + response = video_status( - video_id="video_456", - model="sora-2", - mock_response=mock_data + video_id="video_456", model="sora-2", mock_response=mock_data ) - + assert isinstance(response, VideoObject) assert response.id == "video_456" assert response.status == "processing" @@ -605,24 +659,29 @@ class TestVideoGeneration: status="queued", created_at=1712697600, model="sora-2", - progress=0 + progress=0, ) - + # Mock the async_video_status_handler to return the mock_response async_mock = AsyncMock(return_value=mock_response) - with patch.object(videos_main.base_llm_http_handler, 'async_video_status_handler', async_mock): - with patch.object(videos_main.base_llm_http_handler, 'video_status_handler', side_effect=lambda **kwargs: async_mock(**kwargs)): + with patch.object( + videos_main.base_llm_http_handler, "async_video_status_handler", async_mock + ): + with patch.object( + videos_main.base_llm_http_handler, + "video_status_handler", + side_effect=lambda **kwargs: async_mock(**kwargs), + ): import asyncio - + async def test_async(): response = await avideo_status( - video_id="video_async_123", - model="sora-2" + video_id="video_async_123", model="sora-2" ) return response - + response = asyncio.run(test_async()) - + assert isinstance(response, VideoObject) assert response.id == "video_async_123" assert response.status == "queued" @@ -634,40 +693,46 @@ class TestVideoGeneration: response = video_status( video_id="test_video_id", model="sora-2", - mock_response={"id": "test", "object": "video", "status": "completed", "created_at": 1712697600} + mock_response={ + "id": "test", + "object": "video", + "status": "completed", + "created_at": 1712697600, + }, ) - + assert isinstance(response, VideoObject) assert response.id == "test" def test_video_status_error_handling(self): """Test video status error handling.""" - with patch.object(videos_main.base_llm_http_handler, 'video_status_handler', side_effect=Exception("API Error")): + with patch.object( + videos_main.base_llm_http_handler, + "video_status_handler", + side_effect=Exception("API Error"), + ): with pytest.raises(Exception): - video_status( - video_id="test_video_id", - model="sora-2" - ) + video_status(video_id="test_video_id", model="sora-2") def test_video_status_request_transformation(self): """Test video status request transformation.""" config = OpenAIVideoConfig() - + # Test request transformation url, data = config.transform_video_status_retrieve_request( video_id="video_123", api_base="https://api.openai.com/v1/videos", litellm_params=MagicMock(), - headers={} + headers={}, ) - + assert url == "https://api.openai.com/v1/videos/video_123" assert data == {} def test_video_status_response_transformation(self): """Test video status response transformation.""" config = OpenAIVideoConfig() - + # Mock HTTP response mock_http_response = MagicMock() mock_http_response.json.return_value = { @@ -679,14 +744,13 @@ class TestVideoGeneration: "model": "sora-2", "progress": 100, "size": "1280x720", - "seconds": "12" + "seconds": "12", } - + response = config.transform_video_status_retrieve_response( - raw_response=mock_http_response, - logging_obj=MagicMock() + raw_response=mock_http_response, logging_obj=MagicMock() ) - + assert isinstance(response, VideoObject) assert response.id == "video_789" assert response.status == "completed" @@ -705,12 +769,12 @@ class TestVideoGeneration: "status": "queued", "created_at": 1712697600, "model": "sora-2", - "progress": 0 - } + "progress": 0, + }, ) assert queued_response.status == "queued" assert queued_response.progress == 0 - + # Test processing state processing_response = video_status( video_id="video_processing", @@ -721,12 +785,12 @@ class TestVideoGeneration: "status": "processing", "created_at": 1712697600, "model": "sora-2", - "progress": 50 - } + "progress": 50, + }, ) assert processing_response.status == "processing" assert processing_response.progress == 50 - + # Test completed state completed_response = video_status( video_id="video_completed", @@ -738,8 +802,8 @@ class TestVideoGeneration: "created_at": 1712697600, "completed_at": 1712697660, "model": "sora-2", - "progress": 100 - } + "progress": 100, + }, ) assert completed_response.status == "completed" assert completed_response.progress == 100 @@ -756,25 +820,23 @@ class TestVideoGeneration: "progress": 100, "remixed_from_video_id": "video_original_123", "size": "720x1280", - "seconds": "8" + "seconds": "8", } - + response = video_status( - video_id="video_remix_123", - model="sora-2", - mock_response=mock_data + video_id="video_remix_123", model="sora-2", mock_response=mock_data ) - + assert isinstance(response, VideoObject) assert response.id == "video_remix_123" assert response.status == "completed" - assert hasattr(response, 'remixed_from_video_id') + assert hasattr(response, "remixed_from_video_id") assert response.remixed_from_video_id == "video_original_123" def test_video_status_async_inside_async_function(self): """Test that sync video_status works inside async functions (no asyncio.run issues).""" import asyncio - + async def test_sync_in_async(): # This should work without asyncio.run() issues # Use mock_response parameter for reliable testing @@ -787,13 +849,13 @@ class TestVideoGeneration: "status": "completed", "created_at": 1712697600, "model": "sora-2", - "progress": 100 - } + "progress": 100, + }, ) return response - + response = asyncio.run(test_sync_in_async()) - + assert isinstance(response, VideoObject) assert response.id == "video_sync_in_async" assert response.status == "completed" @@ -801,20 +863,32 @@ class TestVideoGeneration: def test_video_status_url_construction(self): """Test video status URL construction.""" config = OpenAIVideoConfig() - + # Test with different API bases test_cases = [ - ("https://api.openai.com/v1/videos", "video_123", "https://api.openai.com/v1/videos/video_123"), - ("https://api.openai.com/v1/videos/", "video_123", "https://api.openai.com/v1/videos/video_123"), - ("https://custom-api.com/v1/videos", "video_456", "https://custom-api.com/v1/videos/video_456"), + ( + "https://api.openai.com/v1/videos", + "video_123", + "https://api.openai.com/v1/videos/video_123", + ), + ( + "https://api.openai.com/v1/videos/", + "video_123", + "https://api.openai.com/v1/videos/video_123", + ), + ( + "https://custom-api.com/v1/videos", + "video_456", + "https://custom-api.com/v1/videos/video_456", + ), ] - + for api_base, video_id, expected_url in test_cases: url, data = config.transform_video_status_retrieve_request( video_id=video_id, api_base=api_base, litellm_params=MagicMock(), - headers={} + headers={}, ) assert url == expected_url assert data == {} @@ -822,14 +896,16 @@ class TestVideoGeneration: class TestVideoLogging: """Test video generation logging functionality.""" - + class TestVideoLogger(CustomLogger): def __init__(self): self.standard_logging_payload = None - - async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): + + async def async_log_success_event( + self, kwargs, response_obj, start_time, end_time + ): self.standard_logging_payload = kwargs.get("standard_logging_object") - + @pytest.mark.asyncio async def test_video_generation_logging(self): """Test that video generation creates proper logging payload with cost tracking. @@ -848,7 +924,7 @@ class TestVideoLogging: created_at=1712697600, model="sora-2", size="720x1280", - seconds="8" + seconds="8", ) # Create async mock function to return the mock_response @@ -856,12 +932,16 @@ class TestVideoLogging: return mock_response # Patch the async_video_generation_handler method on base_llm_http_handler - with patch.object(videos_main.base_llm_http_handler, 'async_video_generation_handler', side_effect=mock_async_handler): + with patch.object( + videos_main.base_llm_http_handler, + "async_video_generation_handler", + side_effect=mock_async_handler, + ): response = await litellm.avideo_generation( prompt="A cat running in a garden", model="sora-2", seconds="8", - size="720x1280" + size="720x1280", ) await asyncio.sleep(1) # Allow logging to complete @@ -963,9 +1043,7 @@ def test_video_content_handler_passes_variant_to_url(): video_id="video_abc", video_content_provider_config=config, custom_llm_provider="openai", - litellm_params=GenericLiteLLMParams( - api_base="https://api.openai.com/v1" - ), + litellm_params=GenericLiteLLMParams(api_base="https://api.openai.com/v1"), logging_obj=MagicMock(), timeout=5.0, api_key="sk-test", @@ -976,7 +1054,10 @@ def test_video_content_handler_passes_variant_to_url(): assert result == b"thumbnail-bytes" called_url = mock_client.get.call_args.kwargs["url"] - assert called_url == "https://api.openai.com/v1/videos/video_abc/content?variant=thumbnail" + assert ( + called_url + == "https://api.openai.com/v1/videos/video_abc/content?variant=thumbnail" + ) def test_video_content_handler_uses_get_for_openai(): @@ -986,7 +1067,7 @@ def test_video_content_handler_uses_get_for_openai(): # Clear the HTTP client cache to prevent test isolation issues # In CI, a cached real HTTPHandler from a previous test might bypass the mock - if hasattr(litellm, 'in_memory_llm_clients_cache'): + if hasattr(litellm, "in_memory_llm_clients_cache"): litellm.in_memory_llm_clients_cache.flush_cache() handler = BaseLLMHTTPHandler() @@ -1001,7 +1082,9 @@ def test_video_content_handler_uses_get_for_openai(): # Patch _get_httpx_client to ensure no real HTTP client is created # This prevents test isolation issues where isinstance check might fail - with patch('litellm.llms.custom_httpx.llm_http_handler._get_httpx_client') as mock_get_client: + with patch( + "litellm.llms.custom_httpx.llm_http_handler._get_httpx_client" + ) as mock_get_client: mock_get_client.return_value = mock_client result = handler.video_content_handler( @@ -1029,15 +1112,15 @@ def test_video_content_respects_api_base_and_api_key_from_kwargs(): # Mock the handler to capture litellm_params captured_litellm_params = None - + def capture_litellm_params(*args, **kwargs): nonlocal captured_litellm_params captured_litellm_params = kwargs.get("litellm_params") return b"mp4-bytes" - - with patch('litellm.videos.main.base_llm_http_handler') as mock_handler: + + with patch("litellm.videos.main.base_llm_http_handler") as mock_handler: mock_handler.video_content_handler = capture_litellm_params - + # Call video_content with api_base and api_key in kwargs (simulating database entry) # This simulates how the router passes model config from database via **kwargs result = video_content( @@ -1046,10 +1129,13 @@ def test_video_content_respects_api_base_and_api_key_from_kwargs(): api_base="https://test-resource.openai.azure.com/", # Passed via kwargs by router api_key="test-api-key-from-db", # Passed via kwargs by router ) - + # Verify that api_base and api_key from kwargs were included in litellm_params assert captured_litellm_params is not None - assert captured_litellm_params.get("api_base") == "https://test-resource.openai.azure.com/" + assert ( + captured_litellm_params.get("api_base") + == "https://test-resource.openai.azure.com/" + ) assert captured_litellm_params.get("api_key") == "test-api-key-from-db" assert result == b"mp4-bytes" @@ -1070,7 +1156,7 @@ def test_encode_video_id_with_provider_handles_azure_video_prefix(): """ Test that encode_video_id_with_provider correctly encodes Azure/OpenAI video IDs that start with 'video_' prefix. - + This test verifies the fix for the issue where Azure returns video IDs like 'video_69323201cf6081909263f751f89991e6', which were previously skipped from encoding, causing video status retrieval to default to 'openai' provider. @@ -1084,32 +1170,29 @@ def test_encode_video_id_with_provider_handles_azure_video_prefix(): raw_azure_video_id = "video_69323201cf6081909263f751f89991e6" provider = "azure" model_id = "azure/sora-2" - + # Encode the video ID with provider information encoded_id = encode_video_id_with_provider( - video_id=raw_azure_video_id, - provider=provider, - model_id=model_id + video_id=raw_azure_video_id, provider=provider, model_id=model_id ) - + # Verify the ID was encoded (should be different from the original) assert encoded_id != raw_azure_video_id assert encoded_id.startswith("video_") - + # Decode the encoded ID to verify provider information is preserved decoded = decode_video_id_with_provider(encoded_id) assert decoded.get("custom_llm_provider") == provider assert decoded.get("model_id") == model_id assert decoded.get("video_id") == raw_azure_video_id - + # Verify that encoding an already-encoded ID doesn't double-encode it encoded_twice = encode_video_id_with_provider( - video_id=encoded_id, - provider=provider, - model_id=model_id + video_id=encoded_id, provider=provider, model_id=model_id ) assert encoded_twice == encoded_id # Should return the same encoded ID - + + class TestVideoListTransformation: """Tests for video list request/response transformation with provider ID encoding.""" @@ -1171,7 +1254,12 @@ class TestVideoListTransformation: mock_http_response.json.return_value = { "object": "list", "data": [ - {"id": "video_aaa", "object": "video", "model": "sora-2", "status": "completed"}, + { + "id": "video_aaa", + "object": "video", + "model": "sora-2", + "status": "completed", + }, ], "first_id": "video_aaa", "last_id": "video_aaa", @@ -1196,7 +1284,12 @@ class TestVideoListTransformation: mock_http_response.json.return_value = { "object": "list", "data": [ - {"id": "video_aaa", "object": "video", "model": "sora-2", "status": "completed"}, + { + "id": "video_aaa", + "object": "video", + "model": "sora-2", + "status": "completed", + }, ], "has_more": False, } @@ -1259,8 +1352,18 @@ class TestVideoListTransformation: mock_http_response.json.return_value = { "object": "list", "data": [ - {"id": "video_aaa", "object": "video", "model": "sora-2", "status": "completed"}, - {"id": "video_bbb", "object": "video", "model": "sora-2", "status": "completed"}, + { + "id": "video_aaa", + "object": "video", + "model": "sora-2", + "status": "completed", + }, + { + "id": "video_bbb", + "object": "video", + "model": "sora-2", + "status": "completed", + }, ], "first_id": "video_aaa", "last_id": "video_bbb", @@ -1318,16 +1421,16 @@ class TestVideoEndpointsProxyLitellmParams: "vertex_project": "test-project-123", "vertex_location": "global", "vertex_credentials": "/path/to/test-credentials.json", - } + }, } ] } - + # Write config to temporary file - with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: yaml.dump(config, f) config_fp = f.name - + try: # Initialize the proxy with the test config app = FastAPI() @@ -1339,6 +1442,7 @@ class TestVideoEndpointsProxyLitellmParams: finally: # Clean up temporary file import os + if os.path.exists(config_fp): os.unlink(config_fp) @@ -1383,7 +1487,10 @@ class TestVideoEndpointsProxyLitellmParams: @pytest.mark.asyncio async def test_video_status_respects_litellm_params( - self, client_with_vertex_config, mock_video_generation_response, mock_video_status_response + self, + client_with_vertex_config, + mock_video_generation_response, + mock_video_status_response, ): """Test that video_status endpoint uses litellm_params from proxy config.""" from unittest.mock import AsyncMock, MagicMock, patch @@ -1393,7 +1500,9 @@ class TestVideoEndpointsProxyLitellmParams: # Mock the router instance mock_router_instance = MagicMock() - mock_router_instance.resolve_model_name_from_model_id.return_value = "vertex-ai-sora-2" + mock_router_instance.resolve_model_name_from_model_id.return_value = ( + "vertex-ai-sora-2" + ) mock_router_instance.model_names = {"vertex-ai-sora-2"} mock_router_instance.has_model_id.return_value = False @@ -1401,13 +1510,16 @@ class TestVideoEndpointsProxyLitellmParams: # route_request should return a coroutine (not await it), so we return a coroutine async def mock_route_request_func(*args, **kwargs): return mock_video_status_response - + # Create a coroutine that will be added to tasks def create_mock_coroutine(*args, **kwargs): return mock_route_request_func(*args, **kwargs) with patch("litellm.proxy.proxy_server.llm_router", mock_router_instance): - with patch("litellm.proxy.common_request_processing.route_request", side_effect=create_mock_coroutine) as mock_route_request: + with patch( + "litellm.proxy.common_request_processing.route_request", + side_effect=create_mock_coroutine, + ) as mock_route_request: # Make request to video_status endpoint response = client_with_vertex_config.get( f"/v1/videos/{encoded_video_id}", @@ -1421,7 +1533,15 @@ class TestVideoEndpointsProxyLitellmParams: assert mock_route_request.called call_args = mock_route_request.call_args # route_request is called with data as a keyword argument - data_passed = call_args.kwargs.get("data", {}) if call_args.kwargs else (call_args.args[0] if call_args.args and len(call_args.args) > 0 else {}) + data_passed = ( + call_args.kwargs.get("data", {}) + if call_args.kwargs + else ( + call_args.args[0] + if call_args.args and len(call_args.args) > 0 + else {} + ) + ) # Verify that model was resolved and added to data assert data_passed.get("model") == "vertex-ai-sora-2", ( @@ -1436,7 +1556,10 @@ class TestVideoEndpointsProxyLitellmParams: @pytest.mark.asyncio async def test_video_content_respects_litellm_params( - self, client_with_vertex_config, mock_video_generation_response, mock_video_content_response + self, + client_with_vertex_config, + mock_video_generation_response, + mock_video_content_response, ): """Test that video_content endpoint uses litellm_params from proxy config.""" from unittest.mock import AsyncMock, MagicMock, patch @@ -1446,7 +1569,9 @@ class TestVideoEndpointsProxyLitellmParams: # Mock the router instance mock_router_instance = MagicMock() - mock_router_instance.resolve_model_name_from_model_id.return_value = "vertex-ai-sora-2" + mock_router_instance.resolve_model_name_from_model_id.return_value = ( + "vertex-ai-sora-2" + ) mock_router_instance.model_names = {"vertex-ai-sora-2"} mock_router_instance.has_model_id.return_value = False @@ -1454,13 +1579,16 @@ class TestVideoEndpointsProxyLitellmParams: # route_request should return a coroutine (not await it), so we return a coroutine async def mock_route_request_func(*args, **kwargs): return mock_video_content_response - + # Create a coroutine that will be added to tasks def create_mock_coroutine(*args, **kwargs): return mock_route_request_func(*args, **kwargs) with patch("litellm.proxy.proxy_server.llm_router", mock_router_instance): - with patch("litellm.proxy.common_request_processing.route_request", side_effect=create_mock_coroutine) as mock_route_request: + with patch( + "litellm.proxy.common_request_processing.route_request", + side_effect=create_mock_coroutine, + ) as mock_route_request: # Make request to video_content endpoint response = client_with_vertex_config.get( f"/v1/videos/{encoded_video_id}/content", @@ -1474,7 +1602,15 @@ class TestVideoEndpointsProxyLitellmParams: assert mock_route_request.called call_args = mock_route_request.call_args # route_request is called with data as a keyword argument - data_passed = call_args.kwargs.get("data", {}) if call_args.kwargs else (call_args.args[0] if call_args.args and len(call_args.args) > 0 else {}) + data_passed = ( + call_args.kwargs.get("data", {}) + if call_args.kwargs + else ( + call_args.args[0] + if call_args.args and len(call_args.args) > 0 + else {} + ) + ) # Verify that model was resolved and added to data assert data_passed.get("model") == "vertex-ai-sora-2", ( @@ -1489,7 +1625,10 @@ class TestVideoEndpointsProxyLitellmParams: @pytest.mark.asyncio async def test_video_content_preserves_custom_llm_provider_from_decoded_id( - self, client_with_vertex_config, mock_video_generation_response, mock_video_content_response + self, + client_with_vertex_config, + mock_video_generation_response, + mock_video_content_response, ): """Test that video_content preserves custom_llm_provider from decoded video_id.""" from unittest.mock import AsyncMock, MagicMock, patch @@ -1499,7 +1638,9 @@ class TestVideoEndpointsProxyLitellmParams: # Mock the router instance mock_router_instance = MagicMock() - mock_router_instance.resolve_model_name_from_model_id.return_value = "vertex-ai-sora-2" + mock_router_instance.resolve_model_name_from_model_id.return_value = ( + "vertex-ai-sora-2" + ) mock_router_instance.model_names = {"vertex-ai-sora-2"} mock_router_instance.has_model_id.return_value = False @@ -1507,13 +1648,16 @@ class TestVideoEndpointsProxyLitellmParams: # route_request should return a coroutine (not await it), so we return a coroutine async def mock_route_request_func(*args, **kwargs): return mock_video_content_response - + # Create a coroutine that will be added to tasks def create_mock_coroutine(*args, **kwargs): return mock_route_request_func(*args, **kwargs) with patch("litellm.proxy.proxy_server.llm_router", mock_router_instance): - with patch("litellm.proxy.common_request_processing.route_request", side_effect=create_mock_coroutine) as mock_route_request: + with patch( + "litellm.proxy.common_request_processing.route_request", + side_effect=create_mock_coroutine, + ) as mock_route_request: # Make request to video_content endpoint response = client_with_vertex_config.get( f"/v1/videos/{encoded_video_id}/content", @@ -1527,7 +1671,15 @@ class TestVideoEndpointsProxyLitellmParams: assert mock_route_request.called call_args = mock_route_request.call_args # route_request is called with data as a keyword argument - data_passed = call_args.kwargs.get("data", {}) if call_args.kwargs else (call_args.args[0] if call_args.args and len(call_args.args) > 0 else {}) + data_passed = ( + call_args.kwargs.get("data", {}) + if call_args.kwargs + else ( + call_args.args[0] + if call_args.args and len(call_args.args) > 0 + else {} + ) + ) # Most importantly: verify that custom_llm_provider is "vertex_ai" not "openai" # This was the bug we fixed - it was defaulting to "openai" before @@ -1547,7 +1699,10 @@ def test_video_remix_handler_uses_api_key_from_litellm_params(): mock_validate.return_value = {"Authorization": "Bearer deployment-key"} with patch.object(config, "transform_video_remix_request") as mock_transform: - mock_transform.return_value = ("https://api.openai.com/v1/videos/video_123/remix", {"prompt": "remix it"}) + mock_transform.return_value = ( + "https://api.openai.com/v1/videos/video_123/remix", + {"prompt": "remix it"}, + ) with patch.object(config, "transform_video_remix_response") as mock_resp: mock_resp.return_value = MagicMock() @@ -1564,7 +1719,10 @@ def test_video_remix_handler_uses_api_key_from_litellm_params(): prompt="remix it", video_remix_provider_config=config, custom_llm_provider="openai", - litellm_params={"api_key": "deployment-key", "api_base": "https://api.openai.com/v1"}, + litellm_params={ + "api_key": "deployment-key", + "api_base": "https://api.openai.com/v1", + }, logging_obj=MagicMock(), timeout=5.0, api_key=None, @@ -1585,7 +1743,10 @@ async def test_async_video_remix_handler_uses_api_key_from_litellm_params(): mock_validate.return_value = {"Authorization": "Bearer deployment-key"} with patch.object(config, "transform_video_remix_request") as mock_transform: - mock_transform.return_value = ("https://api.openai.com/v1/videos/video_123/remix", {"prompt": "remix it"}) + mock_transform.return_value = ( + "https://api.openai.com/v1/videos/video_123/remix", + {"prompt": "remix it"}, + ) with patch.object(config, "transform_video_remix_response") as mock_resp: mock_resp.return_value = MagicMock() @@ -1603,7 +1764,10 @@ async def test_async_video_remix_handler_uses_api_key_from_litellm_params(): prompt="remix it", video_remix_provider_config=config, custom_llm_provider="openai", - litellm_params={"api_key": "deployment-key", "api_base": "https://api.openai.com/v1"}, + litellm_params={ + "api_key": "deployment-key", + "api_base": "https://api.openai.com/v1", + }, logging_obj=MagicMock(), timeout=5.0, api_key=None, @@ -1622,7 +1786,10 @@ def test_video_remix_handler_prefers_explicit_api_key(): mock_validate.return_value = {"Authorization": "Bearer explicit-key"} with patch.object(config, "transform_video_remix_request") as mock_transform: - mock_transform.return_value = ("https://api.openai.com/v1/videos/video_123/remix", {"prompt": "remix it"}) + mock_transform.return_value = ( + "https://api.openai.com/v1/videos/video_123/remix", + {"prompt": "remix it"}, + ) with patch.object(config, "transform_video_remix_response") as mock_resp: mock_resp.return_value = MagicMock() @@ -1639,7 +1806,10 @@ def test_video_remix_handler_prefers_explicit_api_key(): prompt="remix it", video_remix_provider_config=config, custom_llm_provider="openai", - litellm_params={"api_key": "deployment-key", "api_base": "https://api.openai.com/v1"}, + litellm_params={ + "api_key": "deployment-key", + "api_base": "https://api.openai.com/v1", + }, logging_obj=MagicMock(), timeout=5.0, api_key="explicit-key", @@ -1852,6 +2022,7 @@ class TestVideoEdit: def test_video_edit_strips_encoded_provider_from_video_id(self): """Provider-encoded video IDs are decoded before sending to API.""" from litellm.types.videos.utils import encode_video_id_with_provider + config = OpenAIVideoConfig() encoded_id = encode_video_id_with_provider("raw_video_id", "openai", None) @@ -1925,6 +2096,7 @@ class TestVideoExtension: def test_video_extension_strips_encoded_provider_from_video_id(self): """Provider-encoded video IDs are decoded before sending to API.""" from litellm.types.videos.utils import encode_video_id_with_provider + config = OpenAIVideoConfig() encoded_id = encode_video_id_with_provider("raw_video_id", "openai", None) @@ -1991,7 +2163,9 @@ def test_character_id_decode_handles_missing_base64_padding(): assert decoded["model_id"] == "gpt-4o" -def test_video_create_character_target_model_names_returns_encoded_id(video_proxy_test_client): +def test_video_create_character_target_model_names_returns_encoded_id( + video_proxy_test_client, +): from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing from litellm.types.videos.utils import decode_character_id_with_provider From e0f0a364dc579287064f77a09e1c8c866f1f6a88 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Wed, 1 Apr 2026 11:39:57 +0530 Subject: [PATCH 2/2] Add docs --- .../docs/providers/gemini/videos.md | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/docs/my-website/docs/providers/gemini/videos.md b/docs/my-website/docs/providers/gemini/videos.md index 5b5d5a8a636..3af43656929 100644 --- a/docs/my-website/docs/providers/gemini/videos.md +++ b/docs/my-website/docs/providers/gemini/videos.md @@ -9,8 +9,8 @@ LiteLLM supports Google's Veo video generation models through a unified API inte |-------|-------| | Description | Google's Veo AI video generation models | | Provider Route on LiteLLM | `gemini/` | -| Supported Models | `veo-3.0-generate-preview`, `veo-3.1-generate-preview` | -| Cost Tracking | ✅ Duration-based pricing | +| Supported Models | Veo 3.0 / 3.1 preview and production IDs (see table below), including **Veo 3.1 Lite** | +| Cost Tracking | ✅ Duration-based pricing; optional **per-resolution** tiers where the catalog lists them (e.g. 720p vs 1080p) | | Logging Support | ✅ Full request/response logging | | Proxy Server Support | ✅ Full proxy integration with virtual keys | | Spend Management | ✅ Budget tracking and rate limiting | @@ -79,6 +79,11 @@ print("Video downloaded successfully!") |------------|-------------|--------------|--------| | veo-3.0-generate-preview | Veo 3.0 video generation | 8 seconds | Preview | | veo-3.1-generate-preview | Veo 3.1 video generation | 8 seconds | Preview | +| veo-3.1-lite-generate-preview | Veo 3.1 **Lite** (cost-efficient; [Gemini pricing](https://ai.google.dev/gemini-api/docs/video)) | Per Google docs | Preview | +| veo-3.1-fast-generate-preview / `…-001` | Faster / prod variants | Per Google docs | Preview / GA | +| veo-3.1-generate-001 | Veo 3.1 production | Per Google docs | GA | + +Use the full LiteLLM model id with the `gemini/` prefix (for example `gemini/veo-3.1-lite-generate-preview`). ## Video Generation Parameters @@ -87,14 +92,29 @@ LiteLLM automatically maps OpenAI-style parameters to Veo's format: | OpenAI Parameter | Veo Parameter | Description | Example | |------------------|---------------|-------------|---------| | `prompt` | `prompt` | Text description of the video | "A cat playing" | -| `size` | `aspectRatio` | Video dimensions → aspect ratio | "1280x720" → "16:9" | +| `size` | `aspectRatio` and, when applicable, **`resolution`** | Standard widths/heights map to landscape/portrait **and** to `720p` or `1080p` for the API | See below | | `seconds` | `durationSeconds` | Duration in seconds | "8" → 8 | | `input_reference` | `image` | Reference image to animate | File object or path | | `model` | `model` | Model to use | "gemini/veo-3.0-generate-preview" | -### Size to Aspect Ratio Mapping +### `size` and output resolution + +When you pass a **standard `size`** string, LiteLLM sets both: + +- **Aspect ratio** (`16:9` or `9:16`) — same as before. +- **Output resolution** (`720p` or `1080p`) when the height is clear from the preset, so the correct Veo tier is requested without extra fields. + +| `size` | Aspect ratio | Resolution sent to Veo | +|--------|----------------|-------------------------| +| `1280x720`, `720x1280` | `16:9` / `9:16` | `720p` | +| `1920x1080`, `1080x1920` | `16:9` / `9:16` | `1080p` | + +Other `size` values still map to an aspect ratio (defaulting to `16:9` when unknown); resolution is left to **Google’s default** unless you set it yourself. + +You can also pass Veo’s **`resolution`** (for example via `extra_body`) if you need an explicit value that does not match the presets above. If you set `resolution` yourself, it takes precedence over the value inferred from `size`. + +### Size to aspect ratio (reference) -LiteLLM automatically converts size dimensions to Veo's aspect ratio format: - `"1280x720"`, `"1920x1080"` → `"16:9"` (landscape) - `"720x1280"`, `"1080x1920"` → `"9:16"` (portrait) @@ -293,7 +313,14 @@ with open("video.mp4", "wb") as f: -## Cost Tracking +## Cost tracking and spend + +LiteLLM estimates **video spend** from: + +1. **How long** the generated clip is billed for (seconds), and +2. **The per-second price** for that model in LiteLLM’s model catalog (aligned with [Google’s Gemini API video pricing](https://ai.google.dev/gemini-api/docs/video) where applicable). + +Some models charge **different per-second rates** for **720p** vs **1080p**. When you use the standard `size` presets above (or set `resolution` explicitly), LiteLLM uses the matching tier so **proxy spend, logs, and budgets** line up with the resolution you requested. LiteLLM automatically tracks costs for Veo video generation: @@ -314,8 +341,8 @@ response = litellm.video_generation( | Feature | OpenAI (Sora) | Gemini (Veo) | |---------|---------------|--------------| | Reference Images | ✅ Supported | ❌ Not supported | -| Size Control | ✅ Supported | ❌ Not supported | -| Duration Control | ✅ Supported | ❌ Not supported | +| Size / dimensions | ✅ Supported | ✅ Supported via `size` → aspect ratio + `720p`/`1080p` where preset | +| Duration (`seconds`) | ✅ Supported | ✅ Supported (maps to `durationSeconds`; limits per Google docs) | | Video Remix/Edit | ✅ Supported | ❌ Not supported | | Video List | ✅ Supported | ❌ Not supported | | Prompt-based Generation | ✅ Supported | ✅ Supported |