litellm/tests/unit/proxy/video_endpoints/test_endpoints.py
Rick 4f3cb48dc1
feat(dashscope): add Wan 3.0, Wan 2.7 and HappyHorse video generation via /v1/videos (#43706)
* feat(dashscope): add Wan 3.0, Wan 2.7 and HappyHorse video generation via /v1/videos

DashScopeVideoConfig maps litellm's OpenAI Videos API onto Alibaba Model
Studio's async task API: create hits
/api/v1/services/aigc/video-generation/video-synthesis and status and content
poll /api/v1/tasks/{task_id}. It is registered for dashscope and for the
qwencloud and qwen_ai_platform brand aliases, following the same
get_dashscope_family_*_config pattern image generation uses. Unlike the
Sora-shaped providers the request body is nested, so prompt and media go under
input and everything else goes under parameters. X-DashScope-Async is always
sent, because DashScope rejects the call outright without it

OpenAI params translate to DashScope's contract. seconds becomes duration, and
size yields both a ratio and a named resolution tier (1280x720 gives 16:9 plus
720P), since sending only the ratio leaves the request on the default 1080P and
silently bills at that rate. input_reference becomes a single media entry,
base64 encoded into a data URI for file inputs. Which media type a model takes
(first_frame or, on the reference-to-video models, reference_image) is read
from provider_specific_entry.dashscope_video_reference_input via
get_model_info rather than hardcoded; models without it predate the media
array and get the flat img_url field. A caller-supplied media array always
wins, as that is the only way to express multi-role combinations

The create call is the only billed call, so it bills what DashScope will
deliver: the requested duration and tier, or the documented 5 second and 1080P
defaults when they are omitted. Smart duration (duration -1) lets the service
pick the length after that call, so it is rejected rather than recorded at
zero, with a 400. Task creation and task lookup both report failures as a 200 carrying
top-level code and message with no output, so that shape is raised instead of
surfacing as a queued video with an empty id. Status responses re-encode the
deployment model from the polled id, so follow-up polls and downloads route to
the same deployment. Timestamps parse as UTC+8. DashScope publishes no list,
delete or remix endpoint, so those raise rather than guess at a URL

The end-to-end tests drive the real avideo_generation() entrypoint through an
httpx MockTransport and assert on the JSON body httpx actually encoded. That is
what catches a mapper that drops input_reference before the request is built,
or a body fragment json cannot serialize, neither of which a test calling the
transform directly would see

Both model_prices maps gain wan3.0-video, wan3.0-video-prime, wan2.7-t2v,
wan2.7-i2v, wan2.7-r2v and the HappyHorse 1.0 and 1.1 t2v, i2v and r2v models,
for dashscope and the qwencloud and qwen_ai_platform aliases, priced per output
second by resolution tier from Model Studio's USD rate cards. dashscope and
qwen_ai_platform default to the Beijing host and use its rate card; qwencloud
defaults to the international host and uses Singapore's. The three
*_API_BASE_VIDEO overrides are documented in BerriAI/litellm-docs#1920

Co-authored-by: AaronHowell <237895480@qq.com>

* fix(dashscope): fail loudly on video responses that cannot be read

Addresses the review on the video generation PR. A body that is not the
documented task shape, an output block missing task_id or task_status, an
undocumented task_status and a missing output all raise DashScopeVideoError
now, instead of defaulting into a queued video with an empty id that callers
poll forever. A JSONDecodeError from a gateway response no longer escapes as
a generic connection error.

task_id and task_status are required on _TaskOutput, and a missing output
raises even when the body carries no code or message.

Download failures move into the 4xx range, since a 5xx has the proxy retry
them: 409 while the task is still running, 400 for a task that failed, 404
for an id that expired. The status a provider reported is kept when it
reported one, so an in-body failure that arrives on a 200 is billed as the
400 it is rather than as a retryable connection error.

characters and image are only dropped when drop_params is set; the params
that are rebuilt into the body or consumed by the caller (model, prompt,
user, extra_*) stay silently skipped, since the proxy stamps user from the
caller's end-user header.

Co-authored-by: AaronHowell <237895480@qq.com>

* fix(dashscope): clear the lint gates after merging main

* fix(dashscope): accept file tuples and paths as video input_reference

video_generation() types input_reference as FileTypes, which includes
(filename, content[, content_type[, headers]]) tuples and pathlib paths,
but the DashScope encoder only read bytes and file objects, so those
image-to-video calls raised before reaching DashScope. Bare strings stay
URLs and are never opened as paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(dashscope): snap size to a supported ratio and read file tuples and paths

* test(dashscope): assert whole mapped values and bill against hand-written rates

The tier pricing test read its expected rate from the same map the cost
calculator uses, so a wrong rate passed. Billing is now checked against
fixed rates driven through the status response usage, while the map test
only asserts every supported tier has its own rate. Size mapping tests
compare the full mapped dict, and the single-test wiring class is a
plain function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(videos): download content through the caller's client and stop retrying dead tasks

Two Cursor Bugbot findings on the DashScope video PR.

The DashScope artifact lives on OSS, so transform_video_content_response makes a
second request. It built its own cached client, discarding an injected client and
its ssl_verify. Thread the client through the content response the way
transform_video_status_retrieve_response already does, and use it in the three
providers that fetch from a second URL (DashScope, RunwayML, fal.ai). The
remaining providers accept it and read the response body, matching how they
already accept an unused client on status retrieve.

A FAILED or CANCELED task reported without a code or message fell past the
in-flight check into the 502 branch, so the proxy retried a task that can never
produce a video. Raise 400 for a terminal status instead.

* fix(dashscope): answer 404, not 409, when a Wan video is downloaded before it finishes

A download that lands while the task is still PENDING or RUNNING answered 409. That
reads as a client error, which was the intent, but litellm's retry predicate counts 409
as retryable, so the proxy polled DashScope twice more before giving up and then counted
the caller's own early download against the model's error rate

404 is what the OpenAI videos API documents for content that is not ready, and it is the
one status in that family the retry predicate leaves alone. The rest of the mapping is
unchanged: 400 for a task the provider reported as failed, 404 for an expired task id,
502 when a succeeded task carries no artifact

The download tests now drive avideo_content and assert the caller sees NotFoundError
rather than an internal server error

* fix(dashscope): route a video poll back to the model group that created the task

The create response encoded the upstream model name into the returned id. Both the
dashscope and qwen_ai_platform prefixes serve the same model names from their own hosts,
so a proxy with a deployment per region had deployments that only the model group told
apart. Encoding the bare model name resolved a poll to whichever deployment the router
lists first, which sent it to the other region's host with that region's key and a task
id that region never issued. That is the reroute the earlier proof records as the
dashscope and qwen_ai_platform poll ids coming back tagged qwencloud

The id now carries the model group the create was routed to, which is what the proxy
resolves a poll back to, and falls back to the model name for a direct SDK call that has
no router

The regression test drives a real router with one deployment per region, under a
different prefix and under the same one, and asserts the poll carries the key of the
region that created the task

* fix(dashscope): read the routed model group from litellm_metadata

The router stamps the model group it picked into litellm_metadata. metadata only
carries a copy when the caller sent no metadata of its own, and every proxy request
sends some, so a proxied create still encoded the bare model name and the poll
resolved to whichever region's deployment the router lists first

The regression test now also creates the video with caller metadata, which fails
without this change

* fix(dashscope): reject unbillable video requests and keep fal and runway on their own clients

* fix(dashscope): show the caller why a video task is gone or not ready instead of the bare upstream body

* fix(dashscope): check video request values before the billed create and honor per-call drop_params

Type check documented DashScope parameters so a bad seed answers 400 instead of a billed failed task,
pass the call's and deployment's drop_params into the video mapping, answer 400 for an expired task id so
the deployment is not cooled down, read DASHSCOPE_API_BASE for video, keep polling SUSPENDED tasks, and
refuse to map size onto models litellm has no price for.

* fix(dashscope): carry the requested model name in video ids so alias and wildcard keys can poll

A key scoped to an alias or to a name a wildcard serves could create a
video and then got a 403 on the poll and the download, because the id
carried the group that served the call (wan3, qwencloud/*) and the key
was never given that name. The id now carries the name the router was
asked for on that hop, and the router resolves an alias, routing group
or wildcard name in an id back to itself when no deployment name or
provider model matches it first

* fix(dashscope): keep a routing group's video polls on the member that created the task

A routing group spans several model groups, so an id carrying the group's name
load-balanced its polls, and some left for another region's host and key with a
task id that region never issued. The id now carries the member that served the
task when the request named a routing group, and keeps the requested name for a
model group, an alias, or a wildcard match.

* fix(dashscope): carry the requested and served model in video ids so group-scoped keys poll in the creating region

* fix(proxy): pin a video's served group only after key and team aliases resolve

* test(integration): audit DashScope video create, poll, download, routing and chaos paths

* fix(proxy): keep catch-all video polls on their provider and count served polls against the requested group

* test(routing): expect a forged served group to be dropped from the polled video id

* test(integration): cover rate-limited routing group polls and catch-all polls of DashScope videos

---------

Co-authored-by: AaronHowell <237895480@qq.com>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:10:07 -07:00

725 lines
26 KiB
Python

"""
Routing-contract tests for litellm/proxy/video_endpoints/endpoints.py
Unlike the batches layer, every video endpoint funnels into a single downstream
seam - ProxyBaseLLMRequestProcessing.base_process_llm_request - so there is no
provider-dispatch to assert. All of the video-specific, regression-worthy logic
runs *before* that call, while the endpoint assembles the `data` dict. Each test
therefore locks four things:
1. ROUTE_TYPE - the exact route_type each endpoint forwards
(avideo_generation/status/content/edit). Swapping two would
silently route requests to the wrong handler.
2. DATA SHAPE - the entire `data` dict the processor is constructed with:
provider-precedence resolution, video_id passthrough/extraction,
model resolution from the decoded model_id, and file attachment.
3. RESULT - base_process_llm_request's return value is propagated untouched
(except where the endpoint transforms it).
4. OUTPUT SHAPE - video_content wraps raw bytes in a Response (video/mp4 +
Content-Disposition).
Only true I/O boundaries are mocked (the downstream processor call, request body
parsing, file->bytes conversion, the provider-from-request readers, and the
router's model-id resolver). The id decode helpers and get_custom_provider_from_data
run for real, so the data assertions reflect production exactly. base_process is
patched with autospec so the real __init__ still stores self.data (captured via the
mock's call args), and a brand-new kwarg added to this layer surfaces as a failure.
"""
from contextlib import ExitStack
from dataclasses import dataclass
from typing import Any, Dict, Optional
from unittest.mock import AsyncMock, MagicMock, patch
import orjson
import pytest
import litellm.proxy.proxy_server as proxy_server
import litellm.proxy.video_endpoints.endpoints as endpoints
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
from litellm.proxy.utils import ProxyLogging
from litellm.router import Router
from litellm.types.videos.utils import (
encode_character_id_with_provider,
encode_video_id_with_provider,
)
from fastapi import Response
from starlette.datastructures import UploadFile as StarletteUploadFile
# --------------------------------------------------------------------------- #
# A real model-encoded video id: decodes (for real) to provider "azure",
# model_id VIDEO_MODEL_ID, original video id "video_orig123". The router's
# resolver maps that model_id to a model name; an unknown id resolves to None,
# so a wrong/hardcoded model_id cannot produce a plausible-looking result.
# --------------------------------------------------------------------------- #
VIDEO_MODEL_ID = "deployment-123"
AZURE_VIDEO_ID = encode_video_id_with_provider("video_orig123", "azure", VIDEO_MODEL_ID)
# A real model-encoded character id: decodes to provider "azure", VIDEO_MODEL_ID,
# original character id "char_orig". Distinct from the video id so a test cannot
# pass by reusing the wrong constant.
AZURE_CHARACTER_ID = encode_character_id_with_provider(
"char_orig", "azure", VIDEO_MODEL_ID
)
RESOLVED_MODELS: Dict[str, str] = {VIDEO_MODEL_ID: "azure-sora"}
# Sentinel propagated by base_process for the passthrough endpoints.
SENTINEL = object()
class FakeRequest:
"""Minimal stand-in. headers/query_params are read by the provider readers
(mocked) and on the edit path the raw body is parsed for real via orjson."""
def __init__(
self,
headers: Optional[Dict[str, str]] = None,
query: Optional[Dict[str, str]] = None,
raw_body: bytes = b"{}",
):
self.headers = headers or {}
self.query_params = query or {}
self._raw_body = raw_body
async def body(self) -> bytes:
return self._raw_body
@dataclass
class Harness:
read_body: AsyncMock
batch_to_bytesio: AsyncMock
base_process: MagicMock
handle_exc: AsyncMock
provider_from_headers: MagicMock
provider_from_query: MagicMock
provider_from_body: AsyncMock
router: MagicMock
resolve_model: MagicMock
def processor_data(self) -> Dict[str, Any]:
"""The exact `data` dict the processor was constructed with."""
assert self.base_process.call_count == 1
return dict(self.base_process.call_args.args[0].data)
def route_type(self) -> str:
return self.base_process.call_args.kwargs["route_type"]
@pytest.fixture
def harness():
logging = MagicMock(spec=ProxyLogging)
router = MagicMock(spec=Router)
resolve_model = MagicMock(
side_effect=lambda model_id, custom_llm_provider=None: RESOLVED_MODELS.get(model_id)
)
router.resolve_model_name_from_model_id = resolve_model
read_body = AsyncMock(return_value={})
batch_to_bytesio = AsyncMock(return_value=[b"filebytes"])
handle_exc = AsyncMock(return_value=RuntimeError("handled"))
provider_from_headers = MagicMock(return_value=None)
provider_from_query = MagicMock(return_value=None)
provider_from_body = AsyncMock(return_value=None)
with ExitStack() as stack:
base_process = stack.enter_context(
patch.object(
ProxyBaseLLMRequestProcessing,
"base_process_llm_request",
autospec=True,
)
)
base_process.return_value = SENTINEL
stack.enter_context(
patch.object(
ProxyBaseLLMRequestProcessing,
"handle_llm_api_exception",
handle_exc,
)
)
stack.enter_context(patch.object(endpoints, "read_request_body", read_body))
stack.enter_context(
patch.object(endpoints, "batch_to_bytesio", batch_to_bytesio)
)
stack.enter_context(
patch.object(
endpoints,
"get_custom_llm_provider_from_request_headers",
provider_from_headers,
)
)
stack.enter_context(
patch.object(
endpoints,
"get_custom_llm_provider_from_request_query",
provider_from_query,
)
)
stack.enter_context(
patch.object(
endpoints,
"get_custom_llm_provider_from_request_body",
provider_from_body,
)
)
stack.enter_context(patch.object(proxy_server, "llm_router", router))
stack.enter_context(patch.object(proxy_server, "proxy_logging_obj", logging))
stack.enter_context(patch.object(proxy_server, "general_settings", {}))
stack.enter_context(patch.object(proxy_server, "proxy_config", MagicMock()))
stack.enter_context(
patch.object(proxy_server, "select_data_generator", MagicMock())
)
stack.enter_context(patch.object(proxy_server, "user_model", None))
stack.enter_context(patch.object(proxy_server, "user_temperature", None))
stack.enter_context(patch.object(proxy_server, "user_request_timeout", None))
stack.enter_context(patch.object(proxy_server, "user_max_tokens", None))
stack.enter_context(patch.object(proxy_server, "user_api_base", None))
stack.enter_context(patch.object(proxy_server, "version", "test-version"))
yield Harness(
read_body=read_body,
batch_to_bytesio=batch_to_bytesio,
base_process=base_process,
handle_exc=handle_exc,
provider_from_headers=provider_from_headers,
provider_from_query=provider_from_query,
provider_from_body=provider_from_body,
router=router,
resolve_model=resolve_model,
)
def _user() -> UserAPIKeyAuth:
return UserAPIKeyAuth(api_key="sk-test")
# =========================================================================== #
# POST /v1/videos - video_generation #
# =========================================================================== #
async def call_generation(
harness: Harness, *, body: Dict[str, Any], input_reference=None
):
harness.read_body.return_value = body
return await endpoints.video_generation(
request=FakeRequest(),
fastapi_response=Response(),
input_reference=input_reference,
user_api_key_dict=_user(),
)
@pytest.mark.asyncio
async def test_generation__route_type_data_and_no_provider_default(harness):
body = {"model": "sora-2", "prompt": "a sunset"}
resp = await call_generation(harness, body=body)
assert resp is SENTINEL
assert harness.route_type() == "avideo_generation"
# generation does NOT resolve a provider; data is the body, untouched. A
# future default custom_llm_provider injection would break this row.
assert harness.processor_data() == {"model": "sora-2", "prompt": "a sunset"}
harness.batch_to_bytesio.assert_not_called()
@pytest.mark.asyncio
async def test_generation__input_reference_attached(harness):
body = {"model": "sora-2", "prompt": "a sunset"}
upload = MagicMock(name="upload_file")
await call_generation(harness, body=body, input_reference=upload)
harness.batch_to_bytesio.assert_called_once_with([upload])
assert harness.processor_data() == {
"model": "sora-2",
"prompt": "a sunset",
"input_reference": b"filebytes",
}
@pytest.mark.asyncio
async def test_generation__exception_routed_through_handler(harness):
harness.base_process.side_effect = ValueError("provider boom")
with pytest.raises(RuntimeError, match="handled"):
await call_generation(harness, body={"model": "sora-2"})
harness.handle_exc.assert_called_once()
assert harness.handle_exc.call_args.kwargs["e"].args[0] == "provider boom"
# =========================================================================== #
# GET /v1/videos/{video_id} - video_status #
# =========================================================================== #
async def call_status(harness: Harness, video_id: str, *, headers=None, query=None):
return await endpoints.video_status(
video_id=video_id,
request=FakeRequest(headers=headers, query=query),
fastapi_response=Response(),
user_api_key_dict=_user(),
)
@pytest.mark.asyncio
async def test_status__model_encoded_id_full_contract(harness):
resp = await call_status(harness, AZURE_VIDEO_ID)
assert resp is SENTINEL
assert harness.route_type() == "avideo_status"
# provider comes from the decoded id; model_id resolved to a model name.
harness.resolve_model.assert_called_once_with(VIDEO_MODEL_ID, "azure")
assert harness.processor_data() == {
"video_id": AZURE_VIDEO_ID,
"custom_llm_provider": "azure",
"model": "azure-sora",
}
@pytest.mark.asyncio
async def test_status__plain_id_defaults_to_openai(harness):
await call_status(harness, "video_plain")
# plain id -> nothing decoded, no header/query/body provider -> "openai".
harness.resolve_model.assert_not_called()
assert harness.processor_data() == {
"video_id": "video_plain",
"custom_llm_provider": "openai",
}
@pytest.mark.asyncio
async def test_status__header_provider_beats_decoded_id(harness):
harness.provider_from_headers.return_value = "bedrock"
await call_status(harness, AZURE_VIDEO_ID)
data = harness.processor_data()
# header wins over the provider decoded from the id ...
assert data["custom_llm_provider"] == "bedrock"
# ... but the model is still resolved from the decoded model_id.
assert data["model"] == "azure-sora"
# =========================================================================== #
# GET /v1/videos/{video_id}/content - video_content #
# =========================================================================== #
async def call_content(harness: Harness, video_id: str, *, headers=None, query=None):
return await endpoints.video_content(
video_id=video_id,
request=FakeRequest(headers=headers, query=query),
fastapi_response=Response(),
user_api_key_dict=_user(),
)
@pytest.mark.asyncio
async def test_content__wraps_raw_bytes_in_response(harness):
harness.base_process.return_value = b"VIDEOBYTES"
resp = await call_content(harness, "video_plain")
assert harness.route_type() == "avideo_content"
assert isinstance(resp, Response)
assert resp.body == b"VIDEOBYTES"
assert resp.media_type == "video/mp4"
assert (
resp.headers["content-disposition"]
== "attachment; filename=video_video_plain.mp4"
)
@pytest.mark.asyncio
async def test_content__plain_id_has_no_openai_default(harness):
"""The high-value asymmetry vs video_status: content stops at the decoded
provider and never injects an 'openai' default, so a plain id leaves
custom_llm_provider unset. A copy-paste of status' fallback breaks this."""
harness.base_process.return_value = b"x"
await call_content(harness, "video_plain")
assert harness.processor_data() == {"video_id": "video_plain"}
@pytest.mark.asyncio
async def test_content__model_encoded_id(harness):
harness.base_process.return_value = b"x"
await call_content(harness, AZURE_VIDEO_ID)
harness.resolve_model.assert_called_once_with(VIDEO_MODEL_ID, "azure")
assert harness.processor_data() == {
"video_id": AZURE_VIDEO_ID,
"custom_llm_provider": "azure",
"model": "azure-sora",
}
@pytest.mark.asyncio
@pytest.mark.parametrize("call", [call_status, call_content])
async def test_status_and_content__hand_the_served_member_to_the_request_processor(harness, call):
"""The id names the routing group a key may be scoped to, and the member that served the task, which is
the only one holding it. The processor pins the member once key and team aliases have been applied."""
router = Router(
model_list=[
{"model_name": name, "litellm_params": {"model": "dashscope/wan3.0-video", "api_key": "k"}}
for name in ("wan3-a", "wan3-b")
],
routing_groups=[{"group_name": "wan3", "models": ["wan3-a", "wan3-b"], "routing_strategy": "simple-shuffle"}],
)
harness.base_process.return_value = b"x"
video_id = encode_video_id_with_provider("task1", "dashscope", "wan3", served_model="wan3-b")
with patch.object(proxy_server, "llm_router", router):
await call(harness, video_id)
assert harness.processor_data()["model"] == "wan3"
assert harness.base_process.call_args.args[0].served_model == "wan3-b"
# =========================================================================== #
# POST /v1/videos/edits - video_edit #
# =========================================================================== #
async def call_edit(
harness: Harness, *, body: Dict[str, Any], headers=None, query=None
):
harness.read_body.return_value = dict(body)
return await endpoints.video_edit(
request=FakeRequest(headers=headers, query=query, raw_body=orjson.dumps(body)),
fastapi_response=Response(),
user_api_key_dict=_user(),
)
@pytest.mark.asyncio
async def test_edit__extracts_nested_video_id_full_contract(harness):
resp = await call_edit(
harness, body={"prompt": "brighter", "video": {"id": AZURE_VIDEO_ID}}
)
assert resp is SENTINEL
assert harness.route_type() == "avideo_edit"
harness.resolve_model.assert_called_once_with(VIDEO_MODEL_ID, "azure")
# nested video object is popped; its id becomes video_id; provider/model
# derived from the encoded id.
assert harness.processor_data() == {
"prompt": "brighter",
"video_id": AZURE_VIDEO_ID,
"custom_llm_provider": "azure",
"model": "azure-sora",
}
@pytest.mark.asyncio
async def test_edit__provider_from_body_data_for_plain_id(harness):
"""For a plain id, get_custom_provider_from_data (run for real) pulls the
provider out of the request body before the 'openai' default."""
await call_edit(
harness,
body={
"prompt": "x",
"video": {"id": "video_plain"},
"custom_llm_provider": "vertex_ai",
},
)
data = harness.processor_data()
assert data["video_id"] == "video_plain"
assert data["custom_llm_provider"] == "vertex_ai"
harness.resolve_model.assert_not_called()
@pytest.mark.asyncio
async def test_edit__missing_video_object_defaults_to_openai(harness):
await call_edit(harness, body={"prompt": "x"})
data = harness.processor_data()
# no video object -> empty video_id; plain -> default provider.
assert data["video_id"] == ""
assert data["custom_llm_provider"] == "openai"
assert "video" not in data
@pytest.mark.asyncio
async def test_edit__bare_string_video_id_from_form_field(harness):
await call_edit(harness, body={"prompt": "brighter", "video": "video_plain"})
assert harness.processor_data() == {
"prompt": "brighter",
"video_id": "video_plain",
"custom_llm_provider": "openai",
}
@pytest.mark.asyncio
async def test_edit__json_string_video_reference_from_form_field(harness):
await call_edit(
harness,
body={"prompt": "brighter", "video": orjson.dumps({"id": "video_plain"}).decode()},
)
assert harness.processor_data()["video_id"] == "video_plain"
@pytest.mark.asyncio
async def test_edit__uploaded_video_file_is_forwarded_not_dropped(harness):
"""A multipart-uploaded source video must be converted to bytes and attached
under ``video`` so the provider receives the file. Before the fix the upload
was popped, coerced to an empty ``video_id``, and silently dropped."""
import io
upload = StarletteUploadFile(file=io.BytesIO(b"rawmp4"), filename="clip.mp4")
harness.read_body.return_value = {"prompt": "make it nighttime", "video": upload}
await endpoints.video_edit(
request=FakeRequest(raw_body=b"multipart"),
fastapi_response=Response(),
user_api_key_dict=_user(),
)
harness.batch_to_bytesio.assert_called_once_with((upload,))
assert harness.processor_data() == {
"prompt": "make it nighttime",
"video": b"filebytes",
"video_id": "",
"custom_llm_provider": "openai",
}
# =========================================================================== #
# GET /v1/videos - video_list #
# =========================================================================== #
async def call_list(harness: Harness, *, headers=None, query=None):
return await endpoints.video_list(
request=FakeRequest(headers=headers, query=query),
fastapi_response=Response(),
user_api_key_dict=_user(),
)
@pytest.mark.asyncio
async def test_list__query_params_and_no_provider(harness):
resp = await call_list(harness, query={"limit": "5"})
assert resp is SENTINEL
assert harness.route_type() == "avideo_list"
# no provider anywhere -> custom_llm_provider stays absent (only set if truthy).
assert harness.processor_data() == {"query_params": {"limit": "5"}}
@pytest.mark.asyncio
async def test_list__provider_from_header(harness):
harness.provider_from_headers.return_value = "bedrock"
await call_list(harness)
assert harness.processor_data() == {
"query_params": {},
"custom_llm_provider": "bedrock",
}
# =========================================================================== #
# POST /v1/videos/{video_id}/remix - video_remix #
# =========================================================================== #
async def call_remix(
harness: Harness, video_id: str, *, body, headers=None, query=None
):
harness.read_body.return_value = dict(body)
return await endpoints.video_remix(
video_id=video_id,
request=FakeRequest(headers=headers, query=query, raw_body=orjson.dumps(body)),
fastapi_response=Response(),
user_api_key_dict=_user(),
)
@pytest.mark.asyncio
async def test_remix__model_encoded_id_full_contract(harness):
resp = await call_remix(harness, AZURE_VIDEO_ID, body={"prompt": "new colors"})
assert resp is SENTINEL
assert harness.route_type() == "avideo_remix"
harness.resolve_model.assert_called_once_with(VIDEO_MODEL_ID, "azure")
assert harness.processor_data() == {
"prompt": "new colors",
"video_id": AZURE_VIDEO_ID,
"custom_llm_provider": "azure",
"model": "azure-sora",
}
@pytest.mark.asyncio
async def test_remix__provider_from_body_data_not_request_body_reader(harness):
"""remix resolves the provider from data.get('custom_llm_provider'), never
from the async request-body reader (unlike status/get_character). Setting
that reader to a sentinel and asserting it is untouched locks the difference."""
harness.provider_from_body.return_value = "must-not-win"
await call_remix(
harness,
"video_plain",
body={"prompt": "x", "custom_llm_provider": "vertex_ai"},
)
harness.provider_from_body.assert_not_called()
data = harness.processor_data()
assert data["video_id"] == "video_plain"
assert data["custom_llm_provider"] == "vertex_ai"
@pytest.mark.asyncio
async def test_remix__plain_id_has_no_openai_default(harness):
await call_remix(harness, "video_plain", body={"prompt": "x"})
# like video_content, remix stops at provider_from_id with no 'openai' default.
assert harness.processor_data() == {"prompt": "x", "video_id": "video_plain"}
# =========================================================================== #
# POST /v1/videos/characters - video_create_character #
# =========================================================================== #
async def call_create_character(harness: Harness, *, body, video=None, name="my_char"):
harness.read_body.return_value = body
return await endpoints.video_create_character(
request=FakeRequest(),
fastapi_response=Response(),
video=video if video is not None else MagicMock(name="video_upload"),
name=name,
user_api_key_dict=_user(),
)
@pytest.mark.asyncio
async def test_create_character__video_attached_default_provider_no_encode(harness):
upload = MagicMock(name="video_upload")
resp = await call_create_character(harness, body={"prompt": "x"}, video=upload)
assert resp is SENTINEL
assert harness.route_type() == "avideo_create_character"
harness.batch_to_bytesio.assert_called_once_with([upload])
# no target_model_names -> no model injected, no id re-encoding.
assert harness.processor_data() == {
"prompt": "x",
"video": b"filebytes",
"custom_llm_provider": "openai",
}
@pytest.mark.asyncio
async def test_create_character__target_model_sets_model_and_encodes_id(harness):
harness.base_process.return_value = {"id": "char_raw"}
resp = await call_create_character(
harness,
body={"target_model_names": "azure-sora-model", "custom_llm_provider": "azure"},
)
data = harness.processor_data()
assert data["model"] == "azure-sora-model"
assert data["custom_llm_provider"] == "azure"
# response id re-encoded with the resolved provider + model for the round-trip.
assert resp["id"] == encode_character_id_with_provider(
"char_raw", "azure", "azure-sora-model"
)
# =========================================================================== #
# GET /v1/videos/characters/{character_id} - video_get_character #
# =========================================================================== #
async def call_get_character(
harness: Harness, character_id: str, *, headers=None, query=None
):
return await endpoints.video_get_character(
character_id=character_id,
request=FakeRequest(headers=headers, query=query),
fastapi_response=Response(),
user_api_key_dict=_user(),
)
@pytest.mark.asyncio
async def test_get_character__encoded_id_full_contract(harness):
harness.base_process.return_value = {"id": "char_raw2"}
resp = await call_get_character(harness, AZURE_CHARACTER_ID)
assert harness.route_type() == "avideo_get_character"
harness.resolve_model.assert_called_once_with(VIDEO_MODEL_ID, "azure")
# character_id decoded to its inner value; provider/model from the encoded id.
assert harness.processor_data() == {
"character_id": "char_orig",
"custom_llm_provider": "azure",
"model": "azure-sora",
}
# response id re-encoded for the client round-trip.
assert resp["id"] == encode_character_id_with_provider(
"char_raw2", "azure", VIDEO_MODEL_ID
)
@pytest.mark.asyncio
async def test_get_character__plain_id_defaults_openai_no_encode(harness):
harness.base_process.return_value = {"id": "char_raw3"}
resp = await call_get_character(harness, "char_plain")
harness.resolve_model.assert_not_called()
assert harness.processor_data() == {
"character_id": "char_plain",
"custom_llm_provider": "openai",
}
# id does not start with 'character_' -> returned untouched.
assert resp["id"] == "char_raw3"
# =========================================================================== #
# POST /v1/videos/extensions - video_extension #
# =========================================================================== #
async def call_extension(harness: Harness, *, body, headers=None, query=None):
harness.read_body.return_value = dict(body)
return await endpoints.video_extension(
request=FakeRequest(headers=headers, query=query, raw_body=orjson.dumps(body)),
fastapi_response=Response(),
user_api_key_dict=_user(),
)
@pytest.mark.asyncio
async def test_extension__extracts_nested_video_id_full_contract(harness):
resp = await call_extension(
harness, body={"prompt": "continue", "video": {"id": AZURE_VIDEO_ID}}
)
assert resp is SENTINEL
assert harness.route_type() == "avideo_extension"
harness.resolve_model.assert_called_once_with(VIDEO_MODEL_ID, "azure")
assert harness.processor_data() == {
"prompt": "continue",
"video_id": AZURE_VIDEO_ID,
"custom_llm_provider": "azure",
"model": "azure-sora",
}