diff --git a/litellm/llms/xai/videos/transformation.py b/litellm/llms/xai/videos/transformation.py index 79150975835..77ad2c043b4 100644 --- a/litellm/llms/xai/videos/transformation.py +++ b/litellm/llms/xai/videos/transformation.py @@ -1,3 +1,4 @@ +import math import time from collections.abc import Mapping from typing import TYPE_CHECKING, Final @@ -52,6 +53,28 @@ def _duration_from_seconds(seconds: object) -> int: return 6 +def _positive_seconds(value: object) -> float | None: + if isinstance(value, bool) or not isinstance(value, (int, float, str)): + return None + try: + seconds: Final = float(value) + except ValueError: + return None + return seconds if math.isfinite(seconds) and seconds > 0 else None + + +def _requested_video_usage(request_data: Mapping[str, object]) -> dict[str, object]: # mutable-ok: VideoObject.usage + resolution: Final = request_data.get("resolution") or request_data.get("resolution_name") + return { + key: value + for key, value in ( + ("duration_seconds", _positive_seconds(request_data.get("duration"))), + ("video_resolution", resolution if isinstance(resolution, str) and resolution else None), + ) + if value is not None + } + + _STATUS_MAP: Final = { "done": "completed", "completed": "completed", @@ -206,6 +229,7 @@ class XAIVideoConfig(BaseVideoConfig): "image", "images", "duration", + "resolution", "resolution_name", "aspect_ratio", "size", @@ -238,7 +262,9 @@ class XAIVideoConfig(BaseVideoConfig): if not request_id: raise ValueError(f"xAI video generation response missing request_id: {response_data}") - usage: Final = response_data.get("usage") or {} + provider_usage: Final[object] = response_data.get("usage") + requested_usage: Final = _requested_video_usage(request_data or {}) + duration_seconds: Final = requested_usage.get("duration_seconds") video_obj: Final = VideoObject( id=str(request_id), object="video", @@ -246,11 +272,11 @@ class XAIVideoConfig(BaseVideoConfig): created_at=int(time.time()), model=XAIModelInfo.get_base_model(model) or model, progress=0, + seconds=f"{duration_seconds:g}" if isinstance(duration_seconds, float) else None, ) if custom_llm_provider: video_obj.id = encode_video_id_with_provider(video_obj.id, custom_llm_provider, model) - usage_body: Final = usage if isinstance(usage, dict) else None - video_obj.usage = usage_body or {} + video_obj.usage = {**requested_usage, **(provider_usage if _is_json_object(provider_usage) else {})} return video_obj def _video_resource_url(self, api_base: str, video_id: str) -> str: @@ -301,7 +327,7 @@ class XAIVideoConfig(BaseVideoConfig): seconds=seconds, usage=response_data.get("usage") if isinstance(response_data.get("usage"), dict) else {}, ) - video_obj._hidden_params["video_url"] = video_url # pyright: ignore[reportPrivateUsage] # no public setter + video_obj.hidden_params["video_url"] = video_url if custom_llm_provider and video_obj.id and video_obj.id != "unknown": video_obj.id = encode_video_id_with_provider(video_obj.id, custom_llm_provider, response_data.get("model")) return video_obj diff --git a/tests/unit/llms/xai/batches/test_xai_batches_handler.py b/tests/unit/llms/xai/batches/test_xai_batches_handler.py index 2e42df4528f..a931627c71f 100644 --- a/tests/unit/llms/xai/batches/test_xai_batches_handler.py +++ b/tests/unit/llms/xai/batches/test_xai_batches_handler.py @@ -11,7 +11,7 @@ from litellm.llms.xai.batches.transformation import XAIBatchesError from litellm.types.utils import LiteLLMBatch API_BASE: Final = "https://api.x.ai" -KEY: Final = "xai-test-key" +KEY: Final = "fake-key" @pytest.fixture(autouse=True) diff --git a/tests/unit/llms/xai/files/test_xai_files_transformation.py b/tests/unit/llms/xai/files/test_xai_files_transformation.py index 5a7d86bdfb7..fd5e722a557 100644 --- a/tests/unit/llms/xai/files/test_xai_files_transformation.py +++ b/tests/unit/llms/xai/files/test_xai_files_transformation.py @@ -10,7 +10,7 @@ from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.types.llms.openai import OpenAIFileObject API_BASE: Final = "https://api.x.ai" -KEY: Final = "xai-test-key" +KEY: Final = "fake-key" @pytest.fixture(autouse=True) diff --git a/tests/unit/llms/xai/test_xai_chat_transformation.py b/tests/unit/llms/xai/test_xai_chat_transformation.py index 69e620dbc8a..082228db0b4 100644 --- a/tests/unit/llms/xai/test_xai_chat_transformation.py +++ b/tests/unit/llms/xai/test_xai_chat_transformation.py @@ -199,7 +199,7 @@ def test_completion_strips_message_names_from_request(respx_mock: respx.MockRout {"role": "assistant", "content": "OK", "name": "responder"}, {"role": "user", "content": "Again", "name": "caller"}, ], - api_key="xai-test-key", + api_key="fake-key", api_base=XAI_API_BASE, ) @@ -274,7 +274,7 @@ def test_streaming_include_usage_surfaces_xai_final_usage_chunk( messages=[{"role": "user", "content": "Say OK"}], stream=True, stream_options={"include_usage": True}, - api_key="xai-test-key", + api_key="fake-key", api_base=XAI_API_BASE, ) ) diff --git a/tests/unit/llms/xai/test_xai_common_utils.py b/tests/unit/llms/xai/test_xai_common_utils.py index 42a61062229..83ab51edef9 100644 --- a/tests/unit/llms/xai/test_xai_common_utils.py +++ b/tests/unit/llms/xai/test_xai_common_utils.py @@ -20,7 +20,7 @@ def _default_xai_api_base(monkeypatch: pytest.MonkeyPatch): def _get_models(payload: object) -> list[str]: with respx.mock: respx.get(MODELS_URL).mock(return_value=httpx.Response(200, json=payload)) - return XAIModelInfo().get_models(api_key="xai-test-key") + return XAIModelInfo().get_models(api_key="fake-key") def test_get_models_prefixes_every_listed_model_id_in_provider_order(): @@ -47,9 +47,9 @@ def test_get_models_returns_no_names_for_an_empty_data_container(data: object): def test_get_models_sends_the_api_key_as_bearer_token(): with respx.mock: route = respx.get(MODELS_URL).mock(return_value=httpx.Response(200, json={"data": [{"id": "grok-4"}]})) - XAIModelInfo().get_models(api_key="xai-test-key") + XAIModelInfo().get_models(api_key="fake-key") - assert route.calls[0].request.headers["authorization"] == "Bearer xai-test-key" + assert route.calls[0].request.headers["authorization"] == "Bearer fake-key" @pytest.mark.parametrize( @@ -101,7 +101,7 @@ def test_get_models_reports_the_upstream_status_and_body_on_an_http_error(): with pytest.raises( Exception, match=re.escape("Failed to fetch models from XAI. Status code: 401, Response: bad key") ): - XAIModelInfo().get_models(api_key="xai-test-key") + XAIModelInfo().get_models(api_key="fake-key") @pytest.mark.parametrize( diff --git a/tests/unit/llms/xai/test_xai_image_edit.py b/tests/unit/llms/xai/test_xai_image_edit.py index 3e996b94f4b..e2da75c5d4f 100644 --- a/tests/unit/llms/xai/test_xai_image_edit.py +++ b/tests/unit/llms/xai/test_xai_image_edit.py @@ -1,4 +1,5 @@ import base64 +from io import BytesIO from unittest.mock import MagicMock, patch import httpx @@ -184,3 +185,135 @@ def test_transform_multiple_images_uses_images_array(): {"url": "https://imgen.x.ai/a.jpeg"}, {"url": "https://imgen.x.ai/b.jpeg"}, ] + + +def _edit_request(image, optional_params=None): + from litellm.llms.xai.image_edit.transformation import XAIImageEditConfig + + data, _ = XAIImageEditConfig().transform_image_edit_request( + model="grok-imagine-image", + prompt="make it night", + image=image, + image_edit_optional_request_params=optional_params or {}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + return data + + +def test_validate_environment_api_key_sets_bearer_and_keeps_caller_content_type(): + from litellm.llms.xai.image_edit.transformation import XAIImageEditConfig + + headers = XAIImageEditConfig().validate_environment( + headers={"Content-Type": "application/custom"}, + model="grok-imagine-image", + api_key="fake-key", + litellm_params={}, + ) + + assert headers == {"Content-Type": "application/custom", "Authorization": "Bearer fake-key"} + + +def test_map_rejects_unknown_param_unless_dropped(): + from litellm.llms.xai.image_edit.transformation import XAIImageEditConfig + + config = XAIImageEditConfig() + with pytest.raises(ValueError, match="Parameter quality is not supported"): + config.map_openai_params( + image_edit_optional_params={"quality": "hd", "n": 2}, + model="grok-imagine-image", + drop_params=False, + ) + + assert config.map_openai_params( + image_edit_optional_params={"quality": "hd", "n": 2, "resolution": "2k"}, + model="grok-imagine-image", + drop_params=True, + ) == {"n": 2, "resolution": "2k"} + + +def test_get_complete_url_appends_v1_to_a_bare_custom_base(): + from litellm.llms.xai.image_edit.transformation import XAIImageEditConfig + + url = XAIImageEditConfig().get_complete_url( + model="grok-imagine-image", + api_base="https://xai-gateway.example.com/", + litellm_params={"api_key": "fake-key"}, + ) + + assert url == "https://xai-gateway.example.com/v1/images/edits" + + +@pytest.mark.parametrize("image", [None, [], [None]]) +def test_transform_rejects_missing_reference_image(image): + with pytest.raises(ValueError, match="requires at least one reference image"): + _edit_request(image) + + +@pytest.mark.parametrize( + "image,expected", + [ + ({"url": "https://imgen.x.ai/a.jpeg"}, {"url": "https://imgen.x.ai/a.jpeg"}), + ({"file_id": "file-123"}, {"file_id": "file-123"}), + ], +) +def test_transform_passes_url_and_file_id_references_through(image, expected): + assert _edit_request(image)["image"] == expected + + +@pytest.mark.parametrize( + "image", + [ + bytearray(b"\x89PNG\r\n\x1a\nfakepng"), + BytesIO(b"\x89PNG\r\n\x1a\nfakepng"), + ], +) +def test_transform_encodes_binary_inputs_as_data_uri(image): + expected = "data:image/png;base64," + base64.b64encode(b"\x89PNG\r\n\x1a\nfakepng").decode() + + assert _edit_request(image)["image"]["url"] == expected + + +def test_transform_reads_a_partially_consumed_stream_from_the_start_and_restores_position(): + stream = BytesIO(b"\x89PNG\r\n\x1a\nfakepng") + stream.seek(4) + + url = _edit_request(stream)["image"]["url"] + + assert url.endswith(base64.b64encode(b"\x89PNG\r\n\x1a\nfakepng").decode()) + assert stream.tell() == 4 + + +def test_transform_encodes_text_reader_as_utf8(): + class TextReader: + def read(self) -> str: + return "plain-text-bytes" + + url = _edit_request(TextReader())["image"]["url"] + + assert url.endswith(base64.b64encode(b"plain-text-bytes").decode()) + + +def test_transform_rejects_unsupported_image_type(): + with pytest.raises(ValueError, match="Unsupported image input type"): + _edit_request(12345) + + +@pytest.mark.parametrize( + "raw", + [ + httpx.Response(502, text="bad gateway"), + httpx.Response(200, json={"data": []}), + ], +) +def test_transform_response_raises_provider_error_without_images(raw): + from litellm.llms.xai.image_edit.transformation import XAIImageEditConfig + + with pytest.raises(Exception) as exc_info: + XAIImageEditConfig().transform_image_edit_response( + model="grok-imagine-image", + raw_response=raw, + logging_obj=MagicMock(), + ) + + assert getattr(exc_info.value, "status_code", None) == raw.status_code diff --git a/tests/unit/llms/xai/test_xai_image_generation.py b/tests/unit/llms/xai/test_xai_image_generation.py index a617388f34c..e061f47fb8f 100644 --- a/tests/unit/llms/xai/test_xai_image_generation.py +++ b/tests/unit/llms/xai/test_xai_image_generation.py @@ -127,3 +127,69 @@ def test_transform_request_and_response(): ) assert response.data is not None assert response.data[0].url == "https://imgen.x.ai/example.jpeg" + + +def test_validate_environment_api_key_sets_bearer_and_json_content_type(): + headers = XAIImageGenerationConfig().validate_environment( + headers={}, + model="grok-imagine-image", + messages=[], + optional_params={}, + litellm_params={}, + api_key="fake-key", + ) + + assert headers == {"Authorization": "Bearer fake-key", "Content-Type": "application/json"} + + +def test_map_rejects_unknown_param_unless_dropped(): + config = XAIImageGenerationConfig() + with pytest.raises(ValueError, match="Parameter quality is not supported"): + config.map_openai_params( + non_default_params={"quality": "hd", "n": 2}, + optional_params={}, + model="grok-imagine-image", + drop_params=False, + ) + + assert config.map_openai_params( + non_default_params={"quality": "hd", "n": 2}, + optional_params={}, + model="grok-imagine-image", + drop_params=True, + ) == {"n": 2} + + +def test_get_complete_url_appends_v1_to_a_bare_custom_base(): + url = XAIImageGenerationConfig().get_complete_url( + api_base="https://xai-gateway.example.com/", + api_key="fake-key", + model="grok-imagine-image", + optional_params={}, + litellm_params={}, + ) + + assert url == "https://xai-gateway.example.com/v1/images/generations" + + +@pytest.mark.parametrize( + "raw", + [ + httpx.Response(502, text="bad gateway"), + httpx.Response(200, json={"data": ["junk"]}), + ], +) +def test_transform_response_raises_provider_error_without_images(raw): + with pytest.raises(Exception) as exc_info: + XAIImageGenerationConfig().transform_image_generation_response( + model="grok-imagine-image", + raw_response=raw, + model_response=ImageResponse(), + logging_obj=MagicMock(), + request_data={"prompt": "a red apple"}, + optional_params={}, + litellm_params={}, + encoding=None, + ) + + assert getattr(exc_info.value, "status_code", None) == raw.status_code diff --git a/tests/unit/llms/xai/test_xai_video_generation.py b/tests/unit/llms/xai/test_xai_video_generation.py index 1f9d8b0fedd..d4321a416ea 100644 --- a/tests/unit/llms/xai/test_xai_video_generation.py +++ b/tests/unit/llms/xai/test_xai_video_generation.py @@ -40,6 +40,46 @@ def test_map_seconds_nine_is_not_clamped_to_six(): assert "seconds" not in mapped +def test_map_non_numeric_seconds_falls_back_to_the_default_duration(): + config = XAIVideoConfig() + defaulted = config.map_openai_params( + video_create_optional_params={"seconds": "ten"}, + model="grok-imagine-video-1.5", + drop_params=True, + ) + request_body, _, _ = config.transform_video_create_request( + model="grok-imagine-video-1.5", + prompt="a cat walking", + api_base="https://api.x.ai/v1/videos/generations", + video_create_optional_request_params={}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert defaulted["duration"] == request_body["duration"] + + +def test_get_complete_url_appends_v1_to_a_bare_custom_base(): + url = XAIVideoConfig().get_complete_url( + model="grok-imagine-video", + api_base="https://xai-gateway.example.com/", + litellm_params={"api_key": "fake-key"}, + ) + + assert url == "https://xai-gateway.example.com/v1/videos/generations" + + +def test_create_response_without_request_id_raises(): + with pytest.raises(ValueError, match="missing request_id"): + XAIVideoConfig().transform_video_create_response( + model="grok-imagine-video", + raw_response=httpx.Response(200, json={"error": "queued nowhere"}), + logging_obj=MagicMock(), + custom_llm_provider="xai", + request_data={"duration": 6}, + ) + + def test_get_complete_url_create_and_status_root(): config = XAIVideoConfig() create_url = config.get_complete_url( @@ -120,7 +160,109 @@ def test_transform_create_and_status_response(): ) assert status.status == "completed" assert status.seconds == "6" - assert status._hidden_params.get("video_url") == "https://vidgen.x.ai/x.mp4" + assert status.hidden_params.get("video_url") == "https://vidgen.x.ai/x.mp4" + + +def _created_video_cost(optional_params: dict[str, object], model: str = "grok-imagine-video-1.5") -> tuple[float, str]: + config = XAIVideoConfig() + request_body, _, _ = config.transform_video_create_request( + model=model, + prompt="a cat walking", + api_base="https://api.x.ai/v1/videos/generations", + video_create_optional_request_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + created = config.transform_video_create_response( + model=model, + raw_response=httpx.Response(200, json={"request_id": "req-123"}), + logging_obj=MagicMock(), + custom_llm_provider="xai", + request_data=request_body, + ) + cost = litellm.completion_cost( + completion_response=created, + model=f"xai/{model}", + custom_llm_provider="xai", + call_type="create_video", + ) + return cost, str(created.seconds) + + +@pytest.mark.parametrize( + "optional_params,expected_seconds,rate_key", + [ + ({"duration": 10}, 10, "output_cost_per_second"), + ({"duration": "4", "resolution": "720p"}, 4, "output_cost_per_second_720p"), + ], +) +def test_create_response_bills_the_submitted_duration_and_resolution(optional_params, expected_seconds, rate_key): + model_cost = litellm.model_cost["xai/grok-imagine-video-1.5"] + assert model_cost["output_cost_per_second_720p"] != model_cost["output_cost_per_second"] + + cost, seconds = _created_video_cost(optional_params) + + assert seconds == str(expected_seconds) + assert cost > 0 + assert cost == pytest.approx(expected_seconds * model_cost[rate_key]) + + +def test_create_response_bills_the_default_duration_sent_to_xai(): + config = XAIVideoConfig() + request_body, _, _ = config.transform_video_create_request( + model="grok-imagine-video-1.5", + prompt="a cat walking", + api_base="https://api.x.ai/v1/videos/generations", + video_create_optional_request_params={}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + cost, seconds = _created_video_cost({}) + + assert seconds == str(request_body["duration"]) + assert cost == pytest.approx( + request_body["duration"] * litellm.model_cost["xai/grok-imagine-video-1.5"]["output_cost_per_second"] + ) + + +def test_create_request_forwards_resolution_to_xai(): + request_body, _, _ = XAIVideoConfig().transform_video_create_request( + model="grok-imagine-video-1.5", + prompt="a cat walking", + api_base="https://api.x.ai/v1/videos/generations", + video_create_optional_request_params={"resolution": "1080p"}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert request_body["resolution"] == "1080p" + + +def test_create_response_keeps_provider_reported_usage_over_the_request(): + created = XAIVideoConfig().transform_video_create_response( + model="grok-imagine-video-1.5", + raw_response=httpx.Response(200, json={"request_id": "req-123", "usage": {"duration_seconds": 3.0}}), + logging_obj=MagicMock(), + custom_llm_provider="xai", + request_data={"duration": 10, "resolution": "720p"}, + ) + + assert created.usage == {"duration_seconds": 3.0, "video_resolution": "720p"} + + +@pytest.mark.parametrize("duration", [None, 0, -5, True, "abc", "nan"]) +def test_create_response_omits_unusable_durations_from_usage(duration): + created = XAIVideoConfig().transform_video_create_response( + model="grok-imagine-video-1.5", + raw_response=httpx.Response(200, json={"request_id": "req-123"}), + logging_obj=MagicMock(), + custom_llm_provider="xai", + request_data={"duration": duration}, + ) + + assert created.usage == {} + assert created.seconds is None def test_sync_video_status_through_the_http_handler(): @@ -131,9 +273,7 @@ def test_sync_video_status_through_the_http_handler(): request=httpx.Request("GET", "https://api.x.ai/v1/videos/req-123"), ) - status = litellm.video_status( - video_id="req-123", model="xai/grok-imagine-video", api_key="test-key", client=client - ) + status = litellm.video_status(video_id="req-123", model="xai/grok-imagine-video", api_key="test-key", client=client) assert status.status == "completed" assert client.get.call_args.kwargs["url"] == "https://api.x.ai/v1/videos/req-123" diff --git a/tests/unit/proxy/video_endpoints/test_ownership.py b/tests/unit/proxy/video_endpoints/test_ownership.py index 89bbb07a1f3..80aa9077b7b 100644 --- a/tests/unit/proxy/video_endpoints/test_ownership.py +++ b/tests/unit/proxy/video_endpoints/test_ownership.py @@ -19,6 +19,7 @@ from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth, hash_token from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing from litellm.proxy.video_endpoints import endpoints +from litellm.types.videos.main import VideoObject from litellm.types.videos.utils import encode_video_id_with_provider ALICE = UserAPIKeyAuth(user_id="alice", api_key=hash_token("sk-alice")) @@ -70,11 +71,13 @@ class Provider: def __init__(self): self.calls: list[str] = [] + self.returns_video_objects = False async def respond(self, processor, *, route_type: str, **kwargs): self.calls.append(route_type) if route_type in ("avideo_generation", "avideo_remix", "avideo_edit", "avideo_extension"): - return {"id": f"video_{uuid.uuid4().hex}", "object": "video", "status": "queued"} + created = {"id": f"video_{uuid.uuid4().hex}", "object": "video", "status": "queued"} + return VideoObject(**created) if self.returns_video_objects else created if route_type == "avideo_content": return b"mp4-bytes" if route_type == "avideo_list": @@ -132,11 +135,22 @@ ALL_OK = {"status": 200, "content": 200, "remix": 200, "edit": 200, "extension": ALL_FORBIDDEN = {"status": 403, "content": 403, "remix": 403, "edit": 403, "extension": 403} -def test_owner_can_use_every_video_route(provider, table): +@pytest.mark.parametrize("returns_video_objects", [False, True]) +def test_owner_can_use_every_video_route(provider, table, returns_video_objects): + provider.returns_video_objects = returns_video_objects video_id = _create_video(ALICE) assert table.rows[f"video:{video_id}"].created_by == "alice" assert _access(ALICE, video_id) == ALL_OK + assert _access(BOB, video_id) == ALL_FORBIDDEN + + +def test_caller_without_identity_scope_leaves_the_video_admin_only(provider, table): + video_id = _create_video(UserAPIKeyAuth()) + + assert table.rows == {} + assert _access(ALICE, video_id) == ALL_FORBIDDEN + assert _access(ADMIN, video_id) == ALL_OK def test_other_user_is_forbidden_and_the_provider_is_never_called(provider, table):