mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-08 17:10:14 +00:00
2168 lines
82 KiB
Python
2168 lines
82 KiB
Python
import asyncio
|
|
import io
|
|
import json
|
|
import os
|
|
import sys
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(
|
|
0, os.path.abspath("../..")
|
|
) # Adds the parent directory to the system path
|
|
|
|
import litellm
|
|
from litellm.cost_calculator import default_video_cost_calculator
|
|
from litellm.integrations.custom_logger import CustomLogger
|
|
from litellm.litellm_core_utils.litellm_logging import Logging as LitellmLogging
|
|
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
|
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
|
|
from litellm.llms.gemini.videos.transformation import GeminiVideoConfig
|
|
from litellm.llms.openai.videos.transformation import OpenAIVideoConfig
|
|
from litellm.types.videos.main import VideoObject, VideoResponse
|
|
from litellm.videos import main as videos_main
|
|
from litellm.videos.main import (
|
|
avideo_generation,
|
|
avideo_status,
|
|
video_generation,
|
|
video_status,
|
|
)
|
|
|
|
|
|
class TestVideoGeneration:
|
|
"""Test suite for video generation functionality."""
|
|
|
|
def test_video_generation_basic(self):
|
|
"""Test basic video generation functionality."""
|
|
# Use mock_response parameter for reliable testing
|
|
response = video_generation(
|
|
prompt="Show them running around the room",
|
|
model="sora-2",
|
|
seconds="8",
|
|
size="720x1280",
|
|
mock_response={
|
|
"id": "video_123",
|
|
"object": "video",
|
|
"status": "queued",
|
|
"created_at": 1712697600,
|
|
"model": "sora-2",
|
|
"size": "720x1280",
|
|
"seconds": "8"
|
|
}
|
|
)
|
|
|
|
assert isinstance(response, VideoObject)
|
|
assert response.id == "video_123"
|
|
assert response.status == "queued"
|
|
assert response.model == "sora-2"
|
|
assert response.size == "720x1280"
|
|
assert response.seconds == "8"
|
|
|
|
def test_video_generation_with_mock_response(self):
|
|
"""Test video generation with mock response."""
|
|
mock_data = {
|
|
"id": "video_456",
|
|
"object": "video",
|
|
"status": "completed",
|
|
"created_at": 1712697600,
|
|
"completed_at": 1712697660,
|
|
"model": "sora-2",
|
|
"size": "1280x720",
|
|
"seconds": "10"
|
|
}
|
|
|
|
response = video_generation(
|
|
prompt="A beautiful sunset over the ocean",
|
|
model="sora-2",
|
|
seconds="10",
|
|
size="1280x720",
|
|
mock_response=mock_data
|
|
)
|
|
|
|
assert isinstance(response, VideoObject)
|
|
assert response.id == "video_456"
|
|
assert response.status == "completed"
|
|
assert response.model == "sora-2"
|
|
assert response.size == "1280x720"
|
|
assert response.seconds == "10"
|
|
|
|
def test_video_generation_async(self):
|
|
"""Test async video generation functionality."""
|
|
mock_response = VideoObject(
|
|
id="video_async_123",
|
|
object="video",
|
|
status="processing",
|
|
created_at=1712697600,
|
|
model="sora-2",
|
|
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)):
|
|
import asyncio
|
|
|
|
async def test_async():
|
|
response = await avideo_generation(
|
|
prompt="A cat playing with a ball",
|
|
model="sora-2",
|
|
seconds="5",
|
|
size="720x1280"
|
|
)
|
|
return response
|
|
|
|
response = asyncio.run(test_async())
|
|
|
|
assert isinstance(response, VideoObject)
|
|
assert response.id == "video_async_123"
|
|
assert response.status == "processing"
|
|
assert response.progress == 50
|
|
|
|
def test_video_generation_parameter_validation(self):
|
|
"""Test video generation parameter validation."""
|
|
# Test with minimal required parameters
|
|
response = video_generation(
|
|
prompt="Test video",
|
|
model="sora-2",
|
|
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 pytest.raises(Exception):
|
|
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
|
|
assert "model" in supported_params
|
|
assert "seconds" in supported_params
|
|
assert "size" in supported_params
|
|
|
|
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"
|
|
},
|
|
litellm_params=MagicMock(),
|
|
headers={}
|
|
)
|
|
|
|
assert data["model"] == "sora-2"
|
|
assert data["prompt"] == "Test video prompt"
|
|
assert data["seconds"] == "8"
|
|
assert data["size"] == "720x1280"
|
|
assert files == []
|
|
assert returned_api_base == "https://api.openai.com/v1/videos"
|
|
|
|
def test_video_generation_request_decodes_encoded_character_ids(self):
|
|
"""Encoded character IDs should be decoded before upstream create-video call."""
|
|
from litellm.types.videos.utils import encode_character_id_with_provider
|
|
|
|
config = OpenAIVideoConfig()
|
|
encoded_character_id = encode_character_id_with_provider(
|
|
character_id="char_123",
|
|
provider="openai",
|
|
model_id="sora-2",
|
|
)
|
|
|
|
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",
|
|
"characters": [{"id": encoded_character_id}],
|
|
},
|
|
litellm_params=MagicMock(),
|
|
headers={},
|
|
)
|
|
|
|
assert data["characters"] == [{"id": "char_123"}]
|
|
assert files == []
|
|
assert returned_api_base == "https://api.openai.com/v1/videos"
|
|
|
|
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 = {
|
|
"id": "video_789",
|
|
"object": "video",
|
|
"status": "completed",
|
|
"created_at": 1712697600,
|
|
"model": "sora-2",
|
|
"size": "1280x720",
|
|
"seconds": "12"
|
|
}
|
|
|
|
response = config.transform_video_create_response(
|
|
model="sora-2",
|
|
raw_response=mock_http_response,
|
|
logging_obj=MagicMock()
|
|
)
|
|
|
|
assert isinstance(response, VideoObject)
|
|
assert response.id == "video_789"
|
|
assert response.status == "completed"
|
|
assert response.model == "sora-2"
|
|
|
|
def test_video_generation_cost_calculation(self):
|
|
"""Test video generation cost calculation."""
|
|
import json
|
|
import os
|
|
|
|
# Try to load the local model cost map, skip if not found
|
|
cost_map_path = "model_prices_and_context_window.json"
|
|
if not os.path.exists(cost_map_path):
|
|
# 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),
|
|
]
|
|
for path in alt_paths:
|
|
if os.path.exists(path):
|
|
cost_map_path = path
|
|
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"
|
|
)
|
|
|
|
# Should calculate cost based on duration (10 seconds * $0.10 per second = $1.00)
|
|
assert cost == 1.0
|
|
|
|
def test_video_generation_cost_calculation_unknown_model(self):
|
|
"""Test video generation cost calculation for unknown model."""
|
|
with pytest.raises(Exception, match="Model not found in cost map"):
|
|
default_video_cost_calculator(
|
|
model="unknown-model",
|
|
duration_seconds=5.0,
|
|
custom_llm_provider="openai"
|
|
)
|
|
|
|
def test_video_generation_cost_with_custom_model_info(self):
|
|
"""Test that custom model_info pricing is applied for video generation.
|
|
|
|
When a deployment has custom pricing via model_info, it should be used
|
|
instead of looking up the global litellm.model_cost map.
|
|
|
|
Related: https://github.com/BerriAI/litellm/issues/21907
|
|
"""
|
|
model_info = {
|
|
"output_cost_per_video_per_second": 0.05,
|
|
}
|
|
cost = default_video_cost_calculator(
|
|
model="my-custom-video-model",
|
|
duration_seconds=10.0,
|
|
model_info=model_info,
|
|
)
|
|
assert cost == 0.5
|
|
|
|
def test_video_generation_cost_custom_model_info_fallback_to_per_second(self):
|
|
"""Test that output_cost_per_second is used as fallback when
|
|
output_cost_per_video_per_second is not set in custom model_info.
|
|
|
|
Related: https://github.com/BerriAI/litellm/issues/21907
|
|
"""
|
|
model_info = {
|
|
"output_cost_per_second": 0.10,
|
|
}
|
|
cost = default_video_cost_calculator(
|
|
model="my-custom-video-model",
|
|
duration_seconds=5.0,
|
|
model_info=model_info,
|
|
)
|
|
assert cost == 0.5
|
|
|
|
def test_video_generation_cost_custom_pricing_through_completion_cost(self):
|
|
"""Test that custom video pricing flows through completion_cost via litellm_logging_obj.
|
|
|
|
This tests the full cost calculation path: completion_cost extracts model_info
|
|
from litellm_logging_obj.litellm_params.metadata.model_info and passes it to
|
|
the video cost calculator.
|
|
|
|
Related: https://github.com/BerriAI/litellm/issues/21907
|
|
"""
|
|
from litellm.cost_calculator import completion_cost
|
|
|
|
# Create mock response with usage containing duration_seconds
|
|
mock_response = MagicMock()
|
|
mock_response.usage = MagicMock()
|
|
mock_response.usage.duration_seconds = 10.0
|
|
type(mock_response)._hidden_params = {}
|
|
|
|
# Create mock litellm_logging_obj with custom pricing
|
|
mock_logging_obj = MagicMock()
|
|
mock_logging_obj.litellm_params = {
|
|
"metadata": {
|
|
"model_info": {
|
|
"output_cost_per_video_per_second": 0.05,
|
|
}
|
|
}
|
|
}
|
|
|
|
cost = completion_cost(
|
|
completion_response=mock_response,
|
|
model="openai/hunyuanvideo",
|
|
call_type="create_video",
|
|
custom_llm_provider="openai",
|
|
custom_pricing=True,
|
|
litellm_logging_obj=mock_logging_obj,
|
|
)
|
|
assert cost == 0.5
|
|
|
|
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",
|
|
api_base="https://api.openai.com/v1/videos",
|
|
video_create_optional_request_params={
|
|
"input_reference": mock_file,
|
|
"seconds": "8",
|
|
"size": "720x1280"
|
|
},
|
|
litellm_params=MagicMock(),
|
|
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
|
|
|
|
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"
|
|
)
|
|
|
|
assert "Authorization" in headers
|
|
assert headers["Authorization"] == "Bearer test-api-key"
|
|
|
|
def test_video_generation_uses_api_key_from_litellm_params(self):
|
|
"""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:
|
|
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")
|
|
|
|
# Mock the transform_video_create_response to avoid needing a real 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"
|
|
}
|
|
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,
|
|
):
|
|
result = handler.video_generation_handler(
|
|
model="sora-2",
|
|
prompt="test prompt",
|
|
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"},
|
|
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
|
|
assert call_args.kwargs["api_key"] == "deployment-api-key"
|
|
|
|
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={}
|
|
)
|
|
|
|
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"
|
|
},
|
|
model="sora-2",
|
|
drop_params=False
|
|
)
|
|
|
|
assert mapped_params["seconds"] == "8"
|
|
assert mapped_params["size"] == "720x1280"
|
|
assert mapped_params["user"] == "test-user"
|
|
|
|
def test_video_generation_unsupported_parameters(self):
|
|
"""Test video generation with provider-specific parameters via extra_body."""
|
|
from litellm.videos.utils import VideoGenerationRequestUtils
|
|
|
|
# Test that provider-specific parameters can be passed via extra_body
|
|
# This allows support for Vertex AI and Gemini specific parameters
|
|
result = VideoGenerationRequestUtils.get_optional_params_video_generation(
|
|
model="sora-2",
|
|
video_generation_provider_config=OpenAIVideoConfig(),
|
|
video_generation_optional_params={
|
|
"seconds": "8",
|
|
"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"
|
|
assert result["gemini_param"] == "value2"
|
|
# extra_body itself should be removed from the result
|
|
assert "extra_body" not in result
|
|
|
|
def test_video_generation_types(self):
|
|
"""Test video generation type definitions."""
|
|
# Test VideoObject
|
|
video_obj = VideoObject(
|
|
id="test_id",
|
|
object="video",
|
|
status="completed",
|
|
created_at=1712697600,
|
|
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"
|
|
assert json_data["object"] == "video"
|
|
|
|
def test_video_generation_response_types(self):
|
|
"""Test video generation response types."""
|
|
# Test VideoResponse
|
|
video_obj = VideoObject(
|
|
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
|
|
assert json_data["data"][0]["id"] == "test_id"
|
|
|
|
def test_video_status_basic(self):
|
|
"""Test basic video status functionality."""
|
|
# Use mock_response parameter for reliable testing
|
|
response = video_status(
|
|
video_id="video_123",
|
|
model="sora-2",
|
|
mock_response={
|
|
"id": "video_123",
|
|
"object": "video",
|
|
"status": "completed",
|
|
"created_at": 1712697600,
|
|
"completed_at": 1712697660,
|
|
"model": "sora-2",
|
|
"progress": 100,
|
|
"size": "720x1280",
|
|
"seconds": "8"
|
|
}
|
|
)
|
|
|
|
assert isinstance(response, VideoObject)
|
|
assert response.id == "video_123"
|
|
assert response.status == "completed"
|
|
assert response.progress == 100
|
|
assert response.model == "sora-2"
|
|
|
|
def test_video_status_with_mock_response(self):
|
|
"""Test video status with mock response."""
|
|
mock_data = {
|
|
"id": "video_456",
|
|
"object": "video",
|
|
"status": "processing",
|
|
"created_at": 1712697600,
|
|
"model": "sora-2",
|
|
"progress": 75,
|
|
"size": "1280x720",
|
|
"seconds": "10"
|
|
}
|
|
|
|
response = video_status(
|
|
video_id="video_456",
|
|
model="sora-2",
|
|
mock_response=mock_data
|
|
)
|
|
|
|
assert isinstance(response, VideoObject)
|
|
assert response.id == "video_456"
|
|
assert response.status == "processing"
|
|
assert response.progress == 75
|
|
assert response.model == "sora-2"
|
|
|
|
def test_video_status_async(self):
|
|
"""Test async video status functionality."""
|
|
mock_response = VideoObject(
|
|
id="video_async_123",
|
|
object="video",
|
|
status="queued",
|
|
created_at=1712697600,
|
|
model="sora-2",
|
|
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)):
|
|
import asyncio
|
|
|
|
async def test_async():
|
|
response = await avideo_status(
|
|
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"
|
|
assert response.progress == 0
|
|
|
|
def test_video_status_parameter_validation(self):
|
|
"""Test video status parameter validation."""
|
|
# Test with minimal required parameters
|
|
response = video_status(
|
|
video_id="test_video_id",
|
|
model="sora-2",
|
|
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 pytest.raises(Exception):
|
|
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={}
|
|
)
|
|
|
|
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 = {
|
|
"id": "video_789",
|
|
"object": "video",
|
|
"status": "completed",
|
|
"created_at": 1712697600,
|
|
"completed_at": 1712697660,
|
|
"model": "sora-2",
|
|
"progress": 100,
|
|
"size": "1280x720",
|
|
"seconds": "12"
|
|
}
|
|
|
|
response = config.transform_video_status_retrieve_response(
|
|
raw_response=mock_http_response,
|
|
logging_obj=MagicMock()
|
|
)
|
|
|
|
assert isinstance(response, VideoObject)
|
|
assert response.id == "video_789"
|
|
assert response.status == "completed"
|
|
assert response.progress == 100
|
|
assert response.model == "sora-2"
|
|
|
|
def test_video_status_different_states(self):
|
|
"""Test video status with different video states."""
|
|
# Test queued state
|
|
queued_response = video_status(
|
|
video_id="video_queued",
|
|
model="sora-2",
|
|
mock_response={
|
|
"id": "video_queued",
|
|
"object": "video",
|
|
"status": "queued",
|
|
"created_at": 1712697600,
|
|
"model": "sora-2",
|
|
"progress": 0
|
|
}
|
|
)
|
|
assert queued_response.status == "queued"
|
|
assert queued_response.progress == 0
|
|
|
|
# Test processing state
|
|
processing_response = video_status(
|
|
video_id="video_processing",
|
|
model="sora-2",
|
|
mock_response={
|
|
"id": "video_processing",
|
|
"object": "video",
|
|
"status": "processing",
|
|
"created_at": 1712697600,
|
|
"model": "sora-2",
|
|
"progress": 50
|
|
}
|
|
)
|
|
assert processing_response.status == "processing"
|
|
assert processing_response.progress == 50
|
|
|
|
# Test completed state
|
|
completed_response = video_status(
|
|
video_id="video_completed",
|
|
model="sora-2",
|
|
mock_response={
|
|
"id": "video_completed",
|
|
"object": "video",
|
|
"status": "completed",
|
|
"created_at": 1712697600,
|
|
"completed_at": 1712697660,
|
|
"model": "sora-2",
|
|
"progress": 100
|
|
}
|
|
)
|
|
assert completed_response.status == "completed"
|
|
assert completed_response.progress == 100
|
|
|
|
def test_video_status_with_remix_info(self):
|
|
"""Test video status with remix information."""
|
|
mock_data = {
|
|
"id": "video_remix_123",
|
|
"object": "video",
|
|
"status": "completed",
|
|
"created_at": 1712697600,
|
|
"completed_at": 1712697660,
|
|
"model": "sora-2",
|
|
"progress": 100,
|
|
"remixed_from_video_id": "video_original_123",
|
|
"size": "720x1280",
|
|
"seconds": "8"
|
|
}
|
|
|
|
response = video_status(
|
|
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 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
|
|
response = video_status(
|
|
video_id="video_sync_in_async",
|
|
model="sora-2",
|
|
mock_response={
|
|
"id": "video_sync_in_async",
|
|
"object": "video",
|
|
"status": "completed",
|
|
"created_at": 1712697600,
|
|
"model": "sora-2",
|
|
"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"
|
|
|
|
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"),
|
|
]
|
|
|
|
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={}
|
|
)
|
|
assert url == expected_url
|
|
assert data == {}
|
|
|
|
|
|
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):
|
|
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.
|
|
|
|
Note: Uses AsyncMock with side_effect pattern for reliable parallel execution.
|
|
"""
|
|
custom_logger = self.TestVideoLogger()
|
|
litellm.logging_callback_manager._reset_all_callbacks()
|
|
litellm.callbacks = [custom_logger]
|
|
|
|
# Mock video generation response
|
|
mock_response = VideoObject(
|
|
id="video_test_123",
|
|
object="video",
|
|
status="queued",
|
|
created_at=1712697600,
|
|
model="sora-2",
|
|
size="720x1280",
|
|
seconds="8"
|
|
)
|
|
|
|
# Create async mock function to return the mock_response
|
|
async def mock_async_handler(*args, **kwargs):
|
|
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):
|
|
response = await litellm.avideo_generation(
|
|
prompt="A cat running in a garden",
|
|
model="sora-2",
|
|
seconds="8",
|
|
size="720x1280"
|
|
)
|
|
|
|
await asyncio.sleep(1) # Allow logging to complete
|
|
|
|
# Verify logging payload was created
|
|
assert custom_logger.standard_logging_payload is not None
|
|
|
|
payload = custom_logger.standard_logging_payload
|
|
|
|
# Verify basic logging fields
|
|
assert payload["call_type"] == "avideo_generation"
|
|
assert payload["status"] == "success"
|
|
assert payload["model"] == "sora-2"
|
|
assert payload["custom_llm_provider"] == "openai"
|
|
|
|
# Verify response object is recognized for logging
|
|
assert payload["response"] is not None
|
|
assert payload["response"]["id"] == "video_test_123"
|
|
assert payload["response"]["object"] == "video"
|
|
|
|
# Verify cost tracking is present (may be 0 in test environment)
|
|
assert payload["response_cost"] is not None
|
|
# Note: Cost calculation may not work in test environment due to mocking
|
|
# The important thing is that the logging payload is created and recognized
|
|
|
|
|
|
def test_openai_transform_video_content_request_empty_params():
|
|
"""OpenAI content transform should return empty params to ensure GET is used."""
|
|
config = OpenAIVideoConfig()
|
|
url, params = config.transform_video_content_request(
|
|
video_id="video_123",
|
|
api_base="https://api.openai.com/v1/videos",
|
|
litellm_params={},
|
|
headers={},
|
|
)
|
|
|
|
assert url == "https://api.openai.com/v1/videos/video_123/content"
|
|
assert params == {}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"variant,expected_suffix",
|
|
[
|
|
("thumbnail", "?variant=thumbnail"),
|
|
("spritesheet", "?variant=spritesheet"),
|
|
],
|
|
)
|
|
def test_openai_transform_video_content_request_with_variant(variant, expected_suffix):
|
|
"""OpenAI content transform should append ?variant= when variant is provided."""
|
|
config = OpenAIVideoConfig()
|
|
url, params = config.transform_video_content_request(
|
|
video_id="video_123",
|
|
api_base="https://api.openai.com/v1/videos",
|
|
litellm_params={},
|
|
headers={},
|
|
variant=variant,
|
|
)
|
|
|
|
assert url == f"https://api.openai.com/v1/videos/video_123/content{expected_suffix}"
|
|
assert params == {}
|
|
|
|
|
|
def test_openai_transform_video_content_request_variant_none_no_query_param():
|
|
"""OpenAI content transform should NOT append ?variant= when variant is None."""
|
|
config = OpenAIVideoConfig()
|
|
url, params = config.transform_video_content_request(
|
|
video_id="video_123",
|
|
api_base="https://api.openai.com/v1/videos",
|
|
litellm_params={},
|
|
headers={},
|
|
variant=None,
|
|
)
|
|
|
|
assert "variant" not in url
|
|
assert url == "https://api.openai.com/v1/videos/video_123/content"
|
|
|
|
|
|
def test_video_content_handler_passes_variant_to_url():
|
|
"""HTTP handler should pass variant through to the final URL."""
|
|
from litellm.llms.custom_httpx.http_handler import HTTPHandler
|
|
from litellm.types.router import GenericLiteLLMParams
|
|
|
|
if hasattr(litellm, "in_memory_llm_clients_cache"):
|
|
litellm.in_memory_llm_clients_cache.flush_cache()
|
|
|
|
handler = BaseLLMHTTPHandler()
|
|
config = OpenAIVideoConfig()
|
|
|
|
mock_client = MagicMock(spec=HTTPHandler)
|
|
mock_response = MagicMock()
|
|
mock_response.content = b"thumbnail-bytes"
|
|
mock_client.get.return_value = mock_response
|
|
|
|
with patch(
|
|
"litellm.llms.custom_httpx.llm_http_handler._get_httpx_client",
|
|
return_value=mock_client,
|
|
):
|
|
result = handler.video_content_handler(
|
|
video_id="video_abc",
|
|
video_content_provider_config=config,
|
|
custom_llm_provider="openai",
|
|
litellm_params=GenericLiteLLMParams(
|
|
api_base="https://api.openai.com/v1"
|
|
),
|
|
logging_obj=MagicMock(),
|
|
timeout=5.0,
|
|
api_key="sk-test",
|
|
client=mock_client,
|
|
_is_async=False,
|
|
variant="thumbnail",
|
|
)
|
|
|
|
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"
|
|
|
|
|
|
def test_video_content_handler_uses_get_for_openai():
|
|
"""HTTP handler must use GET (not POST) for OpenAI content download."""
|
|
from litellm.llms.custom_httpx.http_handler import HTTPHandler
|
|
from litellm.types.router import GenericLiteLLMParams
|
|
|
|
# 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'):
|
|
litellm.in_memory_llm_clients_cache.flush_cache()
|
|
|
|
handler = BaseLLMHTTPHandler()
|
|
config = OpenAIVideoConfig()
|
|
|
|
# Use spec=HTTPHandler so isinstance(mock_client, HTTPHandler) returns True,
|
|
# ensuring the handler uses our mock directly instead of creating a new client.
|
|
mock_client = MagicMock(spec=HTTPHandler)
|
|
mock_response = MagicMock()
|
|
mock_response.content = b"mp4-bytes"
|
|
mock_client.get.return_value = mock_response
|
|
|
|
# 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:
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = handler.video_content_handler(
|
|
video_id="video_abc",
|
|
video_content_provider_config=config,
|
|
custom_llm_provider="openai",
|
|
litellm_params=GenericLiteLLMParams(api_base="https://api.openai.com/v1"),
|
|
logging_obj=MagicMock(),
|
|
timeout=5.0,
|
|
api_key="sk-test",
|
|
client=mock_client,
|
|
_is_async=False,
|
|
)
|
|
|
|
assert result == b"mp4-bytes"
|
|
mock_client.get.assert_called_once()
|
|
assert not mock_client.post.called
|
|
called_url = mock_client.get.call_args.kwargs["url"]
|
|
assert called_url == "https://api.openai.com/v1/videos/video_abc/content"
|
|
|
|
|
|
def test_video_content_respects_api_base_and_api_key_from_kwargs():
|
|
"""Test that video_content respects api_base and api_key from kwargs (simulating database entry)."""
|
|
from litellm.videos.main import video_content
|
|
|
|
# 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:
|
|
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(
|
|
video_id="video_test_123",
|
|
custom_llm_provider="azure",
|
|
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_key") == "test-api-key-from-db"
|
|
assert result == b"mp4-bytes"
|
|
|
|
|
|
def test_openai_video_config_has_async_transform():
|
|
"""Ensure OpenAIVideoConfig exposes async_transform_video_content_response at runtime."""
|
|
cfg = OpenAIVideoConfig()
|
|
assert callable(getattr(cfg, "async_transform_video_content_response", None))
|
|
|
|
|
|
def test_gemini_video_config_has_async_transform():
|
|
"""Ensure GeminiVideoConfig exposes async_transform_video_content_response at runtime."""
|
|
cfg = GeminiVideoConfig()
|
|
assert callable(getattr(cfg, "async_transform_video_content_response", None))
|
|
|
|
|
|
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.
|
|
"""
|
|
from litellm.types.videos.utils import (
|
|
decode_video_id_with_provider,
|
|
encode_video_id_with_provider,
|
|
)
|
|
|
|
# Test case: Azure returns a video ID starting with 'video_'
|
|
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
|
|
)
|
|
|
|
# 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
|
|
)
|
|
assert encoded_twice == encoded_id # Should return the same encoded ID
|
|
|
|
class TestVideoListTransformation:
|
|
"""Tests for video list request/response transformation with provider ID encoding."""
|
|
|
|
def test_transform_video_list_response_encodes_first_id_and_last_id(self):
|
|
"""Verify that first_id and last_id are encoded with provider metadata."""
|
|
config = OpenAIVideoConfig()
|
|
|
|
mock_http_response = MagicMock()
|
|
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",
|
|
},
|
|
],
|
|
"first_id": "video_aaa",
|
|
"last_id": "video_bbb",
|
|
"has_more": False,
|
|
}
|
|
|
|
result = config.transform_video_list_response(
|
|
raw_response=mock_http_response,
|
|
logging_obj=MagicMock(),
|
|
custom_llm_provider="azure",
|
|
)
|
|
|
|
from litellm.types.videos.utils import decode_video_id_with_provider
|
|
|
|
# data[].id should be encoded
|
|
for item in result["data"]:
|
|
decoded = decode_video_id_with_provider(item["id"])
|
|
assert decoded["custom_llm_provider"] == "azure"
|
|
|
|
# first_id and last_id should also be encoded
|
|
first_decoded = decode_video_id_with_provider(result["first_id"])
|
|
assert first_decoded["custom_llm_provider"] == "azure"
|
|
assert first_decoded["video_id"] == "video_aaa"
|
|
assert first_decoded["model_id"] == "sora-2"
|
|
|
|
last_decoded = decode_video_id_with_provider(result["last_id"])
|
|
assert last_decoded["custom_llm_provider"] == "azure"
|
|
assert last_decoded["video_id"] == "video_bbb"
|
|
assert last_decoded["model_id"] == "sora-2"
|
|
|
|
def test_transform_video_list_response_no_provider_leaves_ids_unchanged(self):
|
|
"""When custom_llm_provider is None, all IDs should remain unchanged."""
|
|
config = OpenAIVideoConfig()
|
|
|
|
mock_http_response = MagicMock()
|
|
mock_http_response.json.return_value = {
|
|
"object": "list",
|
|
"data": [
|
|
{"id": "video_aaa", "object": "video", "model": "sora-2", "status": "completed"},
|
|
],
|
|
"first_id": "video_aaa",
|
|
"last_id": "video_aaa",
|
|
"has_more": False,
|
|
}
|
|
|
|
result = config.transform_video_list_response(
|
|
raw_response=mock_http_response,
|
|
logging_obj=MagicMock(),
|
|
custom_llm_provider=None,
|
|
)
|
|
|
|
assert result["data"][0]["id"] == "video_aaa"
|
|
assert result["first_id"] == "video_aaa"
|
|
assert result["last_id"] == "video_aaa"
|
|
|
|
def test_transform_video_list_response_missing_pagination_fields(self):
|
|
"""first_id / last_id may be absent or null; should not raise."""
|
|
config = OpenAIVideoConfig()
|
|
|
|
mock_http_response = MagicMock()
|
|
mock_http_response.json.return_value = {
|
|
"object": "list",
|
|
"data": [
|
|
{"id": "video_aaa", "object": "video", "model": "sora-2", "status": "completed"},
|
|
],
|
|
"has_more": False,
|
|
}
|
|
|
|
result = config.transform_video_list_response(
|
|
raw_response=mock_http_response,
|
|
logging_obj=MagicMock(),
|
|
custom_llm_provider="azure",
|
|
)
|
|
|
|
# data[].id should still be encoded
|
|
from litellm.types.videos.utils import decode_video_id_with_provider
|
|
|
|
decoded = decode_video_id_with_provider(result["data"][0]["id"])
|
|
assert decoded["custom_llm_provider"] == "azure"
|
|
|
|
# first_id / last_id should not be present
|
|
assert "first_id" not in result
|
|
assert "last_id" not in result
|
|
|
|
def test_transform_video_list_request_decodes_after_parameter(self):
|
|
"""Encoded 'after' cursor should be decoded back to the raw provider ID."""
|
|
from litellm.types.videos.utils import encode_video_id_with_provider
|
|
|
|
config = OpenAIVideoConfig()
|
|
|
|
raw_id = "video_69888baee890819086dd3366bfc372fe"
|
|
encoded_id = encode_video_id_with_provider(raw_id, "azure", "sora-2")
|
|
|
|
url, params = config.transform_video_list_request(
|
|
api_base="https://my-resource.openai.azure.com/openai/v1/videos",
|
|
litellm_params=MagicMock(),
|
|
headers={},
|
|
after=encoded_id,
|
|
limit=10,
|
|
)
|
|
|
|
assert params["after"] == raw_id
|
|
assert params["limit"] == "10"
|
|
|
|
def test_transform_video_list_request_passes_through_plain_after(self):
|
|
"""A plain (non-encoded) 'after' value should pass through unchanged."""
|
|
config = OpenAIVideoConfig()
|
|
|
|
url, params = config.transform_video_list_request(
|
|
api_base="https://api.openai.com/v1/videos",
|
|
litellm_params=MagicMock(),
|
|
headers={},
|
|
after="video_plain_id",
|
|
)
|
|
|
|
assert params["after"] == "video_plain_id"
|
|
|
|
def test_transform_video_list_roundtrip(self):
|
|
"""first_id from list response should decode correctly when used as after parameter."""
|
|
config = OpenAIVideoConfig()
|
|
|
|
# Simulate a list response
|
|
mock_http_response = MagicMock()
|
|
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"},
|
|
],
|
|
"first_id": "video_aaa",
|
|
"last_id": "video_bbb",
|
|
"has_more": True,
|
|
}
|
|
|
|
list_result = config.transform_video_list_response(
|
|
raw_response=mock_http_response,
|
|
logging_obj=MagicMock(),
|
|
custom_llm_provider="azure",
|
|
)
|
|
|
|
# Use the encoded last_id as the 'after' cursor for the next page
|
|
_, params = config.transform_video_list_request(
|
|
api_base="https://my-resource.openai.azure.com/openai/v1/videos",
|
|
litellm_params=MagicMock(),
|
|
headers={},
|
|
after=list_result["last_id"],
|
|
)
|
|
|
|
# The after param sent to the upstream API should be the raw video ID
|
|
assert params["after"] == "video_bbb"
|
|
|
|
|
|
class TestVideoEndpointsProxyLitellmParams:
|
|
"""Test that video proxy endpoints (status, content, remix) respect litellm_params from proxy config."""
|
|
|
|
@pytest.fixture
|
|
def client_with_vertex_config(self, monkeypatch):
|
|
"""Create a test client with a proxy config that includes Vertex AI model with litellm_params."""
|
|
import asyncio
|
|
import tempfile
|
|
|
|
import yaml
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from litellm.proxy.proxy_server import (
|
|
cleanup_router_config_variables,
|
|
initialize,
|
|
router,
|
|
)
|
|
from litellm.proxy.video_endpoints.endpoints import router as video_router
|
|
|
|
# Clean up any existing router config
|
|
cleanup_router_config_variables()
|
|
|
|
# Create inline config
|
|
config = {
|
|
"model_list": [
|
|
{
|
|
"model_name": "vertex-ai-sora-2",
|
|
"litellm_params": {
|
|
"model": "vertex_ai/veo-2.0-generate-001",
|
|
"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:
|
|
yaml.dump(config, f)
|
|
config_fp = f.name
|
|
|
|
try:
|
|
# Initialize the proxy with the test config
|
|
app = FastAPI()
|
|
asyncio.run(initialize(config=config_fp, debug=True))
|
|
app.include_router(router)
|
|
app.include_router(video_router)
|
|
|
|
return TestClient(app)
|
|
finally:
|
|
# Clean up temporary file
|
|
import os
|
|
if os.path.exists(config_fp):
|
|
os.unlink(config_fp)
|
|
|
|
@pytest.fixture
|
|
def mock_video_generation_response(self):
|
|
"""Mock video generation response with encoded video_id."""
|
|
from litellm.types.videos.utils import encode_video_id_with_provider
|
|
|
|
# Create an encoded video_id that includes provider and model_id
|
|
original_video_id = "projects/test-project-123/locations/global/publishers/google/models/veo-2.0-generate-001/operations/test-operation-123"
|
|
encoded_video_id = encode_video_id_with_provider(
|
|
video_id=original_video_id,
|
|
provider="vertex_ai",
|
|
model_id="veo-2.0-generate-001",
|
|
)
|
|
|
|
return VideoObject(
|
|
id=encoded_video_id,
|
|
object="video",
|
|
status="processing",
|
|
created_at=1712697600,
|
|
model="vertex_ai/veo-2.0-generate-001",
|
|
)
|
|
|
|
@pytest.fixture
|
|
def mock_video_status_response(self):
|
|
"""Mock video status response."""
|
|
return VideoObject(
|
|
id="video_test_123",
|
|
object="video",
|
|
status="completed",
|
|
created_at=1712697600,
|
|
completed_at=1712697660,
|
|
model="vertex_ai/veo-2.0-generate-001",
|
|
progress=100,
|
|
)
|
|
|
|
@pytest.fixture
|
|
def mock_video_content_response(self):
|
|
"""Mock video content response (raw bytes)."""
|
|
return b"fake_video_content_bytes"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_video_status_respects_litellm_params(
|
|
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
|
|
|
|
# Create an encoded video_id
|
|
encoded_video_id = mock_video_generation_response.id
|
|
|
|
# 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.model_names = {"vertex-ai-sora-2"}
|
|
mock_router_instance.has_model_id.return_value = False
|
|
|
|
# Mock route_request to capture the data being passed
|
|
# 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:
|
|
# Make request to video_status endpoint
|
|
response = client_with_vertex_config.get(
|
|
f"/v1/videos/{encoded_video_id}",
|
|
headers={"Authorization": "Bearer sk-1234"},
|
|
)
|
|
|
|
# Verify the endpoint was called
|
|
assert response.status_code == 200, f"Response: {response.text}"
|
|
|
|
# Verify that route_request was called
|
|
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 {})
|
|
|
|
# Verify that model was resolved and added to data
|
|
assert data_passed.get("model") == "vertex-ai-sora-2", (
|
|
f"Expected model to be 'vertex-ai-sora-2', got '{data_passed.get('model')}'. "
|
|
f"Full data: {data_passed}, call_args: {call_args}"
|
|
)
|
|
# Verify that custom_llm_provider is set from decoded video_id
|
|
assert data_passed.get("custom_llm_provider") == "vertex_ai", (
|
|
f"Expected custom_llm_provider to be 'vertex_ai', got '{data_passed.get('custom_llm_provider')}'. "
|
|
f"Full data: {data_passed}"
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_video_content_respects_litellm_params(
|
|
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
|
|
|
|
# Create an encoded video_id
|
|
encoded_video_id = mock_video_generation_response.id
|
|
|
|
# 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.model_names = {"vertex-ai-sora-2"}
|
|
mock_router_instance.has_model_id.return_value = False
|
|
|
|
# Mock route_request to capture the data being passed
|
|
# 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:
|
|
# Make request to video_content endpoint
|
|
response = client_with_vertex_config.get(
|
|
f"/v1/videos/{encoded_video_id}/content",
|
|
headers={"Authorization": "Bearer sk-1234"},
|
|
)
|
|
|
|
# Verify the endpoint was called
|
|
assert response.status_code == 200, f"Response: {response.text}"
|
|
|
|
# Verify that route_request was called
|
|
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 {})
|
|
|
|
# Verify that model was resolved and added to data
|
|
assert data_passed.get("model") == "vertex-ai-sora-2", (
|
|
f"Expected model to be 'vertex-ai-sora-2', got '{data_passed.get('model')}'. "
|
|
f"Full data: {data_passed}, call_args: {call_args}"
|
|
)
|
|
# Verify that custom_llm_provider is correctly set from decoded video_id (not "openai")
|
|
assert data_passed.get("custom_llm_provider") == "vertex_ai", (
|
|
f"Expected custom_llm_provider to be 'vertex_ai', got '{data_passed.get('custom_llm_provider')}'. "
|
|
f"Full data: {data_passed}"
|
|
)
|
|
|
|
@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
|
|
):
|
|
"""Test that video_content preserves custom_llm_provider from decoded video_id."""
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
# Create an encoded video_id
|
|
encoded_video_id = mock_video_generation_response.id
|
|
|
|
# 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.model_names = {"vertex-ai-sora-2"}
|
|
mock_router_instance.has_model_id.return_value = False
|
|
|
|
# Mock route_request to capture the data being passed
|
|
# 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:
|
|
# Make request to video_content endpoint
|
|
response = client_with_vertex_config.get(
|
|
f"/v1/videos/{encoded_video_id}/content",
|
|
headers={"Authorization": "Bearer sk-1234"},
|
|
)
|
|
|
|
# Verify the endpoint was called
|
|
assert response.status_code == 200, f"Response: {response.text}"
|
|
|
|
# Verify that route_request was called
|
|
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 {})
|
|
|
|
# Most importantly: verify that custom_llm_provider is "vertex_ai" not "openai"
|
|
# This was the bug we fixed - it was defaulting to "openai" before
|
|
assert data_passed.get("custom_llm_provider") == "vertex_ai", (
|
|
f"Expected custom_llm_provider to be 'vertex_ai', "
|
|
f"but got '{data_passed.get('custom_llm_provider')}'. "
|
|
f"Full data: {data_passed}, call_args: {call_args}"
|
|
)
|
|
|
|
|
|
def test_video_remix_handler_uses_api_key_from_litellm_params():
|
|
"""Sync remix handler should fall back to litellm_params api_key when api_key param is None."""
|
|
handler = BaseLLMHTTPHandler()
|
|
config = OpenAIVideoConfig()
|
|
|
|
with patch.object(config, "validate_environment") as mock_validate:
|
|
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"})
|
|
|
|
with patch.object(config, "transform_video_remix_response") as mock_resp:
|
|
mock_resp.return_value = MagicMock()
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.post.return_value = MagicMock(status_code=200)
|
|
|
|
with patch(
|
|
"litellm.llms.custom_httpx.llm_http_handler._get_httpx_client",
|
|
return_value=mock_client,
|
|
):
|
|
handler.video_remix_handler(
|
|
video_id="video_123",
|
|
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"},
|
|
logging_obj=MagicMock(),
|
|
timeout=5.0,
|
|
api_key=None,
|
|
_is_async=False,
|
|
)
|
|
|
|
mock_validate.assert_called_once()
|
|
assert mock_validate.call_args.kwargs["api_key"] == "deployment-key"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_video_remix_handler_uses_api_key_from_litellm_params():
|
|
"""Async remix handler should fall back to litellm_params api_key when api_key param is None."""
|
|
handler = BaseLLMHTTPHandler()
|
|
config = OpenAIVideoConfig()
|
|
|
|
with patch.object(config, "validate_environment") as mock_validate:
|
|
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"})
|
|
|
|
with patch.object(config, "transform_video_remix_response") as mock_resp:
|
|
mock_resp.return_value = MagicMock()
|
|
|
|
mock_client = MagicMock(spec=AsyncHTTPHandler)
|
|
mock_response = MagicMock(status_code=200)
|
|
mock_client.post = AsyncMock(return_value=mock_response)
|
|
|
|
with patch(
|
|
"litellm.llms.custom_httpx.llm_http_handler.get_async_httpx_client",
|
|
return_value=mock_client,
|
|
):
|
|
await handler.async_video_remix_handler(
|
|
video_id="video_123",
|
|
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"},
|
|
logging_obj=MagicMock(),
|
|
timeout=5.0,
|
|
api_key=None,
|
|
)
|
|
|
|
mock_validate.assert_called_once()
|
|
assert mock_validate.call_args.kwargs["api_key"] == "deployment-key"
|
|
|
|
|
|
def test_video_remix_handler_prefers_explicit_api_key():
|
|
"""Sync remix handler should prefer explicit api_key over litellm_params."""
|
|
handler = BaseLLMHTTPHandler()
|
|
config = OpenAIVideoConfig()
|
|
|
|
with patch.object(config, "validate_environment") as mock_validate:
|
|
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"})
|
|
|
|
with patch.object(config, "transform_video_remix_response") as mock_resp:
|
|
mock_resp.return_value = MagicMock()
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.post.return_value = MagicMock(status_code=200)
|
|
|
|
with patch(
|
|
"litellm.llms.custom_httpx.llm_http_handler._get_httpx_client",
|
|
return_value=mock_client,
|
|
):
|
|
handler.video_remix_handler(
|
|
video_id="video_123",
|
|
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"},
|
|
logging_obj=MagicMock(),
|
|
timeout=5.0,
|
|
api_key="explicit-key",
|
|
_is_async=False,
|
|
)
|
|
|
|
mock_validate.assert_called_once()
|
|
assert mock_validate.call_args.kwargs["api_key"] == "explicit-key"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__])
|
|
|
|
|
|
# ===== Tests for new video endpoints (characters, edits, extensions) =====
|
|
|
|
|
|
class TestVideoCreateCharacter:
|
|
"""Tests for video_create_character / avideo_create_character."""
|
|
|
|
def test_video_create_character_transform_request(self):
|
|
"""Verify multipart form construction for POST /videos/characters."""
|
|
config = OpenAIVideoConfig()
|
|
fake_video = b"fake_video_bytes"
|
|
|
|
url, files_list = config.transform_video_create_character_request(
|
|
name="hero",
|
|
video=fake_video,
|
|
api_base="https://api.openai.com/v1/videos",
|
|
litellm_params=MagicMock(),
|
|
headers={},
|
|
)
|
|
|
|
assert url == "https://api.openai.com/v1/videos/characters"
|
|
# Should have (name field) + (video file field) = 2 entries
|
|
assert len(files_list) == 2
|
|
field_names = [f[0] for f in files_list]
|
|
assert "name" in field_names
|
|
assert "video" in field_names
|
|
|
|
def test_video_create_character_sets_video_mimetype(self):
|
|
"""Ensure character video upload is sent as video/mp4."""
|
|
config = OpenAIVideoConfig()
|
|
fake_video = io.BytesIO(b"....ftyp....video-bytes")
|
|
fake_video.name = "character.mp4"
|
|
|
|
_, files_list = config.transform_video_create_character_request(
|
|
name="hero",
|
|
video=fake_video,
|
|
api_base="https://api.openai.com/v1/videos",
|
|
litellm_params=MagicMock(),
|
|
headers={},
|
|
)
|
|
|
|
video_parts = [f for f in files_list if f[0] == "video"]
|
|
assert len(video_parts) == 1
|
|
video_tuple = video_parts[0][1]
|
|
assert video_tuple[0] == "character.mp4"
|
|
assert video_tuple[2] == "video/mp4"
|
|
|
|
def test_video_create_character_transform_response(self):
|
|
"""Verify CharacterObject is returned from response."""
|
|
from litellm.types.videos.main import CharacterObject
|
|
|
|
config = OpenAIVideoConfig()
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"id": "char_abc123",
|
|
"object": "character",
|
|
"created_at": 1712697600,
|
|
"name": "hero",
|
|
}
|
|
|
|
result = config.transform_video_create_character_response(
|
|
raw_response=mock_response,
|
|
logging_obj=MagicMock(),
|
|
)
|
|
|
|
assert isinstance(result, CharacterObject)
|
|
assert result.id == "char_abc123"
|
|
assert result.name == "hero"
|
|
|
|
def test_video_create_character_mock_response(self):
|
|
"""video_create_character returns CharacterObject on mock_response."""
|
|
from litellm.types.videos.main import CharacterObject
|
|
from litellm.videos.main import video_create_character
|
|
|
|
response = video_create_character(
|
|
name="hero",
|
|
video=b"fake",
|
|
mock_response={
|
|
"id": "char_abc",
|
|
"object": "character",
|
|
"created_at": 1712697600,
|
|
"name": "hero",
|
|
},
|
|
)
|
|
assert isinstance(response, CharacterObject)
|
|
assert response.id == "char_abc"
|
|
|
|
|
|
class TestVideoGetCharacter:
|
|
"""Tests for video_get_character / avideo_get_character."""
|
|
|
|
def test_video_get_character_transform_request(self):
|
|
"""Verify URL construction for GET /videos/characters/{character_id}."""
|
|
config = OpenAIVideoConfig()
|
|
|
|
url, params = config.transform_video_get_character_request(
|
|
character_id="char_xyz",
|
|
api_base="https://api.openai.com/v1/videos",
|
|
litellm_params=MagicMock(),
|
|
headers={},
|
|
)
|
|
|
|
assert url == "https://api.openai.com/v1/videos/characters/char_xyz"
|
|
assert params == {}
|
|
|
|
def test_video_get_character_transform_response(self):
|
|
"""Verify CharacterObject is returned from GET response."""
|
|
from litellm.types.videos.main import CharacterObject
|
|
|
|
config = OpenAIVideoConfig()
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"id": "char_xyz",
|
|
"object": "character",
|
|
"created_at": 1712697600,
|
|
"name": "villain",
|
|
}
|
|
|
|
result = config.transform_video_get_character_response(
|
|
raw_response=mock_response,
|
|
logging_obj=MagicMock(),
|
|
)
|
|
|
|
assert isinstance(result, CharacterObject)
|
|
assert result.id == "char_xyz"
|
|
assert result.name == "villain"
|
|
|
|
def test_video_get_character_mock_response(self):
|
|
"""video_get_character returns CharacterObject on mock_response."""
|
|
from litellm.types.videos.main import CharacterObject
|
|
from litellm.videos.main import video_get_character
|
|
|
|
response = video_get_character(
|
|
character_id="char_xyz",
|
|
mock_response={
|
|
"id": "char_xyz",
|
|
"object": "character",
|
|
"created_at": 1712697600,
|
|
"name": "villain",
|
|
},
|
|
)
|
|
assert isinstance(response, CharacterObject)
|
|
assert response.id == "char_xyz"
|
|
|
|
|
|
class TestVideoEdit:
|
|
"""Tests for video_edit / avideo_edit."""
|
|
|
|
def test_video_edit_transform_request(self):
|
|
"""Verify JSON body with video.id for POST /videos/edits."""
|
|
config = OpenAIVideoConfig()
|
|
|
|
url, data = config.transform_video_edit_request(
|
|
prompt="make it brighter",
|
|
video_id="video_abc123",
|
|
api_base="https://api.openai.com/v1/videos",
|
|
litellm_params=MagicMock(),
|
|
headers={},
|
|
)
|
|
|
|
assert url == "https://api.openai.com/v1/videos/edits"
|
|
assert data["prompt"] == "make it brighter"
|
|
assert data["video"]["id"] == "video_abc123"
|
|
|
|
def test_video_edit_transform_request_with_extra_body(self):
|
|
"""Extra body params are merged into request data."""
|
|
config = OpenAIVideoConfig()
|
|
|
|
url, data = config.transform_video_edit_request(
|
|
prompt="darken it",
|
|
video_id="video_abc123",
|
|
api_base="https://api.openai.com/v1/videos",
|
|
litellm_params=MagicMock(),
|
|
headers={},
|
|
extra_body={"resolution": "1080p"},
|
|
)
|
|
|
|
assert data["resolution"] == "1080p"
|
|
|
|
def test_video_edit_mock_response(self):
|
|
"""video_edit returns VideoObject on mock_response."""
|
|
from litellm.videos.main import video_edit
|
|
|
|
response = video_edit(
|
|
video_id="video_abc123",
|
|
prompt="make it brighter",
|
|
mock_response={
|
|
"id": "video_edit_001",
|
|
"object": "video",
|
|
"status": "queued",
|
|
"created_at": 1712697600,
|
|
},
|
|
)
|
|
assert isinstance(response, VideoObject)
|
|
assert response.id == "video_edit_001"
|
|
|
|
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)
|
|
url, data = config.transform_video_edit_request(
|
|
prompt="test",
|
|
video_id=encoded_id,
|
|
api_base="https://api.openai.com/v1/videos",
|
|
litellm_params=MagicMock(),
|
|
headers={},
|
|
)
|
|
|
|
# The video.id in the request body should be the raw ID, not the encoded one
|
|
assert data["video"]["id"] == "raw_video_id"
|
|
|
|
|
|
class TestVideoExtension:
|
|
"""Tests for video_extension / avideo_extension."""
|
|
|
|
def test_video_extension_transform_request(self):
|
|
"""Verify JSON body with video.id + seconds for POST /videos/extensions."""
|
|
config = OpenAIVideoConfig()
|
|
|
|
url, data = config.transform_video_extension_request(
|
|
prompt="continue the scene",
|
|
video_id="video_abc123",
|
|
seconds="5",
|
|
api_base="https://api.openai.com/v1/videos",
|
|
litellm_params=MagicMock(),
|
|
headers={},
|
|
)
|
|
|
|
assert url == "https://api.openai.com/v1/videos/extensions"
|
|
assert data["prompt"] == "continue the scene"
|
|
assert data["seconds"] == "5"
|
|
assert data["video"]["id"] == "video_abc123"
|
|
|
|
def test_video_extension_transform_request_with_extra_body(self):
|
|
"""Extra body params are merged into request data."""
|
|
config = OpenAIVideoConfig()
|
|
|
|
url, data = config.transform_video_extension_request(
|
|
prompt="extend",
|
|
video_id="video_abc123",
|
|
seconds="10",
|
|
api_base="https://api.openai.com/v1/videos",
|
|
litellm_params=MagicMock(),
|
|
headers={},
|
|
extra_body={"model": "sora-2"},
|
|
)
|
|
|
|
assert data["model"] == "sora-2"
|
|
|
|
def test_video_extension_mock_response(self):
|
|
"""video_extension returns VideoObject on mock_response."""
|
|
from litellm.videos.main import video_extension
|
|
|
|
response = video_extension(
|
|
video_id="video_abc123",
|
|
prompt="continue the scene",
|
|
seconds="5",
|
|
mock_response={
|
|
"id": "video_ext_001",
|
|
"object": "video",
|
|
"status": "queued",
|
|
"created_at": 1712697600,
|
|
},
|
|
)
|
|
assert isinstance(response, VideoObject)
|
|
assert response.id == "video_ext_001"
|
|
|
|
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)
|
|
url, data = config.transform_video_extension_request(
|
|
prompt="extend",
|
|
video_id=encoded_id,
|
|
seconds="5",
|
|
api_base="https://api.openai.com/v1/videos",
|
|
litellm_params=MagicMock(),
|
|
headers={},
|
|
)
|
|
|
|
assert data["video"]["id"] == "raw_video_id"
|
|
|
|
|
|
@pytest.fixture
|
|
def video_proxy_test_client():
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
from litellm.proxy.video_endpoints.endpoints import router as video_router
|
|
|
|
app = FastAPI()
|
|
app.include_router(video_router)
|
|
app.dependency_overrides[user_api_key_auth] = lambda: MagicMock()
|
|
return TestClient(app)
|
|
|
|
|
|
def test_character_id_encode_decode_roundtrip():
|
|
from litellm.types.videos.utils import (
|
|
decode_character_id_with_provider,
|
|
encode_character_id_with_provider,
|
|
)
|
|
|
|
encoded = encode_character_id_with_provider(
|
|
character_id="char_raw_123",
|
|
provider="vertex_ai",
|
|
model_id="veo-2.0-generate-001",
|
|
)
|
|
decoded = decode_character_id_with_provider(encoded)
|
|
|
|
assert decoded["character_id"] == "char_raw_123"
|
|
assert decoded["custom_llm_provider"] == "vertex_ai"
|
|
assert decoded["model_id"] == "veo-2.0-generate-001"
|
|
|
|
|
|
def test_character_id_decode_handles_missing_base64_padding():
|
|
from litellm.types.videos.utils import (
|
|
decode_character_id_with_provider,
|
|
encode_character_id_with_provider,
|
|
)
|
|
|
|
encoded = encode_character_id_with_provider(
|
|
character_id="id",
|
|
provider="openai",
|
|
model_id="gpt-4o",
|
|
)
|
|
encoded_without_padding = encoded.rstrip("=")
|
|
decoded = decode_character_id_with_provider(encoded_without_padding)
|
|
|
|
assert decoded["character_id"] == "id"
|
|
assert decoded["custom_llm_provider"] == "openai"
|
|
assert decoded["model_id"] == "gpt-4o"
|
|
|
|
|
|
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
|
|
|
|
captured_data = {}
|
|
|
|
async def _mock_base_process(self, **kwargs):
|
|
captured_data.update(self.data)
|
|
return {
|
|
"id": "char_upstream_123",
|
|
"object": "character",
|
|
"created_at": 1712697600,
|
|
"name": "hero",
|
|
}
|
|
|
|
with patch.object(
|
|
ProxyBaseLLMRequestProcessing,
|
|
"base_process_llm_request",
|
|
new=_mock_base_process,
|
|
):
|
|
response = video_proxy_test_client.post(
|
|
"/v1/videos/characters",
|
|
headers={"Authorization": "Bearer sk-1234"},
|
|
files={"video": ("character.mp4", b"fake-video", "video/mp4")},
|
|
data={
|
|
"name": "hero",
|
|
"target_model_names": "vertex-ai-sora-2",
|
|
"extra_body": json.dumps({"custom_llm_provider": "vertex_ai"}),
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
response_json = response.json()
|
|
decoded = decode_character_id_with_provider(response_json["id"])
|
|
assert decoded["character_id"] == "char_upstream_123"
|
|
assert decoded["custom_llm_provider"] == "vertex_ai"
|
|
assert decoded["model_id"] == "vertex-ai-sora-2"
|
|
assert captured_data["model"] == "vertex-ai-sora-2"
|
|
assert captured_data["custom_llm_provider"] == "vertex_ai"
|
|
|
|
|
|
def test_video_get_character_accepts_encoded_character_id(video_proxy_test_client):
|
|
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
|
|
from litellm.types.videos.utils import (
|
|
decode_character_id_with_provider,
|
|
encode_character_id_with_provider,
|
|
)
|
|
|
|
captured_data = {}
|
|
|
|
async def _mock_base_process(self, **kwargs):
|
|
captured_data.update(self.data)
|
|
return {
|
|
"id": "char_upstream_123",
|
|
"object": "character",
|
|
"created_at": 1712697600,
|
|
"name": "hero",
|
|
}
|
|
|
|
encoded_character_id = encode_character_id_with_provider(
|
|
character_id="char_upstream_123",
|
|
provider="vertex_ai",
|
|
model_id="veo-2.0-generate-001",
|
|
)
|
|
mock_router = MagicMock()
|
|
mock_router.resolve_model_name_from_model_id.return_value = "vertex-ai-sora-2"
|
|
|
|
with patch("litellm.proxy.proxy_server.llm_router", mock_router):
|
|
with patch.object(
|
|
ProxyBaseLLMRequestProcessing,
|
|
"base_process_llm_request",
|
|
new=_mock_base_process,
|
|
):
|
|
response = video_proxy_test_client.get(
|
|
f"/v1/videos/characters/{encoded_character_id}",
|
|
headers={"Authorization": "Bearer sk-1234"},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
assert captured_data["character_id"] == "char_upstream_123"
|
|
assert captured_data["custom_llm_provider"] == "vertex_ai"
|
|
assert captured_data["model"] == "vertex-ai-sora-2"
|
|
response_decoded = decode_character_id_with_provider(response.json()["id"])
|
|
assert response_decoded["character_id"] == "char_upstream_123"
|
|
assert response_decoded["custom_llm_provider"] == "vertex_ai"
|
|
assert response_decoded["model_id"] == "veo-2.0-generate-001"
|
|
|
|
|
|
@pytest.mark.parametrize("endpoint", ["/v1/videos/edits", "/v1/videos/extensions"])
|
|
def test_edit_and_extension_support_custom_provider_from_extra_body(
|
|
video_proxy_test_client, endpoint
|
|
):
|
|
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
|
|
|
|
captured_data = {}
|
|
|
|
async def _mock_base_process(self, **kwargs):
|
|
captured_data.update(self.data)
|
|
return {
|
|
"id": "video_resp_123",
|
|
"object": "video",
|
|
"status": "queued",
|
|
"created_at": 1712697600,
|
|
}
|
|
|
|
payload = {
|
|
"prompt": "test",
|
|
"video": {"id": "video_raw_123"},
|
|
"extra_body": {"custom_llm_provider": "vertex_ai"},
|
|
}
|
|
if endpoint.endswith("extensions"):
|
|
payload["seconds"] = "4"
|
|
|
|
with patch.object(
|
|
ProxyBaseLLMRequestProcessing,
|
|
"base_process_llm_request",
|
|
new=_mock_base_process,
|
|
):
|
|
response = video_proxy_test_client.post(
|
|
endpoint,
|
|
headers={"Authorization": "Bearer sk-1234"},
|
|
json=payload,
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
assert captured_data["custom_llm_provider"] == "vertex_ai"
|
|
|
|
|
|
@pytest.mark.parametrize("endpoint", ["/v1/videos/edits", "/v1/videos/extensions"])
|
|
def test_edit_and_extension_route_with_encoded_video_ids(
|
|
video_proxy_test_client, endpoint
|
|
):
|
|
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
|
|
from litellm.types.videos.utils import encode_video_id_with_provider
|
|
|
|
captured_data = {}
|
|
|
|
async def _mock_base_process(self, **kwargs):
|
|
captured_data.update(self.data)
|
|
return {
|
|
"id": "video_resp_123",
|
|
"object": "video",
|
|
"status": "queued",
|
|
"created_at": 1712697600,
|
|
}
|
|
|
|
encoded_video_id = encode_video_id_with_provider(
|
|
video_id="video_raw_123",
|
|
provider="vertex_ai",
|
|
model_id="veo-2.0-generate-001",
|
|
)
|
|
payload = {"prompt": "test", "video": {"id": encoded_video_id}}
|
|
if endpoint.endswith("extensions"):
|
|
payload["seconds"] = "4"
|
|
|
|
mock_router = MagicMock()
|
|
mock_router.resolve_model_name_from_model_id.return_value = "vertex-ai-sora-2"
|
|
|
|
with patch("litellm.proxy.proxy_server.llm_router", mock_router):
|
|
with patch.object(
|
|
ProxyBaseLLMRequestProcessing,
|
|
"base_process_llm_request",
|
|
new=_mock_base_process,
|
|
):
|
|
response = video_proxy_test_client.post(
|
|
endpoint,
|
|
headers={"Authorization": "Bearer sk-1234"},
|
|
json=payload,
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
assert captured_data["video_id"] == encoded_video_id
|
|
assert captured_data["custom_llm_provider"] == "vertex_ai"
|
|
assert captured_data["model"] == "vertex-ai-sora-2"
|