From a909a7908ed56f92b6c1d22283e7be0f86d1a36e Mon Sep 17 00:00:00 2001 From: kerry Date: Mon, 21 Sep 2026 20:01:58 +0000 Subject: [PATCH 1/4] fix(fal_ai): surface fal errors in video status and content Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../llms/base_llm/videos/transformation.py | 13 ++ litellm/llms/custom_httpx/llm_http_handler.py | 2 +- litellm/llms/fal_ai/videos/transformation.py | 183 ++++++++++++++-- tests/integration/contracts.json | 3 + .../providers/test_fal_ai_video_wire.py | 53 +++++ .../test_fal_ai_video_transformation.py | 200 +++++++++++++++++- 6 files changed, 423 insertions(+), 31 deletions(-) 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..130e5bda70a 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 Mapping, Sequence from dataclasses import dataclass from types import MappingProxyType from typing import Final, TypeAlias @@ -163,11 +163,87 @@ 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[Sequence[Mapping[str, object]]] = TypeAdapter( + Sequence[Mapping[str, object]] + ).validate_python(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 _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 _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 get_supported_openai_params(self, model: str) -> _SupportedParams: supported_params: Final[_SupportedParams] = [ # mutable-ok: BaseVideoConfig requires a list @@ -345,25 +421,77 @@ 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: + 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 + } ) + result_response: Final[httpx.Response] = _get_httpx_client().get( + url=result_url, + headers=result_headers, + ) + return _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: + 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 + } + ) + async_httpx_client: Final[AsyncHTTPHandler] = get_async_httpx_client(llm_provider=LlmProviders.FAL_AI) + result_response: Final[httpx.Response] = await async_httpx_client.get( + url=result_url, + headers=result_headers, + ) + return _result_error(result_response) @staticmethod def _decode_video_id(video_id: str) -> tuple[str, str]: @@ -401,15 +529,19 @@ 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 + ) video_url: Final[str] = self._extract_video_url(_response_data(raw_response)) httpx_client: Final[HTTPHandler] = _get_httpx_client() video_response: Final[httpx.Response] = httpx_client.get( # pyright: ignore[reportUnknownMemberType] # HTTP handler stubs are untyped @@ -419,6 +551,13 @@ 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 + ) 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) video_response: Final[httpx.Response] = await async_httpx_client.get( # pyright: ignore[reportUnknownMemberType] # HTTP handler stubs are untyped diff --git a/tests/integration/contracts.json b/tests/integration/contracts.json index 365456c0cec..96fa63a633f 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..570ff883eec 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 @@ -215,9 +216,18 @@ class TestFalAIVideoTransformation: ({"request_id": "abc", "status": "COMPLETED"}, "completed"), ], ) - def test_status_response_mapping(self, response_data, expected_status): + def test_status_response_mapping(self, response_data, expected_status, monkeypatch): 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)) + 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 + monkeypatch.setattr(fal_video_module, "_get_httpx_client", lambda: client) video = self.config.transform_video_status_retrieve_response( raw_response=response, @@ -239,20 +249,26 @@ class TestFalAIVideoTransformation: ) assert poll_url == status_url - def test_status_response_error(self): + def test_status_response_error(self, monkeypatch): response_data = { "request_id": "abc", "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 + monkeypatch.setattr(fal_video_module, "_get_httpx_client", lambda: client) video = self.config.transform_video_status_retrieve_response( raw_response=response, @@ -263,8 +279,99 @@ 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, monkeypatch): + 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 + monkeypatch.setattr(fal_video_module, "_get_httpx_client", lambda: client) + + video = self.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.asyncio + async def test_async_status_completed_result_error_surfaces_fal_message(self, monkeypatch): + 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) + monkeypatch.setattr(fal_video_module, "get_async_httpx_client", lambda llm_provider: client) + + video = await self.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, monkeypatch): + 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() + monkeypatch.setattr(fal_video_module, "_get_httpx_client", lambda: client) + + video = self.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( @@ -309,6 +416,83 @@ 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 + + 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" + + @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 + + @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" + + 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, From ffa1cceb11c466659ada5d8cf19ce65debf8ca08 Mon Sep 17 00:00:00 2001 From: kerry Date: Mon, 21 Sep 2026 20:07:13 +0000 Subject: [PATCH 2/4] refactor(fal_ai): share result request derivation between status fetchers Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/fal_ai/videos/transformation.py | 58 ++++++++++---------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/litellm/llms/fal_ai/videos/transformation.py b/litellm/llms/fal_ai/videos/transformation.py index 130e5bda70a..597b265fbf9 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, Sequence +from collections.abc import Mapping from dataclasses import dataclass from types import MappingProxyType from typing import Final, TypeAlias @@ -189,9 +189,9 @@ def _error_text(response_data: Mapping[str, object]) -> str | None: if isinstance(detail, str): return detail if isinstance(detail, list): - detail_items: Final[Sequence[Mapping[str, object]]] = TypeAdapter( - Sequence[Mapping[str, object]] - ).validate_python(tuple(item for item in detail if isinstance(item, Mapping))) + 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 ) @@ -217,6 +217,26 @@ def _response_string(response_data: Mapping[str, object], key: str, default: str 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, @@ -434,19 +454,10 @@ class FalAIVideoConfig(BaseVideoConfig): raw_response: httpx.Response, response_data: Mapping[str, object], ) -> str | None: - if _response_string(response_data, "status", "IN_QUEUE") != "COMPLETED": + result_request: Final[tuple[str, Mapping[str, str]] | None] = _result_request(raw_response, response_data) + if result_request is None: 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 - } - ) + result_url, result_headers = result_request result_response: Final[httpx.Response] = _get_httpx_client().get( url=result_url, headers=result_headers, @@ -473,19 +484,10 @@ class FalAIVideoConfig(BaseVideoConfig): raw_response: httpx.Response, response_data: Mapping[str, object], ) -> str | None: - if _response_string(response_data, "status", "IN_QUEUE") != "COMPLETED": + result_request: Final[tuple[str, Mapping[str, str]] | None] = _result_request(raw_response, response_data) + if result_request is None: 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 - } - ) + result_url, result_headers = result_request async_httpx_client: Final[AsyncHTTPHandler] = get_async_httpx_client(llm_provider=LlmProviders.FAL_AI) result_response: Final[httpx.Response] = await async_httpx_client.get( url=result_url, From eaa6936f13d8e30e45d1d8f7a9395cf98a083235 Mon Sep 17 00:00:00 2001 From: kerry Date: Mon, 21 Sep 2026 20:27:08 +0000 Subject: [PATCH 3/4] fix(fal_ai): carry fal response into content errors Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/fal_ai/videos/transformation.py | 4 ++++ .../llms/fal_ai/videos/test_fal_ai_video_transformation.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/litellm/llms/fal_ai/videos/transformation.py b/litellm/llms/fal_ai/videos/transformation.py index 597b265fbf9..f3e189c5672 100644 --- a/litellm/llms/fal_ai/videos/transformation.py +++ b/litellm/llms/fal_ai/videos/transformation.py @@ -543,6 +543,8 @@ class FalAIVideoConfig(BaseVideoConfig): 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() @@ -559,6 +561,8 @@ class FalAIVideoConfig(BaseVideoConfig): 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) 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 570ff883eec..fe3ddc0516c 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 @@ -435,6 +435,7 @@ class TestFalAIVideoTransformation: 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( @@ -448,6 +449,7 @@ class TestFalAIVideoTransformation: 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): @@ -469,6 +471,7 @@ class TestFalAIVideoTransformation: 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): @@ -483,6 +486,7 @@ class TestFalAIVideoTransformation: 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) From 0b9035b48ff2d6864b7b47ae007fa1935e82b541 Mon Sep 17 00:00:00 2001 From: kerry Date: Mon, 21 Sep 2026 20:45:58 +0000 Subject: [PATCH 4/4] fix(fal_ai): handle transient result errors and inject clients Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/fal_ai/videos/transformation.py | 34 +++++++--- .../test_fal_ai_video_transformation.py | 63 +++++++++++++------ 2 files changed, 71 insertions(+), 26 deletions(-) diff --git a/litellm/llms/fal_ai/videos/transformation.py b/litellm/llms/fal_ai/videos/transformation.py index f3e189c5672..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 @@ -212,6 +212,16 @@ def _result_error(raw_response: httpx.Response) -> str | None: 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 @@ -265,6 +275,15 @@ def _status_video_object( 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", @@ -458,11 +477,11 @@ class FalAIVideoConfig(BaseVideoConfig): if result_request is None: return None result_url, result_headers = result_request - result_response: Final[httpx.Response] = _get_httpx_client().get( + result_response: Final[httpx.Response] = self._sync_client_factory().get( url=result_url, headers=result_headers, ) - return _result_error(result_response) + return _terminal_result_error(result_response) async def async_transform_video_status_retrieve_response( self, @@ -488,12 +507,11 @@ class FalAIVideoConfig(BaseVideoConfig): if result_request is None: return None result_url, result_headers = result_request - async_httpx_client: Final[AsyncHTTPHandler] = get_async_httpx_client(llm_provider=LlmProviders.FAL_AI) - result_response: Final[httpx.Response] = await async_httpx_client.get( + result_response: Final[httpx.Response] = await self._async_client_factory().get( url=result_url, headers=result_headers, ) - return _result_error(result_response) + return _terminal_result_error(result_response) @staticmethod def _decode_video_id(video_id: str) -> tuple[str, str]: @@ -547,7 +565,7 @@ class FalAIVideoConfig(BaseVideoConfig): 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 ) @@ -565,7 +583,7 @@ class FalAIVideoConfig(BaseVideoConfig): 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/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 fe3ddc0516c..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 @@ -216,9 +216,10 @@ class TestFalAIVideoTransformation: ({"request_id": "abc", "status": "COMPLETED"}, "completed"), ], ) - def test_status_response_mapping(self, response_data, expected_status, monkeypatch): + 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, @@ -227,9 +228,9 @@ class TestFalAIVideoTransformation: ) client: Final = Mock() client.get.return_value = result_response - monkeypatch.setattr(fal_video_module, "_get_httpx_client", lambda: client) + 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", @@ -249,7 +250,7 @@ class TestFalAIVideoTransformation: ) assert poll_url == status_url - def test_status_response_error(self, monkeypatch): + def test_status_response_error(self): response_data = { "request_id": "abc", "status": "COMPLETED", @@ -268,9 +269,9 @@ class TestFalAIVideoTransformation: ) client: Final = Mock() client.get.return_value = result_response - monkeypatch.setattr(fal_video_module, "_get_httpx_client", lambda: client) + 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", @@ -279,7 +280,7 @@ class TestFalAIVideoTransformation: assert video.status == "failed" assert video.error == {"code": "fal_error", "message": "generation failed"} - def test_status_completed_result_error_surfaces_fal_message(self, monkeypatch): + 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( @@ -302,9 +303,9 @@ class TestFalAIVideoTransformation: ) client: Final = Mock() client.get.return_value = result_response - monkeypatch.setattr(fal_video_module, "_get_httpx_client", lambda: client) + 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", @@ -314,8 +315,34 @@ class TestFalAIVideoTransformation: 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, monkeypatch): + 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( @@ -338,9 +365,9 @@ class TestFalAIVideoTransformation: ) client: Final = Mock() client.get = AsyncMock(return_value=result_response) - monkeypatch.setattr(fal_video_module, "get_async_httpx_client", lambda llm_provider: client) + config = FalAIVideoConfig(async_client_factory=lambda: client) - video = await self.config.async_transform_video_status_retrieve_response( + video = await config.async_transform_video_status_retrieve_response( raw_response=response, logging_obj=self.logging_obj, custom_llm_provider="fal_ai", @@ -350,7 +377,7 @@ class TestFalAIVideoTransformation: 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, monkeypatch): + 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, @@ -359,9 +386,9 @@ class TestFalAIVideoTransformation: ) client: Final = Mock() - monkeypatch.setattr(fal_video_module, "_get_httpx_client", lambda: client) + 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", @@ -391,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", @@ -403,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)