Merge main and preserve multi-API engine and duplex guards
Signed-off-by: Sy03 <1370724210@qq.com>
This commit is contained in:
commit
daf91b4d7a
329 files changed
+36428
-35780
No files matched your search
@@ -25,6 +25,7 @@ from PIL import Image
|
||||
from pytest_mock import MockerFixture
|
||||
from vllm import envs
|
||||
|
||||
from vllm_omni.diffusion.data import DIFFUSION_REQUEST_LIFECYCLE_KEY, DIFFUSION_REQUEST_STARTED
|
||||
from vllm_omni.diffusion.utils.media_utils import mux_video_audio_bytes
|
||||
from vllm_omni.entrypoints.openai import api_server, video_api_utils
|
||||
from vllm_omni.entrypoints.openai.api_server import router
|
||||
@@ -58,6 +59,7 @@ class MockVideoResult:
|
||||
multimodal_output=None,
|
||||
stage_durations=None,
|
||||
peak_memory_mb=0.0,
|
||||
custom_output=None,
|
||||
):
|
||||
self.multimodal_output = dict(multimodal_output or {"video": videos})
|
||||
if audios is not None:
|
||||
@@ -66,6 +68,7 @@ class MockVideoResult:
|
||||
self.multimodal_output["audio_sample_rate"] = sample_rate
|
||||
self.stage_durations = stage_durations or {}
|
||||
self.peak_memory_mb = peak_memory_mb
|
||||
self.custom_output = custom_output or {}
|
||||
|
||||
|
||||
class FakeAsyncOmni:
|
||||
@@ -102,9 +105,17 @@ class FakeAsyncOmni:
|
||||
):
|
||||
self.captured_reference_video_bytes = [Path(item).read_bytes() for item in reference_videos]
|
||||
num_outputs = sampling_params_list[0].num_outputs_per_prompt
|
||||
if sampling_params_list[0].emit_request_lifecycle:
|
||||
yield MockVideoResult(
|
||||
[],
|
||||
custom_output={DIFFUSION_REQUEST_LIFECYCLE_KEY: DIFFUSION_REQUEST_STARTED},
|
||||
)
|
||||
videos = [object() for _ in range(num_outputs)]
|
||||
yield MockVideoResult(videos)
|
||||
|
||||
async def abort(self, request_id, *, timeout=None):
|
||||
del request_id, timeout
|
||||
|
||||
|
||||
def test_raw_and_base64_encoders_receive_persistent_converter(mocker: MockerFixture):
|
||||
engine = FakeAsyncOmni()
|
||||
@@ -224,9 +235,18 @@ class BlockingVideoHandler:
|
||||
self.stage_configs = stage_configs
|
||||
|
||||
async def generate_video_bytes(
|
||||
self, request, reference_id, *, reference_image=None, reference_video=None, reference_audio=None
|
||||
self,
|
||||
request,
|
||||
reference_id,
|
||||
*,
|
||||
reference_image=None,
|
||||
reference_video=None,
|
||||
reference_audio=None,
|
||||
on_started=None,
|
||||
):
|
||||
del request, reference_id, reference_image, reference_video, reference_audio
|
||||
if on_started is not None:
|
||||
await on_started()
|
||||
self.started.set()
|
||||
try:
|
||||
await asyncio.Future()
|
||||
@@ -234,6 +254,104 @@ class BlockingVideoHandler:
|
||||
self.cancelled.set()
|
||||
raise
|
||||
|
||||
async def abort_request(self, request_id):
|
||||
del request_id
|
||||
|
||||
|
||||
class HangingAbortHandler(BlockingVideoHandler):
|
||||
"""Engine abort never returns; DELETE must time out and still cancel."""
|
||||
|
||||
async def abort_request(self, request_id):
|
||||
del request_id
|
||||
await asyncio.sleep(30)
|
||||
|
||||
|
||||
class CompletingDuringAbortHandler(BlockingVideoHandler):
|
||||
"""Finishes and persists output while DELETE is still awaiting abort."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._finish = asyncio.Event()
|
||||
|
||||
async def generate_video_bytes(
|
||||
self,
|
||||
request,
|
||||
reference_id,
|
||||
*,
|
||||
reference_image=None,
|
||||
reference_video=None,
|
||||
reference_audio=None,
|
||||
on_started=None,
|
||||
):
|
||||
del request, reference_image, reference_video, reference_audio
|
||||
if on_started is not None:
|
||||
await on_started()
|
||||
self.started.set()
|
||||
await self._finish.wait()
|
||||
return b"late-complete", {}, 0.0, None
|
||||
|
||||
async def abort_request(self, request_id):
|
||||
self._finish.set()
|
||||
deadline = time.monotonic() + 2.0
|
||||
while time.monotonic() < deadline:
|
||||
job = await api_server.VIDEO_STORE.get(request_id)
|
||||
if job is not None and job.status is VideoGenerationStatus.COMPLETED and job.file_name is not None:
|
||||
return
|
||||
await asyncio.sleep(0.01)
|
||||
raise RuntimeError(f"video job {request_id} did not complete during abort")
|
||||
|
||||
|
||||
class SchedulerQueuedVideoHandler(BlockingVideoHandler):
|
||||
"""Parks after engine submission but before scheduler admission."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.admit = threading.Event()
|
||||
self.in_progress = threading.Event()
|
||||
|
||||
async def generate_video_bytes(
|
||||
self,
|
||||
request,
|
||||
reference_id,
|
||||
*,
|
||||
reference_image=None,
|
||||
reference_video=None,
|
||||
reference_audio=None,
|
||||
on_started=None,
|
||||
):
|
||||
del request, reference_id, reference_image, reference_video, reference_audio
|
||||
self.started.set()
|
||||
try:
|
||||
while not self.admit.is_set():
|
||||
await asyncio.sleep(0.01)
|
||||
if on_started is not None:
|
||||
await on_started()
|
||||
self.in_progress.set()
|
||||
await asyncio.Future()
|
||||
except asyncio.CancelledError:
|
||||
self.cancelled.set()
|
||||
raise
|
||||
|
||||
|
||||
class AbortTrackingOmni(FakeAsyncOmni):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.aborted: list[str] = []
|
||||
self.entered = threading.Event()
|
||||
|
||||
async def generate(self, prompt, request_id, sampling_params_list):
|
||||
del prompt, request_id, sampling_params_list
|
||||
self.entered.set()
|
||||
yield MockVideoResult(
|
||||
[],
|
||||
custom_output={DIFFUSION_REQUEST_LIFECYCLE_KEY: DIFFUSION_REQUEST_STARTED},
|
||||
)
|
||||
await asyncio.Future()
|
||||
|
||||
async def abort(self, request_id, *, timeout=None):
|
||||
assert timeout is not None
|
||||
self.aborted.append(request_id)
|
||||
|
||||
|
||||
class FakeServerSocket:
|
||||
def __init__(self):
|
||||
@@ -593,12 +711,12 @@ def test_i2v_video_generation_resizes_input_to_requested_dimensions(test_client,
|
||||
|
||||
def test_i2v_resize_policy_can_defer_to_pipeline(monkeypatch):
|
||||
engine = FakeAsyncOmni()
|
||||
engine.get_diffusion_od_config = lambda: SimpleNamespace(
|
||||
engine.get_diffusion_od_config = lambda: SimpleNamespace( # type: ignore[method-assign]
|
||||
model="org/model",
|
||||
model_class_name="ExamplePipeline",
|
||||
revision="pinned-revision",
|
||||
)
|
||||
captured = {}
|
||||
captured: dict[str, str | None] = {}
|
||||
|
||||
def fake_policy(model_class_name, *, model, revision=None):
|
||||
captured.update(
|
||||
@@ -626,6 +744,7 @@ def test_i2v_resize_policy_can_defer_to_pipeline(monkeypatch):
|
||||
)
|
||||
)
|
||||
|
||||
assert engine.captured_prompt is not None
|
||||
input_image = engine.captured_prompt["multi_modal_data"]["image"]
|
||||
assert isinstance(input_image, Image.Image)
|
||||
assert input_image.size == (48, 32)
|
||||
@@ -719,6 +838,7 @@ def test_video_generation_bridges_request_fields(generation_request, expected_nu
|
||||
|
||||
asyncio.run(handler._run_and_extract(generation_request, "field-bridge"))
|
||||
|
||||
assert engine.captured_sampling_params_list is not None
|
||||
sampling = engine.captured_sampling_params_list[0]
|
||||
# Top-level ``seconds`` bridges into extra_args["duration"]; num_frames is
|
||||
# passed through (or derived as seconds x fps when omitted). No private
|
||||
@@ -1504,7 +1624,7 @@ def test_video_generation_response_exposes_action_payload(mocker: MockerFixture)
|
||||
},
|
||||
)
|
||||
|
||||
engine.generate = _generate
|
||||
engine.generate = _generate # type: ignore[method-assign]
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.serving_video.encode_video_base64",
|
||||
return_value="encoded-video",
|
||||
@@ -2098,6 +2218,80 @@ def test_delete_in_progress_job_cancels_task_and_removes_metadata(test_client):
|
||||
assert retrieve_resp.status_code == 404
|
||||
|
||||
|
||||
def test_async_video_stays_queued_until_scheduler_admission(test_client):
|
||||
handler = SchedulerQueuedVideoHandler()
|
||||
test_client.app.state.openai_serving_video = handler
|
||||
|
||||
create_resp = test_client.post("/v1/videos", data={"prompt": "Queue this video"})
|
||||
assert create_resp.status_code == 200
|
||||
video_id = create_resp.json()["id"]
|
||||
assert handler.started.wait(timeout=2.0)
|
||||
|
||||
queued = test_client.get(f"/v1/videos/{video_id}")
|
||||
assert queued.status_code == 200
|
||||
assert queued.json()["status"] == VideoGenerationStatus.QUEUED.value
|
||||
|
||||
handler.admit.set()
|
||||
assert handler.in_progress.wait(timeout=2.0)
|
||||
in_progress = test_client.get(f"/v1/videos/{video_id}")
|
||||
assert in_progress.status_code == 200
|
||||
assert in_progress.json()["status"] == VideoGenerationStatus.IN_PROGRESS.value
|
||||
|
||||
assert test_client.delete(f"/v1/videos/{video_id}").status_code == 200
|
||||
|
||||
|
||||
def test_delete_times_out_engine_abort_and_still_cancels(test_client, monkeypatch):
|
||||
monkeypatch.setattr(api_server, "VIDEO_ABORT_TIMEOUT_S", 0.05)
|
||||
handler = HangingAbortHandler()
|
||||
test_client.app.state.openai_serving_video = handler
|
||||
|
||||
create_resp = test_client.post("/v1/videos", data={"prompt": "Hang abort"})
|
||||
assert create_resp.status_code == 200
|
||||
video_id = create_resp.json()["id"]
|
||||
assert handler.started.wait(timeout=2.0)
|
||||
|
||||
started = time.monotonic()
|
||||
delete_resp = test_client.delete(f"/v1/videos/{video_id}")
|
||||
assert time.monotonic() - started < 2.0
|
||||
assert delete_resp.status_code == 200
|
||||
assert handler.cancelled.wait(timeout=2.0)
|
||||
|
||||
|
||||
def test_delete_removes_artifact_if_job_completes_during_abort(test_client):
|
||||
handler = CompletingDuringAbortHandler()
|
||||
test_client.app.state.openai_serving_video = handler
|
||||
|
||||
create_resp = test_client.post("/v1/videos", data={"prompt": "Complete during delete"})
|
||||
assert create_resp.status_code == 200
|
||||
video_id = create_resp.json()["id"]
|
||||
assert handler.started.wait(timeout=2.0)
|
||||
|
||||
assert test_client.delete(f"/v1/videos/{video_id}").status_code == 200
|
||||
file_path = os.path.join(api_server.STORAGE_MANAGER.storage_path, video_id)
|
||||
assert not os.path.exists(file_path)
|
||||
assert asyncio.run(api_server.VIDEO_STORE.get(video_id)) is None
|
||||
|
||||
|
||||
def test_delete_aborts_engine_request_before_cancelling_task(test_client):
|
||||
engine = AbortTrackingOmni()
|
||||
test_client.app.state.openai_serving_video = OmniOpenAIServingVideo.for_diffusion(
|
||||
engine,
|
||||
model_name="Wan-AI/Wan2.2-T2V-A14B-Diffusers",
|
||||
)
|
||||
|
||||
create_resp = test_client.post("/v1/videos", data={"prompt": "Abort this video"})
|
||||
assert create_resp.status_code == 200
|
||||
video_id = create_resp.json()["id"]
|
||||
assert engine.entered.wait(timeout=2.0)
|
||||
_wait_for_status(test_client, video_id, VideoGenerationStatus.IN_PROGRESS.value)
|
||||
|
||||
delete_resp = test_client.delete(f"/v1/videos/{video_id}")
|
||||
assert delete_resp.status_code == 200
|
||||
assert delete_resp.json()["deleted"] is True
|
||||
_wait_until(lambda: engine.aborted == [video_id])
|
||||
assert asyncio.run(api_server.VIDEO_STORE.get(video_id)) is None
|
||||
|
||||
|
||||
def test_video_response_file_extension_is_robust():
|
||||
response = VideoResponse(model="test-model", prompt="Make something beautiful")
|
||||
assert response.file_extension == "mp4"
|
||||
|
||||
Reference in new issue
Block a user