Merge pull request #24899 from Sameerlite/litellm_gemini-veo-video-resolution-pricing

feat(gemini): Veo Lite pricing, video resolution usage and tiered cost
This commit is contained in:
Sameer Kankute 2026-04-02 18:27:51 +05:30 committed by GitHub
commit 1acdf912fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 804 additions and 417 deletions

View file

@ -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 **Googles default** unless you set it yourself.
You can also pass Veos **`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:
</TabItem>
</Tabs>
## 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 LiteLLMs model catalog (aligned with [Googles 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 |

View file

@ -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

View file

@ -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

View file

@ -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_<suffix>`` 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_<resolution>`` 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}"

View file

@ -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

View file

@ -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,

View file

@ -341,6 +341,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]]

View file

@ -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_<resolution> (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

View file

@ -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
),

View file

@ -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,

View file

@ -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"])

File diff suppressed because it is too large Load diff