test(e2e): cover fal Seedance video create, poll and download

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
kerry 2026-09-19 18:22:58 +00:00
parent e0b455e94e
commit 85a6a8e206
5 changed files with 109 additions and 1 deletions

View file

@ -80,6 +80,7 @@
- {id: llm.images_generations.vertex.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: images_generations, route: vertex, capability: basic, streaming: nonstream, assertions: [works], source: "vertex_ai/image_generation/image_generation_handler.py", rationale: "Vertex Imagen"}
- {id: llm.images_generations.bedrock.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: images_generations, route: bedrock_converse, capability: basic, streaming: nonstream, assertions: [works], source: "bedrock/image_generation/image_handler.py", rationale: "Bedrock Titan Image"}
- {id: llm.images_generations.black_forest_labs.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: images_generations, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "black_forest_labs/image_generation/handler.py", rationale: "BFL Flux via OpenAI-compat"}
- {id: llm.videos.fal_ai.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: videos, route: fal_ai, capability: basic, streaming: nonstream, assertions: [works], source: "test_video_generation_e2e.py", rationale: "fal queue video create, poll, content download"}
- {id: llm.audio_speech.openai.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: audio_speech, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_audio_speech_e2e.py:22", rationale: "OpenAI TTS binary audio"}
- {id: llm.audio_speech.openai.basic.stream.works, module: llm, tier: P1, subject_endpoint: audio_speech, route: openai, capability: basic, streaming: stream, assertions: [works], source: "proxy_server.py:9043", rationale: "TTS streaming chunk generator"}
- {id: llm.audio_speech.openai.input_validation.nonstream.works, module: llm, tier: P1, subject_endpoint: audio_speech, route: openai, capability: input_validation, streaming: nonstream, assertions: [works], source: "vendor strategy §9.6 / LIT-4778", rationale: "TTS missing input/model, invalid voice, empty input rejected"}

View file

@ -44,6 +44,7 @@ LlmEndpoint = Literal[
"vector_stores",
"ocr",
"bedrock_native",
"videos",
]
LlmRoute = Literal[
@ -53,6 +54,7 @@ LlmRoute = Literal[
"bedrock_converse",
"bedrock_invoke",
"cohere",
"fal_ai",
"gemini",
"hosted_vllm",
"openai",

View file

@ -48,6 +48,7 @@ most likely to silently break and the one a mock can't prove works.
|----------|---------------|-----------|------------|-------------|--------|
| Chat | live (spend suite) | live (spend suite) | gap | live | partial |
| Embeddings | live (spend suite) | n/a | n/a | live | covered |
| Video | live (fal.ai Seedance) | n/a | n/a | - | partial |
| Responses / image / audio / rerank / realtime | - | - | - | - | gap |
## This suite's files
@ -61,6 +62,7 @@ most likely to silently break and the one a mock can't prove works.
| `test_anthropic_passthrough_streaming_logs_cost` | anthropic native, stream, cost |
| `test_anthropic_passthrough_tool_call_logs_cost` | anthropic native, tool call, cost |
| `test_vertex_passthrough_via_managed_model_logs_cost` | vertex_ai native, non-stream, cost |
| `test_fal_seedance_video_completes_and_downloads` | fal.ai Seedance video create, poll, and content download |
Vertex keeps the credential on the proxy like gemini/anthropic, but the deployment is
added at runtime instead of declared in the gateway config: the test POSTs `/model/new`

View file

@ -13,7 +13,7 @@ from dataclasses import dataclass
from typing import Literal
from e2e_config import SLOW_PROVIDER_TIMEOUT_SECONDS
from e2e_http import BinaryStream, Result, StreamingResponse
from e2e_http import BinaryStream, NoBody, Result, StreamingResponse
from models import CacheControl, ChatMessage, LiteLLMParamsBody, RichMessage, TextBlock
from proxy_client import ProxyClient
from pydantic import BaseModel
@ -26,6 +26,8 @@ __all__ = [
"TextBlock",
"TranscriptionForm",
"TranscriptionResult",
"VideoObject",
"VideoRequest",
]
@ -127,6 +129,13 @@ class ImageRequest(BaseModel):
size: str = "1024x1024"
class VideoRequest(BaseModel):
model: str
prompt: str
seconds: str = "4"
size: str = "1280x720"
class ImageEditForm(BaseModel):
model: str
prompt: str
@ -267,6 +276,12 @@ class ImagesResult(BaseModel):
data: list[ImageItem] = []
class VideoObject(BaseModel):
id: str
status: str
model: str | None = None
class TranscriptionResult(BaseModel):
text: str = ""
@ -440,6 +455,25 @@ class EndpointsClient:
"/v1/images/generations", key, ImageRequest(model=model, prompt=prompt)
)
def videos(self, key: str, model: str, prompt: str) -> StreamingResponse:
return self._send(
"/v1/videos", key, VideoRequest(model=model, prompt=prompt)
)
def video_status(self, key: str, video_id: str) -> Result[VideoObject]:
return self.proxy.transport.get(
f"/v1/videos/{video_id}",
headers=self.proxy.transport.bearer(key),
params=NoBody(),
response_type=VideoObject,
)
def video_content(self, key: str, video_id: str) -> StreamingResponse:
return self.proxy.transport.download(
f"/v1/videos/{video_id}/content",
headers=self.proxy.transport.bearer(key),
)
def image_edit(
self, key: str, model: str, prompt: str, image: bytes, *, filename: str = "image.png"
) -> Result[ImagesResult]:

View file

@ -0,0 +1,69 @@
"""Live e2e: POST /v1/videos creates a video and serves its content.
Registers a fal.ai Seedance deployment at runtime, polls the queued video until it
completes, and asserts the generated content is returned as binary data.
"""
from __future__ import annotations
import time
from typing import Final
import pytest
from e2e_config import unique_marker
from e2e_http import require_successful_call, unwrap
from endpoints_client import EndpointsClient, VideoObject
from lifecycle import ResourceManager
from models import LiteLLMParamsBody
pytestmark = pytest.mark.e2e
_POLL_INTERVAL_SECONDS: Final[float] = 5.0
_POLL_TIMEOUT_SECONDS: Final[float] = 600.0
def _wait_for_completion(
endpoints_client: EndpointsClient, key: str, created: VideoObject
) -> VideoObject:
deadline = time.monotonic() + _POLL_TIMEOUT_SECONDS
while time.monotonic() < deadline:
status = unwrap(endpoints_client.video_status(key, created.id))
assert status.id == created.id
if status.status == "completed":
return status
if status.status == "failed":
pytest.fail(f"fal.ai video generation failed: {status}")
time.sleep(_POLL_INTERVAL_SECONDS)
pytest.fail(f"fal.ai video {created.id!r} did not complete within {_POLL_TIMEOUT_SECONDS}s")
class TestVideoGeneration:
@pytest.mark.covers("llm.videos.fal_ai.basic.nonstream.works")
def test_fal_seedance_video_completes_and_downloads(
self, endpoints_client: EndpointsClient, resources: ResourceManager
) -> None:
model = f"e2e-fal-video-{unique_marker()}"
model_id = endpoints_client.create_model(
model,
LiteLLMParamsBody(
model="fal_ai/bytedance/seedance-2.5/text-to-video",
api_key="os.environ/FAL_AI_API_KEY",
),
)
resources.defer(lambda: endpoints_client.delete_model(model_id))
key = resources.key()
result = endpoints_client.videos(
key, model, "a red fox running through snow at dawn"
)
require_successful_call(result)
created = VideoObject.model_validate_json(result.body)
assert created.id
assert created.model
_wait_for_completion(endpoints_client, key, created)
content = endpoints_client.video_content(key, created.id)
require_successful_call(content)
assert len(content.body) > 0
assert not (content.content_type or "").startswith("application/json")