Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 34 additions & 11 deletions litellm/llms/openai/videos/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,45 +269,68 @@ def transform_video_list_request(
) -> Tuple[str, Dict]:
"""
Transform the video list request for OpenAI API.

OpenAI API expects the following request:
- GET /v1/videos
"""
# Use the api_base directly for video list
url = api_base

# Prepare query parameters
params = {}
if after is not None:
params["after"] = after
# Decode the wrapped video ID back to the original provider ID
params["after"] = extract_original_video_id(after)
if limit is not None:
params["limit"] = str(limit)
if order is not None:
params["order"] = order

# Add any extra query parameters
if extra_query:
params.update(extra_query)

return url, params

def transform_video_list_response(
self,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
custom_llm_provider: Optional[str] = None,
) -> Dict[str,str]:
) -> Dict[str, str]:
response_data = raw_response.json()

if custom_llm_provider and "data" in response_data:
for video_obj in response_data.get("data", []):
if isinstance(video_obj, dict) and "id" in video_obj:
video_obj["id"] = encode_video_id_with_provider(
video_obj["id"],
custom_llm_provider,
video_obj.get("model")
video_obj["id"],
custom_llm_provider,
video_obj.get("model"),
)


# Encode pagination cursor IDs so they remain consistent
# with the wrapped data[].id format
data_list = response_data.get("data", [])
if response_data.get("first_id"):
first_model = None
if data_list and isinstance(data_list[0], dict):
first_model = data_list[0].get("model")
response_data["first_id"] = encode_video_id_with_provider(
response_data["first_id"],
custom_llm_provider,
first_model,
)
if response_data.get("last_id"):
last_model = None
if data_list and isinstance(data_list[-1], dict):
last_model = data_list[-1].get("model")
response_data["last_id"] = encode_video_id_with_provider(
response_data["last_id"],
custom_llm_provider,
last_model,
)

return response_data

def transform_video_delete_request(
Expand Down
175 changes: 175 additions & 0 deletions tests/test_litellm/test_video_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -916,6 +916,181 @@ def test_encode_video_id_with_provider_handles_azure_video_prefix():
)
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."""

Expand Down
Loading