mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-20 16:19:55 +00:00
Fix video list pagination cursors not encoded with provider metadata
first_id and last_id in the video list response were returned as raw provider IDs while data[].id was properly wrapped with encode_video_id_with_provider(). This caused pagination to break when clients passed unencoded cursors back as the `after` parameter. - Encode first_id/last_id in transform_video_list_response - Decode the `after` param in transform_video_list_request via extract_original_video_id() - Add 6 unit tests covering encoding, decoding, passthrough, and full round-trip pagination Fixes #20708 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
4f96a3b126
commit
381c3756f4
@@ -269,26 +269,27 @@ class OpenAIVideoConfig(BaseVideoConfig):
|
||||
) -> 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(
|
||||
@@ -296,18 +297,40 @@ class OpenAIVideoConfig(BaseVideoConfig):
|
||||
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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user