mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
Merge pull request #42306 from BerriAI/litellm_fal_ai_surface_video_errors
fix(fal_ai): surface fal errors in video status and content instead of completed and generic 500
This commit is contained in:
commit
89de508d6e
6 changed files with 483 additions and 36 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue