diff --git a/litellm/__init__.py b/litellm/__init__.py index d4418c661a3..d5f3d5cafbf 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -540,6 +540,7 @@ xai_models: Set = set() zai_models: Set = set() deepseek_models: Set = set() runwayml_models: Set = set() +ltx_models: Set = set() azure_ai_models: Set = set() jina_ai_models: Set = set() voyage_models: Set = set() @@ -751,6 +752,8 @@ def add_known_models(model_cost_map: Optional[Dict] = None): deepseek_models.add(key) elif value.get("litellm_provider") == "runwayml": runwayml_models.add(key) + elif value.get("litellm_provider") == "ltx": + ltx_models.add(key) elif value.get("litellm_provider") == "meta_llama": llama_models.add(key) elif value.get("litellm_provider") == "nscale": @@ -921,6 +924,7 @@ model_list = list( | perplexity_models | set(maritalk_models) | runwayml_models + | ltx_models | vertex_language_models | watsonx_models | gemini_models @@ -1022,6 +1026,7 @@ models_by_provider: dict = { "fal_ai": fal_ai_models, "deepseek": deepseek_models, "runwayml": runwayml_models, + "ltx": ltx_models, "mistral": mistral_chat_models, "azure_ai": azure_ai_models, "voyage": voyage_models, diff --git a/litellm/llms/ltx/videos/transformation.py b/litellm/llms/ltx/videos/transformation.py index 14dc72ce6b9..38a7547ebad 100644 --- a/litellm/llms/ltx/videos/transformation.py +++ b/litellm/llms/ltx/videos/transformation.py @@ -27,13 +27,28 @@ else: LTX_VIDEO_STORAGE_DIR = Path(tempfile.gettempdir()) / "litellm_ltx_videos" +LTX_VIDEO_STORAGE_MAX_AGE_SECONDS = 24 * 60 * 60 def _get_ltx_video_storage_path(video_id: str) -> Path: return LTX_VIDEO_STORAGE_DIR / f"{video_id}.mp4" +def _cleanup_old_ltx_videos() -> None: + if not LTX_VIDEO_STORAGE_DIR.exists(): + return + + cutoff = time.time() - LTX_VIDEO_STORAGE_MAX_AGE_SECONDS + for video_path in LTX_VIDEO_STORAGE_DIR.glob("*.mp4"): + try: + if video_path.stat().st_mtime < cutoff: + video_path.unlink(missing_ok=True) + except OSError: + pass + + def _persist_ltx_video_bytes(video_id: str, video_bytes: bytes) -> Path: + _cleanup_old_ltx_videos() LTX_VIDEO_STORAGE_DIR.mkdir(parents=True, exist_ok=True) video_path = _get_ltx_video_storage_path(video_id) video_path.write_bytes(video_bytes) @@ -65,7 +80,6 @@ class LTXVideoConfig(BaseVideoConfig): "input_reference", "seconds", "size", - "user", "extra_headers", ] @@ -98,10 +112,19 @@ class LTXVideoConfig(BaseVideoConfig): # Ignore invalid seconds values and let the provider fall back to defaults. pass + if "user" in video_create_optional_params: + if drop_params: + pass + else: + raise ValueError( + f"Parameter user is not supported for model {model}. " + "Set drop_params=True to drop unsupported parameters." + ) + # Pass through LTX-specific parameters supported_openai_params = self.get_supported_openai_params(model) for key, value in video_create_optional_params.items(): - if key not in supported_openai_params: + if key != "user" and key not in supported_openai_params: mapped_params[key] = value return mapped_params @@ -266,7 +289,10 @@ class LTXVideoConfig(BaseVideoConfig): status_code=404, message=( "No locally stored LTX video content was found for this video_id. " - "Recreate the video before calling video_content()." + "LTX stores generated video content in the local temp directory of " + "the process that created it, so retrieval will fail from a different " + "instance or after a process restart. Recreate the video before " + "calling video_content()." ), ) diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index deb7a14df43..f4c450d427b 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -33671,28 +33671,6 @@ } }, "ltx/ltx-2-3-fast": { - "litellm_provider": "ltx", - "mode": "video_generation", - "output_cost_per_video_per_second": 0.04, - "source": "https://docs.ltx.video/pricing", - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "video" - ], - "supported_resolutions": [ - "1280x720", - "1920x1080", - "2560x1440", - "3840x2160" - ], - "metadata": { - "comment": "$0.04/sec at 1080p, $0.08/sec at 1440p, $0.16/sec at 4K. Using 1080p as base cost." - } - }, - "ltx/ltx-2-3-pro": { "litellm_provider": "ltx", "mode": "video_generation", "output_cost_per_video_per_second": 0.06, @@ -33707,13 +33685,41 @@ "supported_resolutions": [ "1280x720", "1920x1080", + "1080x1920", "2560x1440", - "3840x2160" + "1440x2560", + "3840x2160", + "2160x3840" ], "metadata": { "comment": "$0.06/sec at 1080p, $0.12/sec at 1440p, $0.24/sec at 4K. Using 1080p as base cost." } }, + "ltx/ltx-2-3-pro": { + "litellm_provider": "ltx", + "mode": "video_generation", + "output_cost_per_video_per_second": 0.08, + "source": "https://docs.ltx.video/pricing", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "video" + ], + "supported_resolutions": [ + "1280x720", + "1920x1080", + "1080x1920", + "2560x1440", + "1440x2560", + "3840x2160", + "2160x3840" + ], + "metadata": { + "comment": "$0.08/sec at 1080p, $0.16/sec at 1440p, $0.32/sec at 4K. Using 1080p as base cost." + } + }, "fireworks_ai/accounts/fireworks/models/qwen3-coder-480b-a35b-instruct": { "max_tokens": 262144, "max_input_tokens": 262144, diff --git a/tests/test_litellm/llms/ltx/videos/test_ltx_video_transformation.py b/tests/test_litellm/llms/ltx/videos/test_ltx_video_transformation.py index 12f9d611eef..8909f1d54ae 100644 --- a/tests/test_litellm/llms/ltx/videos/test_ltx_video_transformation.py +++ b/tests/test_litellm/llms/ltx/videos/test_ltx_video_transformation.py @@ -3,11 +3,13 @@ Tests for LTX Video generation transformation. """ import asyncio +import os from unittest.mock import Mock import httpx import pytest +import litellm import litellm.llms.ltx.videos.transformation as ltx_video_transformation from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler @@ -37,7 +39,7 @@ class TestLTXVideoTransformation: assert "input_reference" in params assert "seconds" in params assert "size" in params - assert "user" in params + assert "user" not in params assert "extra_headers" in params def test_map_openai_params_basic(self): @@ -81,6 +83,25 @@ class TestLTXVideoTransformation: ) assert mapped["duration"] == 10 + def test_map_openai_params_user_raises_when_not_dropping(self): + """Test unsupported OpenAI user param fails loudly for LTX.""" + with pytest.raises(ValueError, match="Parameter user is not supported"): + self.config.map_openai_params( + video_create_optional_params={"user": "end-user-123"}, + model="ltx-2-3-fast", + drop_params=False, + ) + + def test_map_openai_params_user_is_dropped_when_requested(self): + """Test unsupported user param can be dropped explicitly.""" + mapped = self.config.map_openai_params( + video_create_optional_params={"user": "end-user-123"}, + model="ltx-2-3-fast", + drop_params=True, + ) + + assert mapped == {} + def test_validate_environment(self): """Test authentication header setup.""" headers = self.config.validate_environment( @@ -254,6 +275,37 @@ class TestLTXVideoTransformation: assert result.status == "completed" assert result.id # should have a UUID + def test_transform_video_create_response_cleans_up_expired_files( + self, monkeypatch, tmp_path + ): + """Test stale locally persisted LTX videos are pruned on write.""" + monkeypatch.setattr(ltx_video_transformation, "LTX_VIDEO_STORAGE_DIR", tmp_path) + monkeypatch.setattr( + ltx_video_transformation, "LTX_VIDEO_STORAGE_MAX_AGE_SECONDS", 1 + ) + + stale_video_path = tmp_path / "stale-video.mp4" + stale_video_path.parent.mkdir(parents=True, exist_ok=True) + stale_video_path.write_bytes(b"old-bytes") + old_timestamp = ltx_video_transformation.time.time() - 10 + os.utime(stale_video_path, (old_timestamp, old_timestamp)) + + mock_response = Mock(spec=httpx.Response) + mock_response.content = b"new-video-bytes" + mock_response.status_code = 200 + mock_response.request = httpx.Request("POST", "https://api.ltx.video/v1") + + result = self.config.transform_video_create_response( + model="ltx-2-3-fast", + raw_response=mock_response, + logging_obj=self.mock_logging_obj, + custom_llm_provider=None, + request_data={"model": "ltx-2-3-fast"}, + ) + + assert stale_video_path.exists() is False + assert (tmp_path / f"{result.id}.mp4").read_bytes() == b"new-video-bytes" + def test_transform_video_create_response_empty_binary_raises(self): """Test that empty create responses fail loudly.""" mock_response = Mock(spec=httpx.Response) @@ -361,6 +413,16 @@ class TestLTXVideoTransformation: headers={}, ) + with pytest.raises( + BaseLLMException, match="different instance or after a process restart" + ): + self.config.transform_video_content_request( + video_id="missing-video", + api_base="", + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + with pytest.raises(NotImplementedError): self.config.transform_video_status_retrieve_request( video_id="test", @@ -492,6 +554,12 @@ class TestLTXVideoTransformation: assert video_obj.status == "completed" assert video_obj.size == "1280x720" + def test_ltx_models_are_registered_globally(self): + """Test LTX models are exposed through LiteLLM's global model registries.""" + assert hasattr(litellm, "ltx_models") + assert "ltx" in litellm.models_by_provider + assert litellm.models_by_provider["ltx"] is litellm.ltx_models + if __name__ == "__main__": pytest.main([__file__, "-v"])