diff --git a/litellm/llms/base_llm/videos/transformation.py b/litellm/llms/base_llm/videos/transformation.py index f725b295d0f..a765d493347 100644 --- a/litellm/llms/base_llm/videos/transformation.py +++ b/litellm/llms/base_llm/videos/transformation.py @@ -272,6 +272,19 @@ class BaseVideoConfig(ABC): ) -> VideoObject: pass + async def async_transform_video_status_retrieve_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + custom_llm_provider: str | None = None, + ) -> VideoObject: + """Async transform video status retrieve response.""" + return self.transform_video_status_retrieve_response( + raw_response=raw_response, + logging_obj=logging_obj, + custom_llm_provider=custom_llm_provider, + ) + def transform_video_create_character_request( self, name: str, diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 49a332e62bb..7f1ff5298ba 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -8881,7 +8881,7 @@ class BaseLLMHTTPHandler: url=url, headers=headers, ) - return video_status_provider_config.transform_video_status_retrieve_response( + return await video_status_provider_config.async_transform_video_status_retrieve_response( raw_response=response, logging_obj=logging_obj, custom_llm_provider=custom_llm_provider, diff --git a/litellm/llms/fal_ai/videos/transformation.py b/litellm/llms/fal_ai/videos/transformation.py index 8a355b3d226..51082a6773b 100644 --- a/litellm/llms/fal_ai/videos/transformation.py +++ b/litellm/llms/fal_ai/videos/transformation.py @@ -1,7 +1,7 @@ import math import sys import time -from collections.abc import Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass from types import MappingProxyType from typing import Final, TypeAlias @@ -163,12 +163,127 @@ def _response_data(raw_response: httpx.Response) -> Mapping[str, object]: return TypeAdapter(Mapping[str, object]).validate_python(raw_response.json()) +def _response_data_or_none(raw_response: httpx.Response) -> Mapping[str, object] | None: + try: + return _response_data(raw_response) + except ValueError: + return None + + +def _detail_item_text(item: Mapping[str, object]) -> str | None: + message: Final[object] = item.get("msg") + if not isinstance(message, str): + return None + location: Final[object] = item.get("loc") + if isinstance(location, str) and location: + return f"{location}: {message}" + if isinstance(location, (list, tuple)): + location_parts: Final[tuple[str, ...]] = tuple(part for part in location if isinstance(part, str)) + if location_parts: + return f"{'.'.join(location_parts)}: {message}" + return message + + +def _error_text(response_data: Mapping[str, object]) -> str | None: + detail: Final[object] = response_data.get("detail") + if isinstance(detail, str): + return detail + if isinstance(detail, list): + detail_items: Final[tuple[Mapping[str, object], ...]] = tuple( + item for item in detail if isinstance(item, Mapping) + ) + detail_messages: Final[tuple[str, ...]] = tuple( + message for item in detail_items if (message := _detail_item_text(item)) is not None + ) + if detail_messages: + return "; ".join(detail_messages) + error: Final[object] = response_data.get("error") + return error if isinstance(error, str) else None + + +def _result_error(raw_response: httpx.Response) -> str | None: + if raw_response.is_success: + return None + response_data: Final[Mapping[str, object] | None] = _response_data_or_none(raw_response) + error_text: Final[str | None] = _error_text(response_data) if response_data is not None else None + if error_text: + return error_text + response_text: Final[str] = raw_response.text + return response_text or f"fal.ai returned HTTP {raw_response.status_code}" + + +def _terminal_result_error(raw_response: httpx.Response) -> str | None: + if raw_response.status_code == 429 or raw_response.status_code >= 500: + return None + return _result_error(raw_response) + + +def _get_fal_ai_async_httpx_client() -> AsyncHTTPHandler: + return get_async_httpx_client(llm_provider=LlmProviders.FAL_AI) + + def _response_string(response_data: Mapping[str, object], key: str, default: str = "") -> str: value: Final[object] = response_data.get(key) return value if isinstance(value, str) else default +def _result_request( + raw_response: httpx.Response, + response_data: Mapping[str, object], +) -> tuple[str, Mapping[str, str]] | None: + if _response_string(response_data, "status", "IN_QUEUE") != "COMPLETED": + return None + result_url: Final[str] = str(raw_response.request.url).removesuffix("/status") + result_headers: Final[Mapping[str, str]] = MappingProxyType( + { + key: value + for key, value in ( + ("Authorization", raw_response.request.headers.get("Authorization")), + ("Content-Type", raw_response.request.headers.get("Content-Type")), + ) + if value is not None + } + ) + return result_url, result_headers + + +def _status_video_object( + response_data: Mapping[str, object], + raw_response: httpx.Response, + custom_llm_provider: str | None, + result_error: str | None, +) -> VideoObject: + raw_status: Final[str] = _response_string(response_data, "status", "IN_QUEUE") + status: Final[str] = _STATUS_MAP.get(raw_status, "queued") + status_error: Final[str | None] = _error_text(response_data) + error: Final[str | None] = result_error if result_error is not None else status_error + provider: Final[str] = custom_llm_provider or _FAL_AI_PROVIDER + model_path: Final[str | None] = _model_path_from_request_url(raw_response) + request_id: Final[str] = _response_string(response_data, "request_id") or ( + _request_id_from_request_url(raw_response) or "" + ) + return VideoObject( + id=encode_video_id_with_provider(request_id, provider, model_path), + object="video", + status="failed" if error else status, + created_at=0, + model=model_path, + error=( + {"code": "fal_error", "message": error} if error else None # mutable-ok: VideoObject requires a dict + ), + ) + + class FalAIVideoConfig(BaseVideoConfig): + def __init__( + self, + sync_client_factory: Callable[[], HTTPHandler] = _get_httpx_client, + async_client_factory: Callable[[], AsyncHTTPHandler] = _get_fal_ai_async_httpx_client, + ) -> None: + super().__init__() + self._sync_client_factory: Final = sync_client_factory + self._async_client_factory: Final = async_client_factory + def get_supported_openai_params(self, model: str) -> _SupportedParams: supported_params: Final[_SupportedParams] = [ # mutable-ok: BaseVideoConfig requires a list "model", @@ -345,25 +460,58 @@ class FalAIVideoConfig(BaseVideoConfig): custom_llm_provider: str | None = None, ) -> VideoObject: response_data: Final[Mapping[str, object]] = _response_data(raw_response) - raw_status: Final[str] = _response_string(response_data, "status", "IN_QUEUE") - status: Final[str] = _STATUS_MAP.get(raw_status, "queued") - error_value: Final[object] = response_data.get("error") - error: Final[str | None] = error_value if isinstance(error_value, str) else None - provider: Final[str] = custom_llm_provider or _FAL_AI_PROVIDER - model_path: Final[str | None] = _model_path_from_request_url(raw_response) - request_id: Final[str] = _response_string(response_data, "request_id") or ( - _request_id_from_request_url(raw_response) or "" + result_error: Final[str | None] = self._fetch_result_error(raw_response, response_data) + return _status_video_object( + response_data=response_data, + raw_response=raw_response, + custom_llm_provider=custom_llm_provider, + result_error=result_error, ) - return VideoObject( - id=encode_video_id_with_provider(request_id, provider, model_path), - object="video", - status="failed" if error else status, - created_at=0, - model=model_path, - error=( - {"code": "fal_error", "message": error} if error else None # mutable-ok: VideoObject requires a dict - ), + + def _fetch_result_error( + self, + raw_response: httpx.Response, + response_data: Mapping[str, object], + ) -> str | None: + result_request: Final[tuple[str, Mapping[str, str]] | None] = _result_request(raw_response, response_data) + if result_request is None: + return None + result_url, result_headers = result_request + result_response: Final[httpx.Response] = self._sync_client_factory().get( + url=result_url, + headers=result_headers, ) + return _terminal_result_error(result_response) + + async def async_transform_video_status_retrieve_response( + self, + raw_response: httpx.Response, + logging_obj: object, + custom_llm_provider: str | None = None, + ) -> VideoObject: + response_data: Final[Mapping[str, object]] = _response_data(raw_response) + result_error: Final[str | None] = await self._fetch_result_error_async(raw_response, response_data) + return _status_video_object( + response_data=response_data, + raw_response=raw_response, + custom_llm_provider=custom_llm_provider, + result_error=result_error, + ) + + async def _fetch_result_error_async( + self, + raw_response: httpx.Response, + response_data: Mapping[str, object], + ) -> str | None: + result_request: Final[tuple[str, Mapping[str, str]] | None] = _result_request(raw_response, response_data) + if result_request is None: + return None + result_url, result_headers = result_request + result_response: Final[httpx.Response] = await self._async_client_factory().get( + url=result_url, + headers=result_headers, + ) + return _terminal_result_error(result_response) @staticmethod def _decode_video_id(video_id: str) -> tuple[str, str]: @@ -401,17 +549,23 @@ class FalAIVideoConfig(BaseVideoConfig): video_url: Final[object] = video_data.get("url") if isinstance(video_url, str) and video_url: return video_url - error_message: Final[str | None] = next( - (value for key in ("error", "detail") if isinstance(value := response_data.get(key), str)), - None, - ) + error_message: Final[str | None] = _error_text(response_data) if error_message: raise ValueError(f"fal.ai video result did not include a video URL: {error_message}") raise ValueError("fal.ai video result did not include a video URL") def transform_video_content_response(self, raw_response: httpx.Response, logging_obj: object) -> bytes: + error: Final[str | None] = _result_error(raw_response) + if error is not None: + raise FalAIVideoError( + status_code=raw_response.status_code, + message=error, + headers=dict(raw_response.headers), # mutable-ok: exception headers require a mutable dictionary + request=raw_response.request, + response=raw_response, + ) video_url: Final[str] = self._extract_video_url(_response_data(raw_response)) - httpx_client: Final[HTTPHandler] = _get_httpx_client() + httpx_client: Final[HTTPHandler] = self._sync_client_factory() video_response: Final[httpx.Response] = httpx_client.get( # pyright: ignore[reportUnknownMemberType] # HTTP handler stubs are untyped video_url ) @@ -419,8 +573,17 @@ class FalAIVideoConfig(BaseVideoConfig): return video_response.content async def async_transform_video_content_response(self, raw_response: httpx.Response, logging_obj: object) -> bytes: + error: Final[str | None] = _result_error(raw_response) + if error is not None: + raise FalAIVideoError( + status_code=raw_response.status_code, + message=error, + headers=dict(raw_response.headers), # mutable-ok: exception headers require a mutable dictionary + request=raw_response.request, + response=raw_response, + ) video_url: Final[str] = self._extract_video_url(_response_data(raw_response)) - async_httpx_client: Final[AsyncHTTPHandler] = get_async_httpx_client(llm_provider=LlmProviders.FAL_AI) + async_httpx_client: Final[AsyncHTTPHandler] = self._async_client_factory() video_response: Final[httpx.Response] = await async_httpx_client.get( # pyright: ignore[reportUnknownMemberType] # HTTP handler stubs are untyped video_url ) diff --git a/tests/integration/contracts.json b/tests/integration/contracts.json index dc6b218feb4..9e9d57b3b3e 100644 --- a/tests/integration/contracts.json +++ b/tests/integration/contracts.json @@ -166,6 +166,9 @@ "tests/integration/providers/test_fal_ai_video_wire.py::test_fal_video_create_status_and_content_follow_queue_wire_contract": [ "other.provider_wire.fal_ai.video_queue_create_status_and_content_download" ], + "tests/integration/providers/test_fal_ai_video_wire.py::test_fal_video_failed_result_reports_failed_status_and_fal_error": [ + "other.provider_wire.fal_ai.video_failed_result_surfaces_fal_error" + ], "tests/integration/providers/test_fal_ai_image_wire.py::test_fal_gpt_image_25_generation_sends_quality_and_size_and_charges_keyed_row": [ "other.provider_wire.fal_ai.gpt_image_generation_quality_size_wire_and_keyed_pricing" ], diff --git a/tests/integration/providers/test_fal_ai_video_wire.py b/tests/integration/providers/test_fal_ai_video_wire.py index ceb53c77c83..827818c6780 100644 --- a/tests/integration/providers/test_fal_ai_video_wire.py +++ b/tests/integration/providers/test_fal_ai_video_wire.py @@ -66,6 +66,7 @@ def test_fal_video_create_status_and_content_follow_queue_wire_contract(gateway: ("POST", f"/{_MODEL}"), ("GET", f"/bytedance/seedance-2.5/requests/{request_id}/status"), ("GET", f"/bytedance/seedance-2.5/requests/{request_id}"), + ("GET", f"/bytedance/seedance-2.5/requests/{request_id}"), ("GET", f"/files/{request_id}.mp4"), ] @@ -119,5 +120,57 @@ def test_fal_h3_video_create_uses_canonical_body_and_status_path(gateway: Gatewa ("POST", f"/{_H3_MODEL}"), ("GET", f"/minimax/h3/requests/{request_id}/status"), ("GET", f"/minimax/h3/requests/{request_id}"), + ("GET", f"/minimax/h3/requests/{request_id}"), ("GET", f"/files/{request_id}.mp4"), ] + + +@pytest.mark.covers("other.provider_wire.fal_ai.video_failed_result_surfaces_fal_error") +def test_fal_video_failed_result_reports_failed_status_and_fal_error(gateway: Gateway) -> None: + request_id: Final = "fal-failed-req-" + uuid.uuid4().hex + error_body: Final = { + "detail": [ + { + "loc": ["body", "input.reference_image_urls"], + "msg": "Failed to download the file. Please check if the URL is accessible and try again.", + "type": "file_download_error", + } + ] + } + + def respond(request: Request) -> Reply: + assert request.headers["authorization"] == "Key synthetic-fal-key" + if request.method == "POST": + assert request.target == f"/{_MODEL}" + return Reply( + body=json.dumps({"status": "IN_QUEUE", "request_id": request_id, "queue_position": 0}).encode() + ) + assert request.method == "GET" + if request.target == f"/bytedance/seedance-2.5/requests/{request_id}/status": + return Reply(body=json.dumps({"status": "COMPLETED", "request_id": request_id}).encode()) + assert request.target == f"/bytedance/seedance-2.5/requests/{request_id}" + return Reply(status=422, body=json.dumps(error_body).encode()) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model=f"fal_ai/{_MODEL}", + api_base=wire.url, + api_key="synthetic-fal-key", + ) + created: Final = gateway.post( + "/v1/videos", + { + "model": model, + "prompt": "a cat playing volleyball on a beach", + "seconds": "4", + "size": "1280x720", + }, + ) + assert created["status"] == "queued" + video_id: Final = created["id"] + status: Final = gateway.get(f"/v1/videos/{video_id}") + assert status["status"] == "failed" + assert "input.reference_image_urls: Failed to download the file" in status["error"]["message"] + content: Final = gateway.request("GET", f"/v1/videos/{video_id}/content") + assert content.status_code == 422, content.text + assert "Failed to download the file" in content.text diff --git a/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py b/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py index 963ed7eac47..86ecbf6701b 100644 --- a/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py +++ b/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py @@ -1,4 +1,5 @@ -from unittest.mock import Mock +from typing import Final +from unittest.mock import AsyncMock, Mock import httpx import pytest @@ -218,8 +219,18 @@ class TestFalAIVideoTransformation: def test_status_response_mapping(self, response_data, expected_status): status_url = "https://queue.fal.run/bytedance/seedance-2.5/requests/abc/status" response = httpx.Response(200, json=response_data, request=httpx.Request("GET", status_url)) + config = self.config + if expected_status == "completed": + result_response: Final = httpx.Response( + 200, + json={"video": {"url": "https://cdn.example.com/video.mp4"}}, + request=httpx.Request("GET", status_url.removesuffix("/status")), + ) + client: Final = Mock() + client.get.return_value = result_response + config = FalAIVideoConfig(sync_client_factory=lambda: client) - video = self.config.transform_video_status_retrieve_response( + video = config.transform_video_status_retrieve_response( raw_response=response, logging_obj=self.logging_obj, custom_llm_provider="fal_ai", @@ -245,16 +256,22 @@ class TestFalAIVideoTransformation: "status": "COMPLETED", "error": "generation failed", } + status_url = "https://queue.fal.run/bytedance/seedance-2.5/requests/abc/status" response = httpx.Response( 200, json=response_data, - request=httpx.Request( - "GET", - "https://queue.fal.run/bytedance/seedance-2.5/requests/abc/status", - ), + request=httpx.Request("GET", status_url), ) + result_response: Final = httpx.Response( + 200, + json={"video": {"url": "https://cdn.example.com/video.mp4"}}, + request=httpx.Request("GET", status_url.removesuffix("/status")), + ) + client: Final = Mock() + client.get.return_value = result_response + config = FalAIVideoConfig(sync_client_factory=lambda: client) - video = self.config.transform_video_status_retrieve_response( + video = config.transform_video_status_retrieve_response( raw_response=response, logging_obj=self.logging_obj, custom_llm_provider="fal_ai", @@ -263,8 +280,125 @@ class TestFalAIVideoTransformation: assert video.status == "failed" assert video.error == {"code": "fal_error", "message": "generation failed"} - def test_status_response_uses_namespaced_request_url(self): + def test_status_completed_result_error_surfaces_fal_message(self): + status_url = "https://queue.fal.run/minimax/h3/requests/abc/status" + auth_headers: Final = {"Authorization": "Key synthetic-fal-key", "Content-Type": "application/json"} + response: Final = httpx.Response( + 200, + json={"request_id": "abc", "status": "COMPLETED"}, + request=httpx.Request("GET", status_url, headers=auth_headers), + ) + result_url: Final = status_url.removesuffix("/status") + result_response: Final = httpx.Response( + 422, + json={ + "detail": [ + { + "loc": ["body", "input.reference_image_urls"], + "msg": "Failed to download the file. Please check if the URL is accessible and try again.", + } + ] + }, + request=httpx.Request("GET", result_url, headers=auth_headers), + ) + client: Final = Mock() + client.get.return_value = result_response + config = FalAIVideoConfig(sync_client_factory=lambda: client) + + video = config.transform_video_status_retrieve_response( + raw_response=response, + logging_obj=self.logging_obj, + custom_llm_provider="fal_ai", + ) + + assert video.status == "failed" + assert "input.reference_image_urls: Failed to download the file" in video.error["message"] + client.get.assert_called_once_with(url=result_url, headers=auth_headers) + + @pytest.mark.parametrize("status_code", [429, 503]) + def test_status_completed_transient_result_error_keeps_completed(self, status_code): + status_url = "https://queue.fal.run/minimax/h3/requests/abc/status" + response: Final = httpx.Response( + 200, + json={"request_id": "abc", "status": "COMPLETED"}, + request=httpx.Request("GET", status_url), + ) + result_response: Final = httpx.Response( + status_code, + json={"detail": "temporary fal failure"}, + request=httpx.Request("GET", status_url.removesuffix("/status")), + ) + client: Final = Mock() + client.get.return_value = result_response + config = FalAIVideoConfig(sync_client_factory=lambda: client) + + video = config.transform_video_status_retrieve_response( + raw_response=response, + logging_obj=self.logging_obj, + custom_llm_provider="fal_ai", + ) + + assert video.status == "completed" + assert video.error is None + + @pytest.mark.asyncio + async def test_async_status_completed_result_error_surfaces_fal_message(self): + status_url = "https://queue.fal.run/minimax/h3/requests/abc/status" + auth_headers: Final = {"Authorization": "Key synthetic-fal-key", "Content-Type": "application/json"} + response: Final = httpx.Response( + 200, + json={"request_id": "abc", "status": "COMPLETED"}, + request=httpx.Request("GET", status_url, headers=auth_headers), + ) + result_url: Final = status_url.removesuffix("/status") + result_response: Final = httpx.Response( + 422, + json={ + "detail": [ + { + "loc": ["body", "input.reference_image_urls"], + "msg": "Failed to download the file. Please check if the URL is accessible and try again.", + } + ] + }, + request=httpx.Request("GET", result_url, headers=auth_headers), + ) + client: Final = Mock() + client.get = AsyncMock(return_value=result_response) + config = FalAIVideoConfig(async_client_factory=lambda: client) + + video = await config.async_transform_video_status_retrieve_response( + raw_response=response, + logging_obj=self.logging_obj, + custom_llm_provider="fal_ai", + ) + + assert video.status == "failed" + assert "input.reference_image_urls: Failed to download the file" in video.error["message"] + client.get.assert_awaited_once_with(url=result_url, headers=auth_headers) + + def test_status_in_progress_does_not_fetch_result(self): + status_url = "https://queue.fal.run/minimax/h3/requests/abc/status" response = httpx.Response( + 200, + json={"request_id": "abc", "status": "IN_PROGRESS"}, + request=httpx.Request("GET", status_url), + ) + + client: Final = Mock() + config = FalAIVideoConfig(sync_client_factory=lambda: client) + + video = config.transform_video_status_retrieve_response( + raw_response=response, + logging_obj=self.logging_obj, + custom_llm_provider="fal_ai", + ) + + assert video.status == "in_progress" + client.get.assert_not_called() + + def test_status_response_uses_namespaced_request_url(self): + response: Final = httpx.Response( 200, json={"status": "IN_PROGRESS"}, request=httpx.Request( @@ -284,7 +418,7 @@ class TestFalAIVideoTransformation: assert decoded["video_id"] == "xyz" assert video.model == "workflows/owner/app" - def test_content_response_downloads_video_url(self, monkeypatch): + def test_content_response_downloads_video_url(self): content_response = httpx.Response( 200, content=b"video-bytes", @@ -296,11 +430,11 @@ class TestFalAIVideoTransformation: assert url == "https://cdn.example.com/video.mp4" return content_response - monkeypatch.setattr(fal_video_module, "_get_httpx_client", lambda: FakeHTTPClient()) + config = FalAIVideoConfig(sync_client_factory=FakeHTTPClient) response = Mock(spec=httpx.Response) response.json.return_value = {"video": {"url": "https://cdn.example.com/video.mp4"}} - assert self.config.transform_video_content_response(response, self.logging_obj) == b"video-bytes" + assert config.transform_video_content_response(response, self.logging_obj) == b"video-bytes" def test_content_response_rejects_missing_video(self): response = Mock(spec=httpx.Response) @@ -309,6 +443,87 @@ class TestFalAIVideoTransformation: with pytest.raises(ValueError, match="generation failed"): self.config.transform_video_content_response(response, self.logging_obj) + def test_content_response_surfaces_list_detail_error(self): + response: Final = httpx.Response( + 422, + json={ + "detail": [ + { + "loc": ["body", "input.reference_image_urls"], + "msg": "Failed to download the file. Please check if the URL is accessible and try again.", + } + ] + }, + request=httpx.Request("GET", "https://queue.fal.run/minimax/h3/requests/abc"), + ) + + with pytest.raises(FalAIVideoError) as error: + self.config.transform_video_content_response(response, self.logging_obj) + + assert error.value.status_code == 422 + assert "input.reference_image_urls: Failed to download the file" in error.value.message + assert "Failed to download the file" in error.value.response.text + + def test_content_response_surfaces_string_detail_error(self): + response: Final = httpx.Response( + 400, + json={"detail": "Request is still in progress"}, + request=httpx.Request("GET", "https://queue.fal.run/minimax/h3/requests/abc"), + ) + + with pytest.raises(FalAIVideoError) as error: + self.config.transform_video_content_response(response, self.logging_obj) + + assert error.value.status_code == 400 + assert error.value.message == "Request is still in progress" + assert "Request is still in progress" in error.value.response.text + + @pytest.mark.asyncio + async def test_async_content_response_surfaces_list_detail_error(self): + response: Final = httpx.Response( + 422, + json={ + "detail": [ + { + "loc": ["body", "input.reference_image_urls"], + "msg": "Failed to download the file. Please check if the URL is accessible and try again.", + } + ] + }, + request=httpx.Request("GET", "https://queue.fal.run/minimax/h3/requests/abc"), + ) + + with pytest.raises(FalAIVideoError) as error: + await self.config.async_transform_video_content_response(response, self.logging_obj) + + assert error.value.status_code == 422 + assert "input.reference_image_urls: Failed to download the file" in error.value.message + assert "Failed to download the file" in error.value.response.text + + @pytest.mark.asyncio + async def test_async_content_response_surfaces_string_detail_error(self): + response = httpx.Response( + 400, + json={"detail": "Request is still in progress"}, + request=httpx.Request("GET", "https://queue.fal.run/minimax/h3/requests/abc"), + ) + + with pytest.raises(FalAIVideoError) as error: + await self.config.async_transform_video_content_response(response, self.logging_obj) + + assert error.value.status_code == 400 + assert error.value.message == "Request is still in progress" + assert "Request is still in progress" in error.value.response.text + + def test_extract_video_url_surfaces_list_detail_error(self): + response: Final = Mock(spec=httpx.Response) + response.json.return_value = { + "detail": [{"loc": ["body", "input.reference_image_urls"], "msg": "Failed to download the file"}] + } + + with pytest.raises(ValueError, match=r"input\.reference_image_urls: Failed to download the file"): + self.config.transform_video_content_response(response, self.logging_obj) + def test_provider_config_and_error_class(self): provider_config = ProviderConfigManager.get_provider_video_config( model=MODEL,