From 153e5ed18533f337a101df9639396e1ae4df5984 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:12:51 -0700 Subject: [PATCH] fix(fal_ai): reuse the status client and headers on the video result probe (#42511) * fix(fal_ai): reuse the status client and headers on the video result probe The Fal result GET issued after a COMPLETED status poll built its own default client and only carried Authorization and Content-Type, so an injected client, a request-level ssl_verify and extra_headers were honored on the status GET but not on the result GET, and a transport failure on that probe escaped as a 500. The handler now hands the selected sync or async client to the provider status transform, Fal reuses it with the full validated header set, and a probe transport error stays non-terminal like the existing 429 and 5xx handling Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(fal_ai): mark the result probe header dict as mutable-ok for the type discipline gate Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(fal_ai): move the result probe repro to tests/integration Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(e2e): restore the shared e2e helpers to the merge base Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: kerry Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../llms/base_llm/videos/transformation.py | 3 + litellm/llms/custom_httpx/llm_http_handler.py | 2 + litellm/llms/edenai/videos/transformation.py | 2 + litellm/llms/fal_ai/videos/transformation.py | 45 ++-- litellm/llms/gemini/videos/transformation.py | 2 + litellm/llms/openai/videos/transformation.py | 2 + .../llms/runwayml/videos/transformation.py | 1 + .../llms/vertex_ai/videos/transformation.py | 2 + tests/integration/_support/wire.py | 19 +- tests/integration/contracts.json | 9 + .../providers/test_fal_ai_video_wire.py | 195 +++++++++++++++++- .../test_fal_ai_video_transformation.py | 145 +++++++++++-- 12 files changed, 391 insertions(+), 36 deletions(-) diff --git a/litellm/llms/base_llm/videos/transformation.py b/litellm/llms/base_llm/videos/transformation.py index 4f94cec0973..a4964de46b8 100644 --- a/litellm/llms/base_llm/videos/transformation.py +++ b/litellm/llms/base_llm/videos/transformation.py @@ -12,6 +12,7 @@ from litellm.types.videos.main import VideoCreateOptionalRequestParams if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj + from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.types.videos.main import CharacterObject as _CharacterObject from litellm.types.videos.main import VideoObject as _VideoObject @@ -269,6 +270,7 @@ class BaseVideoConfig(ABC): raw_response: httpx.Response, logging_obj: LiteLLMLoggingObj, custom_llm_provider: str | None = None, + client: "HTTPHandler | None" = None, ) -> VideoObject: pass @@ -277,6 +279,7 @@ class BaseVideoConfig(ABC): raw_response: httpx.Response, logging_obj: LiteLLMLoggingObj, custom_llm_provider: str | None = None, + client: "AsyncHTTPHandler | None" = None, ) -> VideoObject: """Async transform video status retrieve response.""" return self.transform_video_status_retrieve_response( diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index c9e9906e931..cbef44ce488 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -8812,6 +8812,7 @@ class BaseLLMHTTPHandler: raw_response=response, logging_obj=logging_obj, custom_llm_provider=custom_llm_provider, + client=sync_httpx_client, ) except Exception as e: @@ -8901,6 +8902,7 @@ class BaseLLMHTTPHandler: raw_response=response, logging_obj=logging_obj, custom_llm_provider=custom_llm_provider, + client=async_httpx_client, ) except Exception as e: diff --git a/litellm/llms/edenai/videos/transformation.py b/litellm/llms/edenai/videos/transformation.py index 25c7bcf24ea..31572e9e5fe 100644 --- a/litellm/llms/edenai/videos/transformation.py +++ b/litellm/llms/edenai/videos/transformation.py @@ -21,6 +21,7 @@ from ..common_utils import EdenAIException, authorized_headers, endpoint_url, re if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + from litellm.llms.custom_httpx.http_handler import HTTPHandler def _usage_with_reported_cost( @@ -110,6 +111,7 @@ class EdenAIVideoConfig(OpenAIVideoConfig): raw_response: httpx.Response, logging_obj: "LiteLLMLoggingObj", custom_llm_provider: str | None = None, + client: "HTTPHandler | None" = None, ) -> VideoObject: raw_response.raise_for_status() # the shared GET helpers return error bodies instead of raising video: Final = super().transform_video_status_retrieve_response( diff --git a/litellm/llms/fal_ai/videos/transformation.py b/litellm/llms/fal_ai/videos/transformation.py index 44b33a8f58f..e46199dd89f 100644 --- a/litellm/llms/fal_ai/videos/transformation.py +++ b/litellm/llms/fal_ai/videos/transformation.py @@ -230,6 +230,9 @@ def _response_string(response_data: Mapping[str, object], key: str, default: str return value if isinstance(value, str) else default +_RESULT_HEADERS_NOT_FORWARDED: Final[frozenset[str]] = frozenset({"host", "content-length", "transfer-encoding"}) + + def _result_request( raw_response: httpx.Response, response_data: Mapping[str, object], @@ -237,14 +240,12 @@ def _result_request( if _response_string(response_data, "status", "IN_QUEUE") != "COMPLETED": return None result_url: Final[str] = str(raw_response.request.url).removesuffix("/status") + encoding: Final[str] = raw_response.request.headers.encoding 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 + key.decode(encoding): value.decode(encoding) + for key, value in raw_response.request.headers.raw + if key.decode(encoding).lower() not in _RESULT_HEADERS_NOT_FORWARDED } ) return result_url, result_headers @@ -463,9 +464,10 @@ class FalAIVideoConfig(BaseVideoConfig): raw_response: httpx.Response, logging_obj: object, custom_llm_provider: str | None = None, + client: HTTPHandler | None = None, ) -> VideoObject: response_data: Final[Mapping[str, object]] = _response_data(raw_response) - result_error: Final[str | None] = self._fetch_result_error(raw_response, response_data) + result_error: Final[str | None] = self._fetch_result_error(raw_response, response_data, client) return _status_video_object( response_data=response_data, raw_response=raw_response, @@ -477,15 +479,20 @@ class FalAIVideoConfig(BaseVideoConfig): self, raw_response: httpx.Response, response_data: Mapping[str, object], + client: HTTPHandler | None, ) -> 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, - ) + result_client: Final[HTTPHandler] = client if client is not None else self._sync_client_factory() + try: + result_response: Final[httpx.Response] = result_client.get( + url=result_url, + headers=dict(result_headers), # mutable-ok: HTTPHandler.get only accepts a dict + ) + except httpx.TransportError: + return None return _terminal_result_error(result_response) async def async_transform_video_status_retrieve_response( @@ -493,9 +500,10 @@ class FalAIVideoConfig(BaseVideoConfig): raw_response: httpx.Response, logging_obj: object, custom_llm_provider: str | None = None, + client: AsyncHTTPHandler | 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) + result_error: Final[str | None] = await self._fetch_result_error_async(raw_response, response_data, client) return _status_video_object( response_data=response_data, raw_response=raw_response, @@ -507,15 +515,20 @@ class FalAIVideoConfig(BaseVideoConfig): self, raw_response: httpx.Response, response_data: Mapping[str, object], + client: AsyncHTTPHandler | None, ) -> 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, - ) + result_client: Final[AsyncHTTPHandler] = client if client is not None else self._async_client_factory() + try: + result_response: Final[httpx.Response] = await result_client.get( + url=result_url, + headers=dict(result_headers), # mutable-ok: AsyncHTTPHandler.get only accepts a dict + ) + except httpx.TransportError: + return None return _terminal_result_error(result_response) @staticmethod diff --git a/litellm/llms/gemini/videos/transformation.py b/litellm/llms/gemini/videos/transformation.py index a44717eb659..8a3894cf7fb 100644 --- a/litellm/llms/gemini/videos/transformation.py +++ b/litellm/llms/gemini/videos/transformation.py @@ -26,6 +26,7 @@ from litellm.types.videos.utils import ( if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj + from litellm.llms.custom_httpx.http_handler import HTTPHandler from ...base_llm.chat.transformation import BaseLLMException as _BaseLLMException @@ -386,6 +387,7 @@ class GeminiVideoConfig(BaseVideoConfig): raw_response: httpx.Response, logging_obj: LiteLLMLoggingObj, custom_llm_provider: str | None = None, + client: "HTTPHandler | None" = None, ) -> VideoObject: """ Transform the Veo operation status response. diff --git a/litellm/llms/openai/videos/transformation.py b/litellm/llms/openai/videos/transformation.py index 9a4b030993f..cfd4ca24ae7 100644 --- a/litellm/llms/openai/videos/transformation.py +++ b/litellm/llms/openai/videos/transformation.py @@ -28,6 +28,7 @@ from litellm.types.videos.utils import ( if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj + from litellm.llms.custom_httpx.http_handler import HTTPHandler from ...base_llm.chat.transformation import BaseLLMException as _BaseLLMException @@ -437,6 +438,7 @@ class OpenAIVideoConfig(BaseVideoConfig): raw_response: httpx.Response, logging_obj: LiteLLMLoggingObj, custom_llm_provider: str | None = None, + client: "HTTPHandler | None" = None, ) -> VideoObject: """ Transform the OpenAI video retrieve response. diff --git a/litellm/llms/runwayml/videos/transformation.py b/litellm/llms/runwayml/videos/transformation.py index c7696a1cb29..1abdece44f2 100644 --- a/litellm/llms/runwayml/videos/transformation.py +++ b/litellm/llms/runwayml/videos/transformation.py @@ -616,6 +616,7 @@ class RunwayMLVideoConfig(BaseVideoConfig): raw_response: httpx.Response, logging_obj: LiteLLMLoggingObj, custom_llm_provider: str | None = None, + client: HTTPHandler | None = None, ) -> VideoObject: """ Transform the RunwayML video status retrieve response. diff --git a/litellm/llms/vertex_ai/videos/transformation.py b/litellm/llms/vertex_ai/videos/transformation.py index dc9caa13224..6c29059f0a9 100644 --- a/litellm/llms/vertex_ai/videos/transformation.py +++ b/litellm/llms/vertex_ai/videos/transformation.py @@ -36,6 +36,7 @@ if TYPE_CHECKING: from litellm.llms.base_llm.chat.transformation import ( BaseLLMException as _BaseLLMException, ) + from litellm.llms.custom_httpx.http_handler import HTTPHandler LiteLLMLoggingObj = _LiteLLMLoggingObj BaseLLMException = _BaseLLMException @@ -491,6 +492,7 @@ class VertexAIVideoConfig(BaseVideoConfig, VertexBase): raw_response: httpx.Response, logging_obj: LiteLLMLoggingObj, custom_llm_provider: str | None = None, + client: "HTTPHandler | None" = None, ) -> VideoObject: """ Transform the Veo operation status response. diff --git a/tests/integration/_support/wire.py b/tests/integration/_support/wire.py index acc51dd4497..0c6acfde96c 100644 --- a/tests/integration/_support/wire.py +++ b/tests/integration/_support/wire.py @@ -1,7 +1,8 @@ from __future__ import annotations +import ssl import threading -from collections.abc import Callable, Iterator, Mapping +from collections.abc import Callable, Generator, Mapping from contextlib import contextmanager from dataclasses import dataclass from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -38,7 +39,7 @@ class Wire: @contextmanager -def wire_server(respond: Callable[[Request], Reply]) -> Iterator[Wire]: +def wire_server(respond: Callable[[Request], Reply], tls: ssl.SSLContext | None = None) -> Generator[Wire, None, None]: """Owned TCP peer; requests traverse the real HTTP client and serialization.""" received: Final[SimpleQueue[Request]] = SimpleQueue() errors: Final[SimpleQueue[Exception]] = SimpleQueue() @@ -50,7 +51,8 @@ def wire_server(respond: Callable[[Request], Reply]) -> Iterator[Wire]: def respond(self) -> None: request: Final = Request( - self.command, self.path, + self.command, + self.path, {name.lower(): value for name, value in self.headers.items()}, self.rfile.read(int(self.headers.get("content-length", "0"))), ) @@ -99,11 +101,20 @@ def wire_server(respond: Callable[[Request], Reply]) -> Iterator[Wire]: class OwnedHTTPServer(ThreadingHTTPServer): daemon_threads = False + def server_bind(self) -> None: + super().server_bind() + if tls is not None: + self.socket = tls.wrap_socket(self.socket, server_side=True) + with OwnedHTTPServer(("127.0.0.1", 0), Handler) as server: thread: Final = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05}) thread.start() try: - yield Wire(f"http://127.0.0.1:{server.server_port}", received, disconnected) + yield Wire( + f"{'https' if tls is not None else 'http'}://127.0.0.1:{server.server_port}", + received, + disconnected, + ) finally: server.shutdown() thread.join(timeout=6) diff --git a/tests/integration/contracts.json b/tests/integration/contracts.json index f6a5afeeb28..9d4778801a5 100644 --- a/tests/integration/contracts.json +++ b/tests/integration/contracts.json @@ -220,6 +220,15 @@ "tests/integration/providers/test_fal_ai_video_wire.py::test_fal_h3_video_create_uses_canonical_body_and_status_path": [ "other.provider_wire.fal_ai.video_queue_create_status_and_content_download" ], + "tests/integration/providers/test_fal_ai_video_wire.py::test_fal_result_probe_carries_the_deployment_extra_headers": [ + "other.provider_wire.fal_ai.video_result_probe_forwards_extra_headers" + ], + "tests/integration/providers/test_fal_ai_video_wire.py::test_fal_result_probe_reuses_the_ssl_verify_false_client": [ + "other.provider_wire.fal_ai.video_result_probe_honors_ssl_verify" + ], + "tests/integration/providers/test_fal_ai_video_wire.py::test_fal_provider_hanging_up_on_the_result_probe_keeps_the_completed_status": [ + "other.provider_wire.fal_ai.video_result_probe_hangup_stays_completed" + ], "tests/integration/mcp/test_mcp_lifecycle.py::test_saved_headers_reach_real_mcp_tool_and_survive_unrelated_edit": [ "mcp.call_tool.saved_headers.reach_actual_transport" ], diff --git a/tests/integration/providers/test_fal_ai_video_wire.py b/tests/integration/providers/test_fal_ai_video_wire.py index 1f3113bb840..90df4520b98 100644 --- a/tests/integration/providers/test_fal_ai_video_wire.py +++ b/tests/integration/providers/test_fal_ai_video_wire.py @@ -1,9 +1,17 @@ +import datetime +import ipaddress import json +import ssl import uuid +from pathlib import Path from typing import Final import pytest -from integration._support.client import Gateway +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID +from integration._support.client import JSON_OBJECT, Gateway, object_value, string_value from integration._support.wire import Reply, Request, wire_server _MODEL: Final = "bytedance/seedance-2.5/text-to-video" @@ -16,6 +24,37 @@ def _h3_queue_reply(request_id: str) -> Reply: return Reply(body=json.dumps({"status": "IN_QUEUE", "request_id": request_id, "queue_position": 0}).encode()) +def _write_self_signed_cert(cert_dir: Path) -> tuple[Path, Path]: + key: Final = rsa.generate_private_key(public_exponent=65537, key_size=2048) + now: Final = datetime.datetime.now(datetime.timezone.utc) + name: Final = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]) + cert: Final = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=7)) + .add_extension( + x509.SubjectAlternativeName([x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), + critical=False, + ) + .sign(key, hashes.SHA256()) + ) + cert_file: Final = cert_dir / "cert.pem" + key_file: Final = cert_dir / "key.pem" + cert_file.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + key_file.write_bytes( + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + ) + ) + return cert_file, key_file + + @pytest.mark.covers("other.provider_wire.fal_ai.video_queue_create_status_and_content_download") def test_fal_video_create_status_and_content_follow_queue_wire_contract(gateway: Gateway) -> None: request_id: Final = "fal-req-" + uuid.uuid4().hex @@ -178,12 +217,164 @@ def test_fal_video_failed_result_reports_failed_status_and_fal_error(gateway: Ga 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"] + assert "input.reference_image_urls: Failed to download the file" in string_value( + object_value(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 +@pytest.mark.covers("other.provider_wire.fal_ai.video_result_probe_forwards_extra_headers") +def test_fal_result_probe_carries_the_deployment_extra_headers(gateway: Gateway) -> None: + request_id: Final = "fal-probe-req-" + uuid.uuid4().hex + marker: Final = uuid.uuid4().hex + + def respond(request: Request) -> Reply: + assert request.headers["authorization"] == "Key synthetic-fal-key" + if request.method == "POST": + assert request.target == f"/{_H3_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"/minimax/h3/requests/{request_id}/status": + return Reply(body=json.dumps({"status": "COMPLETED", "request_id": request_id}).encode()) + assert request.target == f"/minimax/h3/requests/{request_id}" + if request.headers.get("x-integration-routing") != marker: + return Reply(status=403, body=json.dumps({"detail": "routing header missing"}).encode()) + return Reply(body=json.dumps({"video": {"url": f"{wire_url}/files/{request_id}.mp4"}}).encode()) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + wire_url: Final = wire.url + model: Final = scenario.model( + model=f"fal_ai/{_H3_MODEL}", + api_base=wire.url, + api_key="synthetic-fal-key", + extra_headers={"x-integration-routing": marker}, + ) + created: Final = gateway.post( + "/v1/videos", + { + "model": model, + "prompt": "a paper boat drifting across a puddle after rain", + "seconds": 6, + "size": "2k", + }, + ) + assert created["status"] == "queued" + video_id: Final = created["id"] + response: Final = gateway.request("GET", f"/v1/videos/{video_id}") + assert response.status_code == 200, response.text + status: Final = JSON_OBJECT.validate_json(response.content) + assert status["status"] == "completed", status + assert status["error"] is None, status + assert [ + (request.method, request.target, request.headers.get("x-integration-routing")) for request in wire.drain() + ] == [ + ("POST", f"/{_H3_MODEL}", marker), + ("GET", f"/minimax/h3/requests/{request_id}/status", marker), + ("GET", f"/minimax/h3/requests/{request_id}", marker), + ] + + +@pytest.mark.covers("other.provider_wire.fal_ai.video_result_probe_honors_ssl_verify") +def test_fal_result_probe_reuses_the_ssl_verify_false_client(gateway: Gateway, tmp_path: Path) -> None: + request_id: Final = "fal-tls-req-" + uuid.uuid4().hex + cert_file, key_file = _write_self_signed_cert(tmp_path) + context: Final = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(certfile=cert_file, keyfile=key_file) + + def respond(request: Request) -> Reply: + assert request.headers["authorization"] == "Key synthetic-fal-key" + if request.method == "POST": + assert request.target == f"/{_H3_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"/minimax/h3/requests/{request_id}/status": + return Reply(body=json.dumps({"status": "COMPLETED", "request_id": request_id}).encode()) + assert request.target == f"/minimax/h3/requests/{request_id}" + return Reply(body=json.dumps({"video": {"url": f"{wire_url}/files/{request_id}.mp4"}}).encode()) + + with wire_server(respond, tls=context) as wire, gateway.scenario() as scenario: + wire_url: Final = wire.url + model: Final = scenario.model( + model=f"fal_ai/{_H3_MODEL}", + api_base=wire.url, + api_key="synthetic-fal-key", + ssl_verify=False, + ) + created: Final = gateway.post( + "/v1/videos", + { + "model": model, + "prompt": "a paper boat drifting across a puddle after rain", + "seconds": 6, + "size": "2k", + }, + ) + assert created["status"] == "queued" + video_id: Final = created["id"] + response: Final = gateway.request("GET", f"/v1/videos/{video_id}") + assert response.status_code == 200, response.text + status: Final = JSON_OBJECT.validate_json(response.content) + assert status["status"] == "completed", status + assert status["error"] is None, status + assert [(request.method, request.target) for request in wire.drain()] == [ + ("POST", f"/{_H3_MODEL}"), + ("GET", f"/minimax/h3/requests/{request_id}/status"), + ("GET", f"/minimax/h3/requests/{request_id}"), + ] + + +@pytest.mark.covers("other.provider_wire.fal_ai.video_result_probe_hangup_stays_completed") +def test_fal_provider_hanging_up_on_the_result_probe_keeps_the_completed_status(gateway: Gateway) -> None: + request_id: Final = "fal-hangup-req-" + uuid.uuid4().hex + + def respond(request: Request) -> Reply: + assert request.headers["authorization"] == "Key synthetic-fal-key" + if request.method == "POST": + assert request.target == f"/{_H3_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"/minimax/h3/requests/{request_id}/status": + return Reply(body=json.dumps({"status": "COMPLETED", "request_id": request_id}).encode()) + assert request.target == f"/minimax/h3/requests/{request_id}" + return Reply(chunks=(b"{",), abort_after=0) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model=f"fal_ai/{_H3_MODEL}", + api_base=wire.url, + api_key="synthetic-fal-key", + ) + created: Final = gateway.post( + "/v1/videos", + { + "model": model, + "prompt": "a paper boat drifting across a puddle after rain", + "seconds": 6, + "size": "2k", + }, + ) + assert created["status"] == "queued" + video_id: Final = created["id"] + response: Final = gateway.request("GET", f"/v1/videos/{video_id}") + assert response.status_code == 200, response.text + status: Final = JSON_OBJECT.validate_json(response.content) + assert status["status"] == "completed", status + assert status["error"] is None, status + assert [(request.method, request.target) for request in wire.drain()] == [ + ("POST", f"/{_H3_MODEL}"), + ("GET", f"/minimax/h3/requests/{request_id}/status"), + ("GET", f"/minimax/h3/requests/{request_id}"), + ] + + @pytest.mark.covers("other.provider_wire.fal_ai.h3_auto_duration_omits_duration_and_queues") def test_fal_h3_auto_duration_omits_duration_and_queues(gateway: Gateway) -> None: request_id: Final = "fal-h3-auto-" + uuid.uuid4().hex 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 4b8bfd264a6..d4ad22ca5a0 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 @@ -389,6 +389,117 @@ 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_completed_result_probe_reuses_status_client_and_extra_headers(self): + status_url = "https://queue.fal.run/minimax/h3/requests/abc/status" + status_headers: Final = { + "Authorization": "Key synthetic-fal-key", + "Content-Type": "application/json", + "X-Routing": "canary-7", + } + response: Final = httpx.Response( + 200, + json={"request_id": "abc", "status": "COMPLETED"}, + request=httpx.Request("GET", status_url, headers=status_headers), + ) + result_url: Final = status_url.removesuffix("/status") + status_client: Final = Mock() + status_client.get.return_value = httpx.Response( + 403, text="missing X-Routing", request=httpx.Request("GET", result_url) + ) + factory_client: Final = Mock() + config = FalAIVideoConfig(sync_client_factory=lambda: factory_client) + + video = config.transform_video_status_retrieve_response( + raw_response=response, + logging_obj=self.logging_obj, + custom_llm_provider="fal_ai", + client=status_client, + ) + + assert video.status == "failed" + assert video.error == {"code": "fal_error", "message": "missing X-Routing"} + factory_client.get.assert_not_called() + status_client.get.assert_called_once() + assert status_client.get.call_args.kwargs["url"] == result_url + assert status_client.get.call_args.kwargs["headers"].items() >= status_headers.items() + + def test_status_completed_result_probe_transport_error_keeps_completed(self): + 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), + ) + client: Final = Mock() + client.get.side_effect = httpx.ReadError("connection reset by fal.ai") + + video = FalAIVideoConfig().transform_video_status_retrieve_response( + raw_response=response, + logging_obj=self.logging_obj, + custom_llm_provider="fal_ai", + client=client, + ) + + assert video.status == "completed" + assert video.error is None + + @pytest.mark.asyncio + async def test_async_status_completed_result_probe_reuses_status_client_and_extra_headers(self): + status_url = "https://queue.fal.run/minimax/h3/requests/abc/status" + status_headers: Final = { + "Authorization": "Key synthetic-fal-key", + "Content-Type": "application/json", + "X-Routing": "canary-7", + } + response: Final = httpx.Response( + 200, + json={"request_id": "abc", "status": "COMPLETED"}, + request=httpx.Request("GET", status_url, headers=status_headers), + ) + result_url: Final = status_url.removesuffix("/status") + status_client: Final = Mock() + status_client.get = AsyncMock( + return_value=httpx.Response(403, text="missing X-Routing", request=httpx.Request("GET", result_url)) + ) + factory_client: Final = Mock() + factory_client.get = AsyncMock() + config = FalAIVideoConfig(async_client_factory=lambda: factory_client) + + video = await config.async_transform_video_status_retrieve_response( + raw_response=response, + logging_obj=self.logging_obj, + custom_llm_provider="fal_ai", + client=status_client, + ) + + assert video.status == "failed" + assert video.error == {"code": "fal_error", "message": "missing X-Routing"} + factory_client.get.assert_not_awaited() + status_client.get.assert_awaited_once() + assert status_client.get.await_args.kwargs["url"] == result_url + assert status_client.get.await_args.kwargs["headers"].items() >= status_headers.items() + + @pytest.mark.asyncio + async def test_async_status_completed_result_probe_transport_error_keeps_completed(self): + 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), + ) + client: Final = Mock() + client.get = AsyncMock(side_effect=httpx.ConnectError("tls handshake failed")) + + video = await FalAIVideoConfig().async_transform_video_status_retrieve_response( + raw_response=response, + logging_obj=self.logging_obj, + custom_llm_provider="fal_ai", + client=client, + ) + + assert video.status == "completed" + assert video.error is None + def test_status_in_progress_does_not_fetch_result(self): status_url = "https://queue.fal.run/minimax/h3/requests/abc/status" response = httpx.Response( @@ -564,17 +675,23 @@ class TestFalAIVideoTransformation: row = litellm.model_cost[f"fal_ai/{H3_TEXT_MODEL}"] model_info = litellm.get_model_info(model=H3_TEXT_MODEL, custom_llm_provider="fal_ai") - assert video_generation_cost( - model=H3_TEXT_MODEL, - duration_seconds=5, - custom_llm_provider="fal_ai", - model_info=model_info, - video_resolution="2K", - ) == 5 * row["output_cost_per_second_2k"] - assert video_generation_cost( - model=H3_TEXT_MODEL, - duration_seconds=5, - custom_llm_provider="fal_ai", - model_info=model_info, - video_resolution="768p", - ) == 5 * row["output_cost_per_second_768p"] + assert ( + video_generation_cost( + model=H3_TEXT_MODEL, + duration_seconds=5, + custom_llm_provider="fal_ai", + model_info=model_info, + video_resolution="2K", + ) + == 5 * row["output_cost_per_second_2k"] + ) + assert ( + video_generation_cost( + model=H3_TEXT_MODEL, + duration_seconds=5, + custom_llm_provider="fal_ai", + model_info=model_info, + video_resolution="768p", + ) + == 5 * row["output_cost_per_second_768p"] + )