Merge remote-tracking branch 'origin/main' into qwen-image-2.1
# Conflicts: # vllm_omni/diffusion/executor/multiproc_executor.py # vllm_omni/diffusion/offloader/sequential_backend.py
This commit is contained in:
commit
9174554e40
1332 files changed
+160051
-60504
No files matched your search
@@ -42,8 +42,12 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import signal
|
||||
from argparse import Namespace
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
@@ -59,11 +63,78 @@ from vllm_omni.entrypoints.serve.utils import errors as serve_errors
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeParallelConfig:
|
||||
_api_process_rank: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeVllmConfig:
|
||||
parallel_config: _FakeParallelConfig
|
||||
|
||||
|
||||
def test_omni_api_worker_sets_parent_death_signal_before_validation(mocker):
|
||||
args = _minimal_args(_omni_stage_client_configs=[{"stage_addresses": {}}])
|
||||
death_signal = mocker.patch.object(api_server, "set_death_signal")
|
||||
|
||||
with pytest.raises(RuntimeError, match="client configuration"):
|
||||
api_server.run_omni_api_server_worker_proc(
|
||||
"127.0.0.1:8000",
|
||||
mocker.Mock(),
|
||||
args,
|
||||
{"client_count": 2, "client_index": -1},
|
||||
)
|
||||
|
||||
death_signal.assert_called_once_with(signal.SIGTERM)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_omni_run_server_worker_forwards_client_config(monkeypatch):
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_build_async_omni(*_args, **kwargs):
|
||||
captured.update(kwargs)
|
||||
yield _FakeEngineClient(vllm_config=_FakeVllmConfig(_FakeParallelConfig(_api_process_rank=1)))
|
||||
|
||||
monkeypatch.setattr(api_server, "build_async_omni", fake_build_async_omni)
|
||||
monkeypatch.setattr(api_server, "build_openai_app", lambda *_args: FastAPI())
|
||||
monkeypatch.setattr(api_server, "omni_init_app_state", lambda *_args: asyncio.sleep(0))
|
||||
monkeypatch.setattr(api_server, "shutdown_unsupported_routes", lambda *_args: None)
|
||||
|
||||
async def fake_storage_start() -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(api_server.STORAGE_MANAGER, "start", fake_storage_start)
|
||||
|
||||
async def fake_serve_http(app, **_kwargs):
|
||||
assert app.state.api_server_count == 2
|
||||
task = asyncio.create_task(asyncio.sleep(0))
|
||||
await asyncio.sleep(0)
|
||||
return task
|
||||
|
||||
monkeypatch.setattr(api_server, "serve_http", fake_serve_http)
|
||||
|
||||
args = _minimal_args(model="dummy")
|
||||
config = {"client_count": 2, "client_index": 1, "stage_addresses": {}}
|
||||
|
||||
await api_server.omni_run_server_worker("127.0.0.1:8000", _FakeSocket(), args, config)
|
||||
assert captured["client_config"] is config
|
||||
|
||||
|
||||
# FastAPI/Starlette auto-adds HEAD on every GET route. Including HEAD here
|
||||
# would invent ("HEAD", path) entries for every GET and fail the current census.
|
||||
# TODO: extend this set and the expected census if @router.head / @router.options appear.
|
||||
_HTTP_METHODS = {"GET", "POST", "DELETE", "PUT", "PATCH"}
|
||||
|
||||
|
||||
def test_profiler_endpoints_detect_typed_stage_config():
|
||||
stage = SimpleNamespace(profiler_config=SimpleNamespace(profiler="torch"))
|
||||
|
||||
assert api_server._should_enable_profiler_endpoints([stage])
|
||||
|
||||
|
||||
# Exact Omni router census on main. Update intentionally when routes change.
|
||||
_EXPECTED_ROUTER_ROUTES = {
|
||||
("POST", "/v1/chat/completions"),
|
||||
@@ -80,6 +151,11 @@ _EXPECTED_ROUTER_ROUTES = {
|
||||
("WEBSOCKET", "/v1/realtime"),
|
||||
("WEBSOCKET", "/v1/realtime/robot/openpi"),
|
||||
("WEBSOCKET", "/v1/duplex"),
|
||||
("POST", "/v1/realtime/sessions"),
|
||||
("POST", "/v1/realtime/sessions/{session_id}/step"),
|
||||
("POST", "/v1/realtime/sessions/{session_id}/reset"),
|
||||
("POST", "/v1/realtime/sessions/{session_id}/close"),
|
||||
("GET", "/v1/realtime/sessions/{session_id}/status"),
|
||||
("GET", "/health"),
|
||||
("GET", "/v1/models"),
|
||||
("POST", "/v1/images/generations"),
|
||||
@@ -120,6 +196,7 @@ _DIFFUSION_APP_STATE_KEYS = {
|
||||
"openai_streaming_speech",
|
||||
"openai_streaming_video",
|
||||
"openai_serving_realtime_robot",
|
||||
"rl_rollout_serving",
|
||||
"enable_server_load_tracking",
|
||||
"server_load_metrics",
|
||||
}
|
||||
@@ -151,12 +228,14 @@ _MULTISTAGE_APP_STATE_KEYS = {
|
||||
"openai_serving_realtime",
|
||||
"openai_serving_video",
|
||||
"openai_serving_realtime_robot",
|
||||
"rl_rollout_serving",
|
||||
"enable_server_load_tracking",
|
||||
"server_load_metrics",
|
||||
}
|
||||
_MULTISTAGE_MUST_BE_NONE = {
|
||||
"openai_serving_duplex",
|
||||
"openai_serving_realtime_robot",
|
||||
"rl_rollout_serving",
|
||||
}
|
||||
_MULTISTAGE_MUST_BE_WIRED = _MULTISTAGE_APP_STATE_KEYS - _MULTISTAGE_MUST_BE_NONE
|
||||
|
||||
@@ -486,7 +565,7 @@ async def test_timestamp_middleware_stamps_http_and_passes_websocket(monkeypatch
|
||||
Fails if HTTP requests lose ``request_timestamp``, or WebSocket scopes
|
||||
start getting stamped / mutated incorrectly.
|
||||
"""
|
||||
captured: dict[str, object] = {}
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_build_openai_app(args, supported_tasks):
|
||||
return FastAPI()
|
||||
@@ -514,8 +593,8 @@ async def test_timestamp_middleware_stamps_http_and_passes_websocket(monkeypatch
|
||||
await api_server.omni_run_server_worker("127.0.0.1:8000", _FakeSocket(), _minimal_args())
|
||||
middleware = captured["served_app"]
|
||||
|
||||
http_scope = {"type": "http", "state": {}}
|
||||
ws_scope = {"type": "websocket"}
|
||||
http_scope: dict[str, Any] = {"type": "http", "state": {}}
|
||||
ws_scope: dict[str, Any] = {"type": "websocket"}
|
||||
|
||||
async def _receive():
|
||||
return {"type": "http.disconnect"}
|
||||
@@ -611,6 +690,48 @@ async def test_realtime_route_defaults_to_configured_duplex_handler(
|
||||
assert calls == [expected_handler]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/v1/duplex", "/v1/realtime", "/v1/realtime?duplex=1"])
|
||||
def test_multi_api_duplex_reconnect_rejected_on_each_worker(path: str) -> None:
|
||||
class SessionHandler:
|
||||
async def handle_realtime_session(self, _websocket) -> None:
|
||||
pytest.fail("Multi-API requests must not access process-local sessions")
|
||||
|
||||
class PendingWarmup:
|
||||
def is_set(self) -> bool:
|
||||
raise AssertionError("Multi-API rejection must not wait for duplex warmup")
|
||||
|
||||
# Separate apps represent workers with independent frontend registries.
|
||||
# Opening on A then reconnecting to B must reject at the same boundary.
|
||||
for worker_index in (0, 1):
|
||||
app = FastAPI()
|
||||
app.state.api_server_count = 2
|
||||
app.state.api_server_index = worker_index
|
||||
app.state.openai_serving_duplex = SessionHandler()
|
||||
app.state.duplex_warmup_done = PendingWarmup()
|
||||
app.include_router(api_server.router)
|
||||
with TestClient(app) as client, client.websocket_connect(path) as websocket:
|
||||
assert websocket.receive_json()["code"] == "multi_api_duplex_unsupported"
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
websocket.receive_text()
|
||||
assert exc.value.code == 1008
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/v1/duplex", "/v1/realtime?duplex=1"])
|
||||
def test_single_api_duplex_session_still_reaches_handler(path: str) -> None:
|
||||
class SessionHandler:
|
||||
async def handle_realtime_session(self, websocket) -> None:
|
||||
await websocket.accept()
|
||||
await websocket.send_json({"type": "session.created"})
|
||||
await websocket.close()
|
||||
|
||||
app = FastAPI()
|
||||
app.state.api_server_count = 1
|
||||
app.state.openai_serving_duplex = SessionHandler()
|
||||
app.include_router(api_server.router)
|
||||
with TestClient(app) as client, client.websocket_connect(path) as websocket:
|
||||
assert websocket.receive_json() == {"type": "session.created"}
|
||||
|
||||
|
||||
def test_health_without_engine_returns_stable_unhealthy_response() -> None:
|
||||
"""Lock ``/health`` with no engine initialized.
|
||||
|
||||
@@ -700,6 +821,38 @@ def test_speech_without_handler_preserves_not_found_http_error() -> None:
|
||||
assert exc_info.value.detail == "The model does not support Speech API"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_api_rejects_runtime_voice_upload() -> None:
|
||||
app = FastAPI()
|
||||
app.state.api_server_count = 2
|
||||
raw_request = _request_for(app, method="POST", path="/v1/audio/voices")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await api_server.upload_voice(
|
||||
raw_request,
|
||||
audio_sample=None,
|
||||
speaker_embedding=None,
|
||||
consent="consent-id",
|
||||
name="probe",
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 409
|
||||
assert "process-local frontend state" in exc_info.value.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_api_rejects_sleep_route() -> None:
|
||||
app = FastAPI()
|
||||
app.state.api_server_count = 2
|
||||
raw_request = _request_for(app, method="POST", path="/v1/omni/sleep")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await api_server.omni_sleep(api_server.OmniSleepRequest(stage_ids=[0]), raw_request)
|
||||
|
||||
assert exc_info.value.status_code == 409
|
||||
assert "process-local frontend state" in exc_info.value.detail
|
||||
|
||||
|
||||
def test_engine_error_json_response_includes_request_and_stage_fields(monkeypatch) -> None:
|
||||
"""Lock shared ``EngineGenerateError`` JSON fields used by Omni handlers.
|
||||
|
||||
@@ -768,11 +921,10 @@ async def test_pure_diffusion_app_state_key_snapshot(monkeypatch) -> None:
|
||||
engine = _FakeEngineClient(stage_configs=[stage])
|
||||
|
||||
def _for_diffusion_factory(label: str):
|
||||
@classmethod
|
||||
def _factory(cls, *args, **kwargs):
|
||||
return _marker(label)
|
||||
|
||||
return _factory
|
||||
return classmethod(_factory)
|
||||
|
||||
monkeypatch.setattr(api_server.OmniOpenAIServingChat, "for_diffusion", _for_diffusion_factory("chat"))
|
||||
monkeypatch.setattr(api_server.OmniOpenAIServingChatBatch, "for_diffusion", _for_diffusion_factory("chat_batch"))
|
||||
@@ -813,13 +965,11 @@ async def test_pure_diffusion_speech_forwards_media_access_args(monkeypatch) ->
|
||||
speech_kwargs = {}
|
||||
|
||||
def _for_diffusion_factory(label: str):
|
||||
@classmethod
|
||||
def _factory(cls, *args, **kwargs):
|
||||
return _marker(label)
|
||||
|
||||
return _factory
|
||||
return classmethod(_factory)
|
||||
|
||||
@classmethod
|
||||
def _speech_factory(cls, *args, **kwargs):
|
||||
speech_kwargs.update(kwargs)
|
||||
return _marker("speech")
|
||||
@@ -833,7 +983,7 @@ async def test_pure_diffusion_speech_forwards_media_access_args(monkeypatch) ->
|
||||
)
|
||||
monkeypatch.setattr(api_server.OmniOpenAIServingVideo, "for_diffusion", _for_diffusion_factory("video"))
|
||||
monkeypatch.setattr(api_server.OmniStreamingVideoOutputHandler, "__init__", lambda self, *a, **k: None)
|
||||
monkeypatch.setattr(api_server.OmniOpenAIServingSpeech, "for_diffusion", _speech_factory)
|
||||
monkeypatch.setattr(api_server.OmniOpenAIServingSpeech, "for_diffusion", classmethod(_speech_factory))
|
||||
monkeypatch.setattr(
|
||||
api_server.ServingRealtimeRobotOpenPI,
|
||||
"create_policy_server",
|
||||
@@ -912,7 +1062,6 @@ async def test_multistage_app_state_key_snapshot(monkeypatch) -> None:
|
||||
monkeypatch.setattr(api_server, "create_streaming_video_handler", lambda **_k: _marker("streaming_video"))
|
||||
monkeypatch.setattr(api_server, "OpenAIServingRealtime", _FakeCtor)
|
||||
monkeypatch.setattr(api_server, "OmniOpenAIServingVideo", _FakeCtor)
|
||||
monkeypatch.setattr(api_server, "should_enable_duplex_endpoint", lambda *_a, **_k: False)
|
||||
|
||||
state = State()
|
||||
await api_server.omni_init_app_state(engine, state, _minimal_args())
|
||||
@@ -923,3 +1072,57 @@ async def test_multistage_app_state_key_snapshot(monkeypatch) -> None:
|
||||
must_be_wired=_MULTISTAGE_MUST_BE_WIRED,
|
||||
must_be_none=_MULTISTAGE_MUST_BE_NONE,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [None, 0, -1, "2", True])
|
||||
def test_process_local_guard_rejects_unknown_topology(count):
|
||||
app = FastAPI()
|
||||
if count is not None:
|
||||
app.state.api_server_count = count
|
||||
# CLI args cannot substitute for missing/invalid actual runtime state.
|
||||
app.state.args = Namespace(api_server_count=1)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
api_server._reject_process_local_state_with_multiple_api_workers(_request_for(app), "Sleep")
|
||||
assert exc.value.status_code == 503
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"requested,config,expected",
|
||||
[(None, None, 1), (1, None, 1), (2, {"client_count": 2}, 2), (None, {"client_count": 2}, 2)],
|
||||
)
|
||||
def test_api_topology_resolved_from_launch(requested, config, expected):
|
||||
assert api_server._resolve_api_server_count(Namespace(api_server_count=requested), config) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("requested,config", [(2, None), (2, {}), (2, {"client_count": 1}), (2, {"client_count": "2"})])
|
||||
def test_api_topology_rejects_inconsistent_launch(requested, config):
|
||||
with pytest.raises(ValueError):
|
||||
api_server._resolve_api_server_count(Namespace(api_server_count=requested), config)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count,status", [(2, 409), (None, 503)])
|
||||
@pytest.mark.parametrize(
|
||||
"method,path",
|
||||
[
|
||||
("POST", "/v1/videos"),
|
||||
("GET", "/v1/videos"),
|
||||
("GET", "/v1/videos/job"),
|
||||
("GET", "/v1/videos/job/content"),
|
||||
("DELETE", "/v1/videos/job"),
|
||||
],
|
||||
)
|
||||
def test_video_lifecycle_guard_precedes_processing(monkeypatch, count, status, method, path):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app = FastAPI()
|
||||
if count is not None:
|
||||
app.state.api_server_count = count
|
||||
app.include_router(api_server.router)
|
||||
|
||||
async def forbidden_form():
|
||||
pytest.fail("video form processing must not run before topology rejection")
|
||||
|
||||
app.dependency_overrides[api_server._parse_video_form] = forbidden_form
|
||||
with TestClient(app) as client:
|
||||
response = client.request(method, path)
|
||||
assert response.status_code == status
|
||||
@@ -15,6 +15,7 @@ from types import SimpleNamespace
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.config.stage_config import StagePipelineConfig
|
||||
from vllm_omni.entrypoints.openai.serving_speech import (
|
||||
_AUDEX_NO_AUDIO_GUARD_MODEL_TYPES,
|
||||
OmniOpenAIServingSpeech,
|
||||
@@ -251,9 +252,28 @@ class TestAudexStageDetection:
|
||||
stage = serving._find_tts_stage()
|
||||
assert stage is not None and stage.engine_args.model_stage == "audex_thinker"
|
||||
|
||||
def test_typed_pipeline_detected_without_legacy_engine_args(self):
|
||||
serving = OmniOpenAIServingSpeech.__new__(OmniOpenAIServingSpeech)
|
||||
stages = [
|
||||
SimpleNamespace(
|
||||
stage_pipeline_config=StagePipelineConfig(stage_id=index, model_stage=model_stage),
|
||||
model_config=SimpleNamespace(model_arch=None),
|
||||
worker_type="generation",
|
||||
)
|
||||
for index, model_stage in enumerate(("audex_thinker", "audex_code2wav"))
|
||||
]
|
||||
serving.engine_client = SimpleNamespace(stage_configs=stages)
|
||||
|
||||
stage = serving._find_tts_stage()
|
||||
serving._tts_stage = stage
|
||||
|
||||
assert stage is stages[0]
|
||||
assert serving._detect_tts_model_type() == "audex"
|
||||
|
||||
|
||||
def test_guided_injection_sets_guided_temperature():
|
||||
"""Guided requests decode at the measured CFG temperature (0.05)."""
|
||||
|
||||
serving = TestAudexCfgInjection()._serving_with_fake_tokenizer()
|
||||
cond = TestAudexCfgInjection()._cond_prompt()
|
||||
params = SimpleNamespace(extra_args={"cfg_scale": 1.5}, temperature=0.1)
|
||||
@@ -265,6 +285,31 @@ def test_guided_injection_sets_guided_temperature():
|
||||
assert out.temperature == 0.1
|
||||
|
||||
|
||||
def test_typed_full_pipeline_uses_full_checkpoint_tokenizer(mocker):
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import SpeechServingContext
|
||||
|
||||
serving = OmniOpenAIServingSpeech.__new__(OmniOpenAIServingSpeech)
|
||||
serving._tts_stage = SimpleNamespace(
|
||||
stage_pipeline_config=StagePipelineConfig(stage_id=0, model_stage="audex_omni"),
|
||||
model_config=SimpleNamespace(model_arch=None),
|
||||
worker_type="ar",
|
||||
)
|
||||
serving.engine_client = SimpleNamespace(
|
||||
model_config=SimpleNamespace(model="/models/audex"),
|
||||
)
|
||||
ensure_snapshot = mocker.patch(
|
||||
"vllm_omni.model_executor.models.audex.checkpoint.ensure_audex_snapshot",
|
||||
return_value="/snapshots/audex",
|
||||
)
|
||||
from_pretrained = mocker.patch("transformers.AutoTokenizer.from_pretrained", return_value=object())
|
||||
|
||||
adapter = AudexAdapter(SpeechServingContext(server=serving, engine_client=serving.engine_client))
|
||||
adapter._get_tokenizer()
|
||||
|
||||
ensure_snapshot.assert_called_once_with("/models/audex", profile="full")
|
||||
from_pretrained.assert_called_once_with("/snapshots/audex/checkpoint_folder_full")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- TTA serving surface
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import resolve_adapter # noqa: E402
|
||||
|
||||
@@ -12,6 +12,8 @@ Covers:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from io import BytesIO
|
||||
|
||||
import numpy as np
|
||||
@@ -27,6 +29,8 @@ from vllm_omni.entrypoints.openai.protocol.audio import (
|
||||
SUPPORTED_CHAT_AUDIO_FORMATS,
|
||||
CreateAudio,
|
||||
)
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
from vllm_omni.outputs.mm_outputs import MultimodalCompletionOutput, MultimodalPayload
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
@@ -65,6 +69,13 @@ class TestCreateAudio:
|
||||
)
|
||||
response = mixin.create_audio(audio_obj)
|
||||
assert len(response.audio_data) > 0
|
||||
assert response.audio_metadata is not None
|
||||
assert response.audio_metadata.model_dump() == {
|
||||
"format": fmt,
|
||||
"sample_rate_hz": 24000,
|
||||
"frame_count": 24000,
|
||||
"channels": 1,
|
||||
}
|
||||
|
||||
def test_wav_magic_bytes(self, mixin, audio_tensor):
|
||||
audio_obj = CreateAudio(
|
||||
@@ -113,10 +124,9 @@ class TestCreateAudio:
|
||||
response = mixin.create_audio(audio_obj)
|
||||
assert response.audio_data[:4] == b"RIFF"
|
||||
assert response.media_type == "audio/wav"
|
||||
assert response.audio_metadata.format == "wav"
|
||||
|
||||
def test_base64_encoding(self, mixin, audio_tensor):
|
||||
import base64
|
||||
|
||||
audio_obj = CreateAudio(
|
||||
audio_tensor=audio_tensor,
|
||||
sample_rate=24000,
|
||||
@@ -128,21 +138,33 @@ class TestCreateAudio:
|
||||
decoded = base64.b64decode(response.audio_data)
|
||||
assert decoded[:4] == b"RIFF"
|
||||
|
||||
def test_resamples_wav_to_requested_output_rate(self, mixin, audio_tensor):
|
||||
@pytest.mark.parametrize(
|
||||
"channels,speed,frame_count",
|
||||
[(1, 1.0, 8000), (2, 2.0, 4000)],
|
||||
ids=["mono-resampled", "stereo-resampled-faster"],
|
||||
)
|
||||
def test_metadata_matches_transformed_waveform(self, mixin, audio_tensor, channels, speed, frame_count):
|
||||
response = mixin.create_audio(
|
||||
CreateAudio(
|
||||
audio_tensor=audio_tensor,
|
||||
audio_tensor=audio_tensor if channels == 1 else np.stack((audio_tensor, audio_tensor)),
|
||||
sample_rate=24000,
|
||||
output_sample_rate=8000,
|
||||
response_format="wav",
|
||||
speed=1.0,
|
||||
speed=speed,
|
||||
base64_encode=False,
|
||||
)
|
||||
)
|
||||
|
||||
with soundfile.SoundFile(BytesIO(response.audio_data)) as audio_file:
|
||||
assert audio_file.samplerate == 8000
|
||||
assert audio_file.frames == 8000
|
||||
assert audio_file.frames == frame_count
|
||||
assert audio_file.channels == channels
|
||||
assert response.audio_metadata.model_dump() == {
|
||||
"format": "wav",
|
||||
"sample_rate_hz": audio_file.samplerate,
|
||||
"frame_count": audio_file.frames,
|
||||
"channels": audio_file.channels,
|
||||
}
|
||||
|
||||
|
||||
class TestStreamingAudioResampler:
|
||||
@@ -262,3 +284,65 @@ class TestResolveAudioFormat:
|
||||
request = self._make_request({"format": fmt, "voice": "alloy"})
|
||||
result = serving_chat._resolve_audio_format(request)
|
||||
assert not isinstance(result, ErrorResponse), f"Format {fmt} should be accepted"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stream", [False, True])
|
||||
@pytest.mark.parametrize("fmt", ["pcm", "wav"])
|
||||
def test_chat_audio_metadata_survives_response_serialization(stream, fmt):
|
||||
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
|
||||
from vllm.entrypoints.serve.engine.protocol import ErrorResponse, UsageInfo
|
||||
|
||||
from vllm_omni.entrypoints.openai.protocol.chat_completion import (
|
||||
OmniChatCompletionResponse,
|
||||
OmniChatCompletionStreamResponse,
|
||||
)
|
||||
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
|
||||
|
||||
serving_chat = object.__new__(OmniOpenAIServingChat)
|
||||
output = MultimodalCompletionOutput(
|
||||
index=0,
|
||||
text="",
|
||||
token_ids=[],
|
||||
cumulative_logprob=None,
|
||||
logprobs=None,
|
||||
multimodal_output=MultimodalPayload.from_dict(
|
||||
{"audio": [torch.zeros(17), torch.zeros(23)], "sr": torch.tensor(22050)}
|
||||
),
|
||||
finish_reason="stop",
|
||||
stop_reason=None,
|
||||
)
|
||||
request = ChatCompletionRequest(
|
||||
model="test-model",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
audio={"format": fmt, "voice": "alloy"},
|
||||
)
|
||||
choices = serving_chat._create_audio_choice(
|
||||
OmniRequestOutput(outputs=[output], final_output_type="audio"), "assistant", request, stream=stream
|
||||
)
|
||||
assert not isinstance(choices, ErrorResponse)
|
||||
response_type = OmniChatCompletionStreamResponse if stream else OmniChatCompletionResponse
|
||||
response = response_type(
|
||||
id="chatcmpl-audio-metadata",
|
||||
created=0,
|
||||
model="test-model",
|
||||
choices=choices,
|
||||
usage=UsageInfo(prompt_tokens=0, completion_tokens=0, total_tokens=0),
|
||||
)
|
||||
|
||||
serialized = json.loads(response.model_dump_json(exclude_unset=True))
|
||||
choice = serialized["choices"][0]
|
||||
frame_count = 23 if stream else 40
|
||||
assert choice["audio_metadata"] == {
|
||||
"format": fmt,
|
||||
"sample_rate_hz": 22050,
|
||||
"frame_count": frame_count,
|
||||
"channels": 1,
|
||||
}
|
||||
audio = choice["delta"]["content"] if stream else choice["message"]["audio"]["data"]
|
||||
raw = base64.b64decode(audio)
|
||||
if fmt == "wav":
|
||||
with soundfile.SoundFile(BytesIO(raw)) as audio_file:
|
||||
assert audio_file.frames == frame_count
|
||||
assert audio_file.samplerate == 22050
|
||||
else:
|
||||
assert len(raw) == frame_count * 2
|
||||
@@ -0,0 +1,520 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""CPU tests for serving-mode selection, duplex API wiring and warmup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from argparse import Namespace
|
||||
from dataclasses import replace
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.datastructures import State
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from vllm_omni.config import stage_config
|
||||
from vllm_omni.config.config_factory import StageConfigFactory
|
||||
from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig, PipelineConfig, StagePipelineConfig
|
||||
from vllm_omni.engine.duplex.config import DuplexCapabilities
|
||||
from vllm_omni.entrypoints.duplex.serving import OmniDuplexSessionHandler
|
||||
from vllm_omni.entrypoints.duplex_omni import DuplexOmni
|
||||
from vllm_omni.entrypoints.openai import api_server
|
||||
from vllm_omni.utils.tracking_parser import TrackingNamespace
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
# ``app.state`` of a duplex-only server. Presence is not enough: a route whose
|
||||
# handler is missing answers "not available", so "not wired" is written as None.
|
||||
_DUPLEX_APP_STATE_KEYS = {
|
||||
"engine_client",
|
||||
"log_stats",
|
||||
"args",
|
||||
"sleeping_stages",
|
||||
"stage_configs",
|
||||
"vllm_config",
|
||||
"diffusion_engine",
|
||||
"openai_serving_models",
|
||||
"serving_tokenization",
|
||||
"serving_tokens",
|
||||
"online_renderer",
|
||||
"openai_serving_chat",
|
||||
"openai_serving_chat_batch",
|
||||
"openai_serving_completion",
|
||||
"openai_serving_responses",
|
||||
"openai_serving_embedding",
|
||||
"openai_serving_pooling",
|
||||
"openai_serving_classification",
|
||||
"openai_serving_scores",
|
||||
"openai_serving_transcription",
|
||||
"openai_serving_translation",
|
||||
"openai_serving_speech",
|
||||
"openai_serving_audio_generate",
|
||||
"openai_serving_video",
|
||||
"openai_streaming_speech",
|
||||
"openai_streaming_video",
|
||||
"openai_streaming_video_output",
|
||||
"openai_serving_realtime",
|
||||
"openai_serving_realtime_robot",
|
||||
"anthropic_serving_messages",
|
||||
"openai_serving_duplex",
|
||||
"enable_server_load_tracking",
|
||||
"server_load_metrics",
|
||||
}
|
||||
#: Every turn-based service, plus the Realtime route that is not the duplex one.
|
||||
#: ``openai_serving_chat`` is absent: a duplex engine also serves chat, through
|
||||
#: the ordinary turn-based service, because DuplexOmni extends AsyncOmni.
|
||||
#: ``online_renderer`` is absent for the same reason -- the chat service needs it.
|
||||
_DUPLEX_MUST_BE_NONE = _DUPLEX_APP_STATE_KEYS - {
|
||||
"engine_client",
|
||||
"log_stats",
|
||||
"args",
|
||||
"sleeping_stages",
|
||||
"stage_configs",
|
||||
"vllm_config",
|
||||
"openai_serving_models",
|
||||
"openai_serving_duplex",
|
||||
"openai_serving_chat",
|
||||
"online_renderer",
|
||||
"enable_server_load_tracking",
|
||||
"server_load_metrics",
|
||||
}
|
||||
_DUPLEX_MUST_BE_WIRED = {
|
||||
"engine_client",
|
||||
"args",
|
||||
"stage_configs",
|
||||
"vllm_config",
|
||||
"openai_serving_models",
|
||||
"openai_serving_duplex",
|
||||
"openai_serving_chat",
|
||||
}
|
||||
|
||||
|
||||
class _FakeDuplexOmni(DuplexOmni):
|
||||
"""A ``DuplexOmni`` without an engine: only what the app-state wiring reads."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.model = "demo-duplex-model"
|
||||
self._stage_configs = [object(), object(), object()]
|
||||
self._vllm_config = SimpleNamespace(
|
||||
lora_config=None,
|
||||
model_config=SimpleNamespace(),
|
||||
parallel_config=SimpleNamespace(_api_process_rank=0),
|
||||
)
|
||||
self._duplex_session_config = DuplexSessionRuntimeConfig()
|
||||
self._capabilities = DuplexCapabilities(supports_chat_completions=True)
|
||||
|
||||
async def get_vllm_config(self):
|
||||
return self._vllm_config
|
||||
|
||||
async def get_supported_tasks(self) -> tuple[str, ...]:
|
||||
return ("generate",)
|
||||
|
||||
async def get_tokenizer(self):
|
||||
return SimpleNamespace(chat_template="{{ messages }}")
|
||||
|
||||
@property
|
||||
def stage_configs(self) -> list[object]:
|
||||
return self._stage_configs
|
||||
|
||||
@property
|
||||
def duplex_session_config(self) -> DuplexSessionRuntimeConfig:
|
||||
return self._duplex_session_config
|
||||
|
||||
@property
|
||||
def duplex_capabilities(self) -> DuplexCapabilities:
|
||||
return self._capabilities
|
||||
|
||||
# Properties on AsyncOmni, so the fake overrides rather than assigns them.
|
||||
@property
|
||||
def model_config(self) -> SimpleNamespace:
|
||||
return SimpleNamespace()
|
||||
|
||||
@property
|
||||
def renderer(self) -> SimpleNamespace:
|
||||
return SimpleNamespace()
|
||||
|
||||
|
||||
class _FakeModels:
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self.base_model_paths = kwargs.get("base_model_paths") or []
|
||||
|
||||
|
||||
def _minimal_args(**overrides) -> SimpleNamespace:
|
||||
args = SimpleNamespace(
|
||||
model="demo-duplex-model",
|
||||
served_model_name=None,
|
||||
disable_log_stats=True,
|
||||
enable_log_requests=False,
|
||||
max_log_len=None,
|
||||
enable_server_load_tracking=False,
|
||||
trust_remote_code=True,
|
||||
deploy_config=None,
|
||||
# What the chat service reads when a duplex model also serves chat.
|
||||
chat_template=None,
|
||||
chat_template_content_format="auto",
|
||||
trust_request_chat_template=False,
|
||||
default_chat_template_kwargs=None,
|
||||
response_role="assistant",
|
||||
return_tokens_as_token_ids=False,
|
||||
enable_auto_tool_choice=False,
|
||||
exclude_tools_when_tool_choice_none=False,
|
||||
tool_call_parser=None,
|
||||
structured_outputs_config=SimpleNamespace(reasoning_parser=None),
|
||||
enable_prompt_tokens_details=False,
|
||||
enable_force_include_usage=False,
|
||||
enable_log_outputs=False,
|
||||
enable_log_deltas=False,
|
||||
)
|
||||
for key, value in overrides.items():
|
||||
setattr(args, key, value)
|
||||
return args
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Startup: which entrypoint class serves the model #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pipeline_config", "expected"),
|
||||
[
|
||||
(SimpleNamespace(duplex_plugin="pkg.mod.Plugin"), True),
|
||||
(SimpleNamespace(duplex_plugin=None), False),
|
||||
(SimpleNamespace(duplex_plugin=""), False),
|
||||
(SimpleNamespace(), False),
|
||||
(None, False),
|
||||
],
|
||||
)
|
||||
def test_duplex_serving_requires_a_pipeline_plugin(monkeypatch, tmp_path, pipeline_config, expected: bool) -> None:
|
||||
seen: dict[str, object] = {}
|
||||
deploy_path = tmp_path / "deploy.yaml"
|
||||
deploy_path.write_text("session_mode: duplex\n")
|
||||
|
||||
def fake_get_pipeline_config(*, model, trust_remote_code, deploy_config_path=None, **_kwargs):
|
||||
seen.update(model=model, trust_remote_code=trust_remote_code, deploy_config_path=deploy_config_path)
|
||||
return pipeline_config
|
||||
|
||||
monkeypatch.setattr(StageConfigFactory, "get_pipeline_config", fake_get_pipeline_config)
|
||||
|
||||
kwargs = {"trust_remote_code": True, "deploy_config": str(deploy_path)}
|
||||
assert api_server._should_serve_duplex("demo-duplex-model", kwargs) is expected
|
||||
assert seen == {
|
||||
"model": "demo-duplex-model",
|
||||
"trust_remote_code": True,
|
||||
"deploy_config_path": str(deploy_path),
|
||||
}
|
||||
|
||||
|
||||
def test_duplex_model_probe_propagates_resolution_errors(monkeypatch) -> None:
|
||||
"""A pipeline that cannot be resolved must fail startup, not fall back to turn-based."""
|
||||
|
||||
def fake_get_pipeline_config(**_kwargs):
|
||||
raise ValueError("needs trust_remote_code=True")
|
||||
|
||||
monkeypatch.setattr(StageConfigFactory, "get_pipeline_config", fake_get_pipeline_config)
|
||||
|
||||
with pytest.raises(ValueError, match="trust_remote_code"):
|
||||
api_server._should_serve_duplex("demo-duplex-model", {})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def duplex_pipeline():
|
||||
return PipelineConfig(
|
||||
model_type="demo",
|
||||
stages=(StagePipelineConfig(stage_id=0, model_stage="demo", final_output=True),),
|
||||
duplex_plugin="pkg.mod.Plugin",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("session_mode", ["turn", "duplex"])
|
||||
@pytest.mark.parametrize("config_source", ["explicit", "default", "basename", "inherited"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_serving_mode_selects_engine_and_preserves_stage_config(
|
||||
monkeypatch, tmp_path, duplex_pipeline, session_mode, config_source
|
||||
):
|
||||
base_path = tmp_path / "base.yaml"
|
||||
base_path.write_text("session_mode: duplex\nstages:\n - stage_id: 0\n max_num_seqs: 4\n")
|
||||
deploy_path = tmp_path / "deploy.yaml"
|
||||
deploy_path.write_text(f"base_config: base.yaml\nsession_mode: {session_mode}\n")
|
||||
if config_source == "inherited":
|
||||
deploy_path = tmp_path / "overlay.yaml"
|
||||
deploy_path.write_text("base_config: deploy.yaml\n")
|
||||
pipeline = replace(duplex_pipeline, default_deploy_config_name=deploy_path.name)
|
||||
monkeypatch.setattr(StageConfigFactory, "get_pipeline_config", lambda **_: pipeline)
|
||||
monkeypatch.setattr(stage_config, "_DEPLOY_DIR", tmp_path)
|
||||
kwargs = {}
|
||||
if config_source != "default":
|
||||
kwargs["deploy_config"] = deploy_path.name if config_source == "basename" else str(deploy_path)
|
||||
|
||||
created = []
|
||||
|
||||
class TurnEngine:
|
||||
def __init__(self, model, **engine_kwargs):
|
||||
self.model = model
|
||||
self.kwargs = engine_kwargs
|
||||
self.closed = False
|
||||
created.append(self)
|
||||
|
||||
def shutdown(self):
|
||||
self.closed = True
|
||||
|
||||
class DuplexEngine(TurnEngine):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(api_server, "AsyncOmni", TurnEngine)
|
||||
monkeypatch.setattr(api_server, "DuplexOmni", DuplexEngine)
|
||||
args = TrackingNamespace(
|
||||
Namespace(model="demo-model", disable_log_stats=True, **kwargs), frozenset({"model", *kwargs})
|
||||
)
|
||||
async with api_server.build_async_omni_from_stage_config(args) as engine:
|
||||
assert type(engine) is (DuplexEngine if session_mode == "duplex" else TurnEngine)
|
||||
assert engine.model == "demo-model"
|
||||
assert engine.kwargs == {**kwargs, "log_stats": False}
|
||||
assert not engine.closed
|
||||
assert len(created) == 1
|
||||
assert engine.closed
|
||||
deploy = stage_config.load_deploy_config(deploy_path)
|
||||
assert deploy.session_mode == session_mode
|
||||
assert deploy.stages[0].max_num_seqs == 4
|
||||
|
||||
|
||||
@pytest.mark.parametrize("contents", ["{}\n", "session_mode: null\n", "session_mode: typo\n"])
|
||||
def test_duplex_serving_rejects_missing_or_invalid_mode(monkeypatch, tmp_path, duplex_pipeline, contents):
|
||||
deploy_path = tmp_path / "deploy.yaml"
|
||||
deploy_path.write_text(contents)
|
||||
monkeypatch.setattr(StageConfigFactory, "get_pipeline_config", lambda **_: duplex_pipeline)
|
||||
with pytest.raises(ValueError, match="session_mode: turn or duplex"):
|
||||
api_server._should_serve_duplex("demo-model", {"deploy_config": str(deploy_path)})
|
||||
|
||||
|
||||
def test_duplex_serving_requires_a_deploy_config(monkeypatch, duplex_pipeline):
|
||||
monkeypatch.setattr(StageConfigFactory, "get_pipeline_config", lambda **_: duplex_pipeline)
|
||||
with pytest.raises(ValueError, match="requires a deploy config"):
|
||||
api_server._should_serve_duplex("demo-model", {})
|
||||
|
||||
|
||||
def test_duplex_serving_propagates_deploy_load_errors(monkeypatch, tmp_path, duplex_pipeline):
|
||||
monkeypatch.setattr(StageConfigFactory, "get_pipeline_config", lambda **_: duplex_pipeline)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
api_server._should_serve_duplex("demo-model", {"deploy_config": str(tmp_path / "missing.yaml")})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("deploy_name,expected", [("minicpmo_4_5.yaml", True), ("minicpmo_4_5_turn.yaml", False)])
|
||||
def test_minicpmo_serving_profiles(monkeypatch, deploy_name, expected):
|
||||
from vllm_omni.model_executor.models.minicpmo_4_5.pipeline import MINICPMO_4_5_PIPELINE
|
||||
|
||||
monkeypatch.setattr(StageConfigFactory, "get_pipeline_config", lambda **_: MINICPMO_4_5_PIPELINE)
|
||||
assert api_server._should_serve_duplex("openbmb/MiniCPM-o-4_5", {"deploy_config": deploy_name}) is expected
|
||||
assert api_server._should_serve_duplex("openbmb/MiniCPM-o-4_5", {}) is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# app.state of a duplex-only server #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplex_app_state_wires_only_the_session_surfaces(monkeypatch) -> None:
|
||||
"""Lock the duplex ``app.state``: the two session-backed surfaces are live, every turn-based service is None.
|
||||
|
||||
Fails if a turn-based service is wired into a duplex server (its route
|
||||
would answer instead of reporting "not available"), or if a key the routes
|
||||
read disappears. ``openai_serving_chat`` is the one shared name: the route
|
||||
is served, but by the adapter that runs it on a duplex session, never by
|
||||
the turn-based chat service.
|
||||
"""
|
||||
monkeypatch.setattr(api_server, "OpenAIServingModels", _FakeModels)
|
||||
monkeypatch.setattr(api_server, "OnlineRenderer", lambda **kwargs: SimpleNamespace(**kwargs))
|
||||
monkeypatch.setattr(api_server, "OmniOpenAIServingChat", lambda **kwargs: SimpleNamespace(**kwargs))
|
||||
engine = _FakeDuplexOmni()
|
||||
state = State()
|
||||
|
||||
await api_server.omni_init_app_state(engine, state, _minimal_args())
|
||||
|
||||
present = {key for key in _DUPLEX_APP_STATE_KEYS if hasattr(state, key)}
|
||||
assert present == _DUPLEX_APP_STATE_KEYS
|
||||
not_wired = sorted(key for key in _DUPLEX_MUST_BE_WIRED if getattr(state, key) is None)
|
||||
assert not not_wired, f"duplex app.state keys registered but not wired: {not_wired}"
|
||||
unexpectedly_set = sorted(key for key in _DUPLEX_MUST_BE_NONE if getattr(state, key) is not None)
|
||||
assert not unexpectedly_set, f"turn-based services wired into a duplex server: {unexpectedly_set}"
|
||||
assert isinstance(state.openai_serving_duplex, OmniDuplexSessionHandler)
|
||||
assert state.openai_serving_chat is not None
|
||||
assert state.engine_client is engine
|
||||
assert state.vllm_config is engine._vllm_config
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Routes #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class _RecordingHandler:
|
||||
"""Stands in for ``OmniDuplexSessionHandler`` on the app state."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.queries: list[dict[str, str]] = []
|
||||
|
||||
async def handle_realtime_session(self, websocket) -> None:
|
||||
await websocket.accept()
|
||||
self.queries.append(dict(websocket.query_params))
|
||||
await websocket.send_json({"type": "session.created", "session": {"id": "duplex-test"}})
|
||||
await websocket.close()
|
||||
|
||||
|
||||
def _duplex_app(handler: object) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(api_server.router)
|
||||
app.state.openai_serving_duplex = handler
|
||||
# A duplex server serves no turn-based Realtime route.
|
||||
app.state.openai_serving_realtime = None
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/v1/duplex", "/v1/realtime?duplex=1"])
|
||||
def test_both_duplex_routes_reach_the_session_handler(path: str) -> None:
|
||||
"""``/v1/duplex`` is an alias of ``/v1/realtime?duplex=1``: same handler, same protocol."""
|
||||
handler = _RecordingHandler()
|
||||
|
||||
with TestClient(_duplex_app(handler)) as client:
|
||||
with client.websocket_connect(path) as websocket:
|
||||
assert websocket.receive_json()["type"] == "session.created"
|
||||
with pytest.raises(WebSocketDisconnect):
|
||||
websocket.receive_text()
|
||||
|
||||
assert len(handler.queries) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", ["1", "true", "on"])
|
||||
def test_realtime_route_switches_to_duplex_only_for_the_documented_flag_values(flag: str) -> None:
|
||||
handler = _RecordingHandler()
|
||||
|
||||
with TestClient(_duplex_app(handler)) as client:
|
||||
with client.websocket_connect(f"/v1/realtime?duplex={flag}") as websocket:
|
||||
assert websocket.receive_json()["type"] == "session.created"
|
||||
with pytest.raises(WebSocketDisconnect):
|
||||
websocket.receive_text()
|
||||
|
||||
assert handler.queries == [{"duplex": flag}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query", ["", "?model=openbmb%2FMiniCPM-o-4_5"])
|
||||
def test_realtime_without_a_duplex_flag_reaches_the_session_handler(query: str) -> None:
|
||||
"""A bare connection to a duplex deployment is a duplex session.
|
||||
|
||||
``?duplex=1`` is the spelling the server advertises, but it is not a
|
||||
requirement: a deployment that declares a duplex plugin answers plain
|
||||
``/v1/realtime`` too, so a stock Realtime client needs no vendor query
|
||||
parameter.
|
||||
"""
|
||||
handler = _RecordingHandler()
|
||||
|
||||
with TestClient(_duplex_app(handler)) as client:
|
||||
with client.websocket_connect(f"/v1/realtime{query}") as websocket:
|
||||
assert websocket.receive_json()["type"] == "session.created"
|
||||
with pytest.raises(WebSocketDisconnect):
|
||||
websocket.receive_text()
|
||||
|
||||
assert len(handler.queries) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query", ["?duplex=0", "?duplex=false", "?duplex=off"])
|
||||
def test_realtime_opted_out_of_duplex_falls_through_to_the_turn_based_route(query: str) -> None:
|
||||
"""An explicit opt-out selects the legacy handler, which a duplex server does not mount."""
|
||||
handler = _RecordingHandler()
|
||||
|
||||
with TestClient(_duplex_app(handler)) as client:
|
||||
with client.websocket_connect(f"/v1/realtime{query}") as websocket:
|
||||
assert websocket.receive_json() == {
|
||||
"type": "error",
|
||||
"error": "Realtime API is not available",
|
||||
"code": "unsupported",
|
||||
}
|
||||
with pytest.raises(WebSocketDisconnect):
|
||||
websocket.receive_text()
|
||||
|
||||
assert handler.queries == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Startup warmup gate #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _warmup_websocket(warmup_done: asyncio.Event | None, query: dict[str, str]) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
app=SimpleNamespace(state=SimpleNamespace(duplex_warmup_done=warmup_done)),
|
||||
query_params=query,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_warmup_gate_holds_clients_until_the_warmup_session_finishes() -> None:
|
||||
warmup_done = asyncio.Event()
|
||||
websocket = _warmup_websocket(warmup_done, {})
|
||||
|
||||
waiting = asyncio.create_task(api_server._wait_for_duplex_warmup(websocket))
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(asyncio.shield(waiting), timeout=0.05)
|
||||
|
||||
warmup_done.set()
|
||||
await asyncio.wait_for(waiting, timeout=1.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("warmup_done", "query"),
|
||||
[
|
||||
(None, {}),
|
||||
(asyncio.Event(), {"vllm_omni_warmup": "1"}),
|
||||
],
|
||||
ids=["no-warmup-configured", "the-warmup-connection-itself"],
|
||||
)
|
||||
async def test_warmup_gate_lets_the_warmup_connection_and_plain_servers_through(warmup_done, query) -> None:
|
||||
websocket = _warmup_websocket(warmup_done, query)
|
||||
|
||||
await asyncio.wait_for(api_server._wait_for_duplex_warmup(websocket), timeout=1.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_model_that_does_not_declare_chat_completions_does_not_get_the_route(monkeypatch) -> None:
|
||||
"""The decision stays the model's: no capability, no chat service, and the route says so."""
|
||||
monkeypatch.setattr(api_server, "OpenAIServingModels", _FakeModels)
|
||||
engine = _FakeDuplexOmni()
|
||||
engine._capabilities = DuplexCapabilities(supports_chat_completions=False)
|
||||
state = State()
|
||||
|
||||
await api_server.omni_init_app_state(engine, state, _minimal_args())
|
||||
|
||||
assert state.openai_serving_chat is None
|
||||
assert isinstance(state.openai_serving_duplex, OmniDuplexSessionHandler)
|
||||
|
||||
|
||||
def test_qwen_plugin_preserves_custom_turn_deployment_default(monkeypatch, tmp_path):
|
||||
from vllm_omni.model_executor.models.qwen3_omni.pipeline import QWEN3_OMNI_PIPELINE
|
||||
|
||||
monkeypatch.setattr(StageConfigFactory, "get_pipeline_config", lambda **kwargs: QWEN3_OMNI_PIPELINE)
|
||||
deploy = tmp_path / "qwen.yaml"
|
||||
deploy.write_text("stages: []\n")
|
||||
assert not api_server._should_serve_duplex("qwen", {"deploy_config": str(deploy)})
|
||||
deploy.write_text("session_mode: duplex\nstages: []\n")
|
||||
assert api_server._should_serve_duplex("qwen", {"deploy_config": str(deploy)})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", ["1", "true", "on"])
|
||||
def test_turn_deployment_rejects_explicit_duplex_without_falling_back_to_stt(flag: str) -> None:
|
||||
app = _duplex_app(None)
|
||||
# Even an available STT service must not silently accept a duplex request.
|
||||
app.state.openai_serving_realtime = object()
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect(f"/v1/realtime?duplex={flag}") as websocket:
|
||||
assert websocket.receive_json() == {
|
||||
"type": "error",
|
||||
"code": "unsupported",
|
||||
"error": "VAD realtime is not enabled",
|
||||
}
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
websocket.receive_text()
|
||||
assert exc.value.code == 1008
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,276 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""CPU tests for the Gepard-1.0 speech adapter.
|
||||
|
||||
Covers detection, the reject matrix, voice default, empty input, extra_params,
|
||||
max_new_tokens frame bounds, and build() parity with the offline prompt helper.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from pydantic import ValidationError
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import detect_tts_model_type, resolve_adapter
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import SpeechServingContext
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.gepard import GepardAdapter
|
||||
from vllm_omni.model_executor.models.gepard.configuration_gepard import GepardConfig
|
||||
from vllm_omni.model_executor.models.gepard.prompt import build_gepard_prompt_ids
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
_MODEL = "nineninesix/gepard-1.0"
|
||||
|
||||
|
||||
def _adapter(*, revision: str | None = None) -> GepardAdapter:
|
||||
engine = SimpleNamespace(model_config=SimpleNamespace(model=_MODEL, revision=revision))
|
||||
return GepardAdapter(SpeechServingContext(server=SimpleNamespace(engine_client=engine), engine_client=engine))
|
||||
|
||||
|
||||
def test_gepard_detection_and_registration() -> None:
|
||||
assert resolve_adapter("gepard") is GepardAdapter
|
||||
assert GepardAdapter.stage_keys == {"gepard"}
|
||||
assert GepardAdapter.model_archs == {"GepardTalkerForConditionalGeneration"}
|
||||
assert detect_tts_model_type("gepard", None) == "gepard"
|
||||
assert detect_tts_model_type("gepard", "GepardTalkerForConditionalGeneration") == "gepard"
|
||||
assert detect_tts_model_type(None, "GepardTalkerForConditionalGeneration") == "gepard"
|
||||
|
||||
|
||||
def test_gepard_accepts_omitted_and_default_voice() -> None:
|
||||
adapter = _adapter()
|
||||
assert adapter.validate(OpenAICreateSpeechRequest(input="Hello from Gepard.")) is None
|
||||
assert adapter.validate(OpenAICreateSpeechRequest(input="Hello from Gepard.", voice="default")) is None
|
||||
assert adapter.validate(OpenAICreateSpeechRequest(input="Hello from Gepard.", voice="Default")) is None
|
||||
assert (
|
||||
adapter.validate(
|
||||
OpenAICreateSpeechRequest.model_validate({"input": "Hello from Gepard.", "speaker": "default"})
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kwargs", "field"),
|
||||
[
|
||||
({"voice": "vivian"}, "voice"),
|
||||
({"speed": 1.2}, "speed"),
|
||||
({"extra_params": {"temperature": 0.3}}, "extra_params"),
|
||||
({"extra_params": {"top_p": 0.9}}, "extra_params"),
|
||||
({"extra_params": {"top_k": 20}}, "extra_params"),
|
||||
({"extra_params": {"foo": 1}}, "extra_params"),
|
||||
({"ref_audio": "https://example.com/ref.wav"}, "ref_audio"),
|
||||
({"ref_text": "transcript"}, "ref_text"),
|
||||
({"ref_audio_2": "https://example.com/ref2.wav"}, "ref_audio_2"),
|
||||
({"speaker_embedding": [0.1, 0.2]}, "speaker_embedding"),
|
||||
({"x_vector_only_mode": True}, "x_vector_only_mode"),
|
||||
({"task_type": "Base"}, "task_type"),
|
||||
({"non_streaming_mode": True}, "non_streaming_mode"),
|
||||
({"instructions": "speak happily"}, "instructions"),
|
||||
({"language": "English"}, "language"),
|
||||
({"ambient_sound": "rain"}, "ambient_sound"),
|
||||
({"duration_seconds": 2.0}, "duration_seconds"),
|
||||
({"initial_codec_chunk_frames": 4}, "initial_codec_chunk_frames"),
|
||||
({"word_timestamps": True}, "word_timestamps"),
|
||||
({"response_format": "opus"}, "opus"),
|
||||
],
|
||||
)
|
||||
def test_gepard_rejects_unsupported_fields(kwargs: dict, field: str) -> None:
|
||||
adapter = _adapter()
|
||||
err = adapter.validate(OpenAICreateSpeechRequest(input="Hello from Gepard.", **kwargs))
|
||||
assert err is not None
|
||||
assert field in err
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["", " ", "\n\t"])
|
||||
def test_gepard_rejects_empty_input(text: str) -> None:
|
||||
adapter = _adapter()
|
||||
err = adapter.validate(OpenAICreateSpeechRequest(input=text))
|
||||
assert err is not None
|
||||
assert "empty" in err.lower()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("max_new_tokens", [0, -1])
|
||||
def test_gepard_schema_rejects_non_positive_max_new_tokens(max_new_tokens: int) -> None:
|
||||
with pytest.raises(ValidationError, match="greater than or equal to 1"):
|
||||
OpenAICreateSpeechRequest(input="Hello from Gepard.", max_new_tokens=max_new_tokens)
|
||||
|
||||
|
||||
def test_gepard_rejects_max_new_tokens_above_frame_budget() -> None:
|
||||
adapter = _adapter()
|
||||
err = adapter.validate(OpenAICreateSpeechRequest(input="Hello from Gepard.", max_new_tokens=4097))
|
||||
assert err is not None
|
||||
assert "max_new_tokens" in err
|
||||
assert "frames" in err
|
||||
|
||||
|
||||
def test_gepard_accepts_max_new_tokens_bounds() -> None:
|
||||
adapter = _adapter()
|
||||
assert adapter.validate(OpenAICreateSpeechRequest(input="Hello from Gepard.", max_new_tokens=1)) is None
|
||||
assert adapter.validate(OpenAICreateSpeechRequest(input="Hello from Gepard.", max_new_tokens=4096)) is None
|
||||
|
||||
|
||||
def test_gepard_apply_sampling_overrides_uses_frames() -> None:
|
||||
adapter = _adapter()
|
||||
stage_defaults = [SimpleNamespace(max_tokens=1000)]
|
||||
overridden = adapter.apply_sampling_overrides(
|
||||
stage_defaults,
|
||||
OpenAICreateSpeechRequest(input="Hello from Gepard.", max_new_tokens=32),
|
||||
)
|
||||
assert overridden[0].max_tokens == 32
|
||||
assert stage_defaults[0].max_tokens == 1000
|
||||
|
||||
|
||||
def test_gepard_build_matches_offline_prompt_builder(monkeypatch) -> None:
|
||||
adapter = _adapter()
|
||||
cfg = GepardConfig()
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.model_executor.models.gepard.configuration_gepard.GepardConfig.from_checkpoint",
|
||||
staticmethod(lambda *_a, **_k: cfg),
|
||||
)
|
||||
|
||||
class _Tok:
|
||||
def __call__(self, text, add_special_tokens=False):
|
||||
return {"input_ids": [11, 12, 13]}
|
||||
|
||||
adapter._tokenizer = _Tok()
|
||||
adapter._gepard_config = cfg
|
||||
text = "Hello from Gepard."
|
||||
prepared = asyncio.run(adapter.build(OpenAICreateSpeechRequest(input=text, seed=7), [], False))
|
||||
|
||||
expected = build_gepard_prompt_ids([11, 12, 13], config=cfg)
|
||||
assert prepared.prompt["prompt_token_ids"] == expected
|
||||
assert prepared.prompt["additional_information"] == {"text": [text]}
|
||||
assert prepared.model_type == "gepard"
|
||||
assert prepared.output_policy.accumulate_nonstreaming is True
|
||||
|
||||
|
||||
def test_gepard_loads_tokenizer_and_config_at_served_revision(monkeypatch) -> None:
|
||||
adapter = _adapter(revision="abc123")
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
def fake_from_checkpoint(model, backbone_config=None, revision=None):
|
||||
seen["config"] = (model, revision)
|
||||
return GepardConfig()
|
||||
|
||||
class _Tok:
|
||||
def __init__(self, *args, **kwargs):
|
||||
seen["tok"] = (args[0] if args else None, kwargs.get("revision"))
|
||||
|
||||
def __call__(self, text, add_special_tokens=False):
|
||||
return {"input_ids": [11, 12, 13]}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.model_executor.models.gepard.configuration_gepard.GepardConfig.from_checkpoint",
|
||||
staticmethod(fake_from_checkpoint),
|
||||
)
|
||||
monkeypatch.setattr("transformers.AutoTokenizer.from_pretrained", _Tok)
|
||||
prepared = asyncio.run(adapter.build(OpenAICreateSpeechRequest(input="Hello from Gepard."), [], False))
|
||||
assert seen["config"] == (_MODEL, "abc123")
|
||||
assert seen["tok"] == (_MODEL, "abc123")
|
||||
assert prepared.prompt["prompt_token_ids"] == build_gepard_prompt_ids([11, 12, 13], config=GepardConfig())
|
||||
|
||||
|
||||
def _gepard_mock_output(sample_rate: int = 22050, num_samples: int = 2048) -> OmniRequestOutput:
|
||||
class MockCompletionOutput:
|
||||
def __init__(self) -> None:
|
||||
self.index = 0
|
||||
self.text = ""
|
||||
self.token_ids: list[int] = []
|
||||
self.finish_reason = "stop"
|
||||
self.stop_reason = None
|
||||
self.logprobs = None
|
||||
|
||||
class MockRequestOutput:
|
||||
def __init__(self) -> None:
|
||||
self.request_id = "speech-gepard-mock"
|
||||
self.outputs = [MockCompletionOutput()]
|
||||
self.multimodal_output = {
|
||||
"audio": torch.zeros(num_samples),
|
||||
"sr": torch.tensor(sample_rate),
|
||||
}
|
||||
self.finished = True
|
||||
self.prompt_token_ids = None
|
||||
self.encoder_prompt_token_ids = None
|
||||
self.num_cached_tokens = None
|
||||
self.prompt_logprobs = None
|
||||
self.kv_transfer_params = None
|
||||
|
||||
output = OmniRequestOutput.from_stage_output(MockRequestOutput(), stage_id=0, final_output_type="audio")
|
||||
output.metrics = {"stage_metrics": {"0": {"num_tokens_in": 0, "num_tokens_out": 2}}}
|
||||
return output
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gepard_server(mocker: MockerFixture):
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.tts_adapters.base.load_supported_speakers",
|
||||
return_value={"default"},
|
||||
)
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.tts_adapters.base.load_codec_frame_rate",
|
||||
return_value=22050.0,
|
||||
)
|
||||
|
||||
async def _gen(*_args, **_kwargs):
|
||||
yield _gepard_mock_output()
|
||||
|
||||
mock_engine_client = mocker.MagicMock()
|
||||
mock_engine_client.errored = False
|
||||
mock_engine_client.model_config = mocker.MagicMock(model=_MODEL, async_chunk=False, revision=None)
|
||||
mock_engine_client.default_sampling_params_list = [SimpleNamespace(max_tokens=1000, seed=42, extra_args=None)]
|
||||
mock_engine_client.tts_batch_max_items = 32
|
||||
mock_engine_client.generate = mocker.MagicMock(side_effect=lambda **_k: _gen())
|
||||
mock_engine_client.stage_configs = [
|
||||
SimpleNamespace(
|
||||
engine_args=SimpleNamespace(model_stage="gepard", model_arch="GepardTalkerForConditionalGeneration"),
|
||||
tts_args={},
|
||||
)
|
||||
]
|
||||
|
||||
mock_models = mocker.MagicMock()
|
||||
mock_models.is_base_model.return_value = True
|
||||
server = OmniOpenAIServingSpeech(
|
||||
engine_client=mock_engine_client,
|
||||
models=mock_models,
|
||||
request_logger=mocker.MagicMock(),
|
||||
)
|
||||
cfg = GepardConfig()
|
||||
server._adapter._gepard_config = cfg
|
||||
server._adapter._tokenizer = lambda text, add_special_tokens=False: {"input_ids": [11, 12, 13]}
|
||||
yield server
|
||||
server.shutdown()
|
||||
|
||||
|
||||
def test_gepard_prepare_uses_prompt_builder(gepard_server) -> None:
|
||||
request_id, _generator, _ = asyncio.run(
|
||||
gepard_server._prepare_speech_generation(OpenAICreateSpeechRequest(input="Hello from Gepard."))
|
||||
)
|
||||
assert request_id.startswith("speech-")
|
||||
prompt = gepard_server.engine_client.generate.call_args.kwargs["prompt"]
|
||||
assert prompt["prompt_token_ids"] == build_gepard_prompt_ids([11, 12, 13], config=GepardConfig())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("response_format", ["wav", "pcm", "flac", "mp3"])
|
||||
def test_gepard_mocked_engine_encodes_response_formats(gepard_server, response_format: str) -> None:
|
||||
audio, media_type = asyncio.run(
|
||||
gepard_server._generate_audio_bytes(
|
||||
OpenAICreateSpeechRequest(input="Hello from Gepard.", response_format=response_format)
|
||||
)
|
||||
)
|
||||
assert isinstance(audio, (bytes, str))
|
||||
assert len(audio) > 0
|
||||
expected_media_type = {"wav": "audio/wav", "pcm": "audio/pcm", "flac": "audio/flac", "mp3": "audio/mpeg"}
|
||||
assert media_type == expected_media_type[response_format]
|
||||
|
||||
|
||||
def test_gepard_streaming_rejects_non_pcm_wav() -> None:
|
||||
with pytest.raises(ValidationError, match="requires response_format='pcm' or 'wav'"):
|
||||
OpenAICreateSpeechRequest(input="Hello from Gepard.", stream=True, response_format="mp3")
|
||||
@@ -0,0 +1,54 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""L1 tests for the image response encoder's compression contract."""
|
||||
|
||||
import base64
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from vllm_omni.entrypoints.openai.image_api_utils import (
|
||||
encode_image_base64_with_compression,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def image():
|
||||
# Photographic-content proxy: noise-plus-gradient content compresses very
|
||||
# differently across PNG compress levels, which is what the mapping under
|
||||
# test controls.
|
||||
import numpy as np
|
||||
|
||||
rng = np.random.default_rng(0)
|
||||
grad = np.linspace(0, 255, 256, dtype=np.float32)
|
||||
arr = np.clip(grad[None, :, None] + rng.integers(-24, 25, (256, 256, 3)), 0, 255).astype(np.uint8)
|
||||
return Image.fromarray(arr, mode="RGB")
|
||||
|
||||
|
||||
def test_png_output_compression_maps_to_compress_level(image):
|
||||
"""output_compression 100 -> compress_level 0, 1 -> compress_level 9."""
|
||||
|
||||
def encode(compression):
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG", compress_level=max(0, min(9, 9 - compression // 11)))
|
||||
return len(base64.b64encode(buffer.getvalue()))
|
||||
|
||||
fast = encode_image_base64_with_compression(image, format="png", output_compression=100)
|
||||
small = encode_image_base64_with_compression(image, format="png", output_compression=1)
|
||||
|
||||
# Level-0 PNG barely compresses; the mapped level-9 encode is smaller.
|
||||
assert len(small) < len(fast)
|
||||
# The mapping must produce the same sizes as the equivalent compress_level.
|
||||
assert len(fast) == encode(100)
|
||||
assert len(small) == encode(1)
|
||||
|
||||
|
||||
def test_jpeg_output_compression_maps_to_quality(image):
|
||||
high = base64.b64decode(encode_image_base64_with_compression(image, format="jpeg", output_compression=100))
|
||||
low = base64.b64decode(encode_image_base64_with_compression(image, format="jpeg", output_compression=10))
|
||||
|
||||
assert len(high) > len(low)
|
||||
@@ -0,0 +1,75 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import base64
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from vllm_omni.entrypoints.openai.protocol.images import (
|
||||
_FILE_RESPONSE_CHUNK_SIZE,
|
||||
ImageData,
|
||||
ImageGenerationResponse,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
_NEWLINE_HEAVY_PAYLOAD = (b"\x89PNG\n\x1a\n" * 64) + bytes(range(256)) * 8
|
||||
|
||||
|
||||
def _response(*payloads: bytes) -> ImageGenerationResponse:
|
||||
return ImageGenerationResponse(
|
||||
created=0,
|
||||
data=[ImageData(b64_json=base64.b64encode(payload).decode()) for payload in payloads],
|
||||
output_format="png",
|
||||
size="1x1",
|
||||
)
|
||||
|
||||
|
||||
async def _collect(response: StreamingResponse) -> list[bytes]:
|
||||
return [bytes(chunk) async for chunk in response.body_iterator]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_file_response_chunks_ignore_newlines() -> None:
|
||||
payload = _NEWLINE_HEAVY_PAYLOAD
|
||||
assert b"\n" in payload
|
||||
|
||||
response = _response(payload).stream_response()
|
||||
chunks = await _collect(response)
|
||||
|
||||
assert len(chunks) == 1
|
||||
assert b"".join(chunks) == payload
|
||||
assert response.headers["content-length"] == str(len(payload))
|
||||
assert response.headers["content-type"] == "image/png"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_file_response_chunks_are_bounded() -> None:
|
||||
payload = _NEWLINE_HEAVY_PAYLOAD * 200
|
||||
expected_chunks = -(-len(payload) // _FILE_RESPONSE_CHUNK_SIZE)
|
||||
assert expected_chunks > 1
|
||||
|
||||
chunks = await _collect(_response(payload).stream_response())
|
||||
|
||||
assert len(chunks) == expected_chunks
|
||||
assert max(len(chunk) for chunk in chunks) <= _FILE_RESPONSE_CHUNK_SIZE
|
||||
assert b"".join(chunks) == payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zip_file_response_streams_whole_archive() -> None:
|
||||
payloads = (_NEWLINE_HEAVY_PAYLOAD, b"second image\nwith a newline")
|
||||
|
||||
response = _response(*payloads).stream_response()
|
||||
chunks = await _collect(response)
|
||||
|
||||
zip_bytes = b"".join(chunks)
|
||||
assert len(chunks) == -(-len(zip_bytes) // _FILE_RESPONSE_CHUNK_SIZE)
|
||||
assert response.headers["content-length"] == str(len(zip_bytes))
|
||||
assert response.headers["content-type"] == "application/zip"
|
||||
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as archive:
|
||||
assert archive.read("image_0.png") == payloads[0]
|
||||
assert archive.read("image_1.png") == payloads[1]
|
||||
@@ -2314,3 +2314,72 @@ def test_image_edits_size_auto_preserves_bridge_size(async_omni_stage_configs_on
|
||||
assert captured_prompt["prompt"].count("<img>") == 2, (
|
||||
f"N=2 reference images must emit 2 <img> placeholders in AR prompt; got {captured_prompt[KEY].count(IMG)} -- prompt: {captured_prompt[KEY]!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason=(
|
||||
"Pre-existing gap, not introduced by this change: the /v1/images/edits route in "
|
||||
"api_server.py never calls resolve_stop_token_ids at all (grep: api_server.py has "
|
||||
"no stop_token_ids reference), so the AR stage keeps SamplingParams' default empty "
|
||||
"list. The omitted-bot_task fix landed in serving_chat.py, which is the only "
|
||||
"production caller; that path is covered by "
|
||||
"test_serving_chat_multistage_generation.py::"
|
||||
"test_build_multistage_generation_inputs_omitted_bot_task_matches_prompt_default. "
|
||||
"Wiring the images/edits route through the same seam needs a tokenizer in that "
|
||||
"scope and a decision on whether HunyuanImage3 it2i should resolve AR stop tokens "
|
||||
"unconditionally (today the whole AR block is gated on an explicit bot_task / "
|
||||
"use_system_prompt / system_prompt), so it is tracked separately."
|
||||
),
|
||||
)
|
||||
def test_image_edits_omitted_bot_task_stop_tokens_match_prompt_default(
|
||||
async_omni_stage_configs_only_client,
|
||||
):
|
||||
"""Regression: an omitted bot_task must resolve identically for the AR
|
||||
prompt and its stop_token_ids.
|
||||
|
||||
build_prompt/build_prompt_tokens default an omitted bot_task per-task
|
||||
(e.g. "think" for the base "it2i" task, since it isn't itself a key in
|
||||
_TASK_PRESETS). resolve_stop_token_ids must land on that same default
|
||||
to compute the matching stop set. Passing the raw (still-None) outer
|
||||
bot_task variable to resolve_stop_token_ids -- instead of mirroring
|
||||
build_kwargs's own omitted-or-not "bot_task" entry -- made it normalize
|
||||
bot_task=None instead of "think", so it fell through to the full
|
||||
<img_ratio_*> stop range instead of the think/recaption-only pair.
|
||||
|
||||
An explicit (non-"auto") size is required to reach this: with
|
||||
need_ratio=True (size="auto"), resolve_stop_token_ids returns the full
|
||||
ratio range regardless of bot_task, so the two code paths only visibly
|
||||
disagree once a concrete size selects the narrower think/recaption stop
|
||||
set.
|
||||
"""
|
||||
from vllm_omni.diffusion.models.hunyuan_image3.prompt_utils import (
|
||||
HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS,
|
||||
)
|
||||
|
||||
img = make_test_image_bytes((64, 64))
|
||||
response = async_omni_stage_configs_only_client.post(
|
||||
"/v1/images/edits",
|
||||
files=[("image", img)],
|
||||
data={
|
||||
"prompt": "make it neon",
|
||||
"size": "512x512",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
engine = async_omni_stage_configs_only_client.app.state.engine_client
|
||||
captured = engine.captured_sampling_params_list
|
||||
assert captured is not None
|
||||
|
||||
ar_params = captured[0]
|
||||
expected_stop_token_ids = [
|
||||
HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["</think>"],
|
||||
HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["</recaption>"],
|
||||
]
|
||||
assert ar_params.stop_token_ids == expected_stop_token_ids, (
|
||||
f"omitted bot_task with an explicit size must resolve stop_token_ids "
|
||||
f"for the default 'think' bot_task ({expected_stop_token_ids}); got "
|
||||
f"{ar_params.stop_token_ids} -- this is the full ratio range, meaning "
|
||||
"resolve_stop_token_ids disagreed with build_prompt_tokens's default."
|
||||
)
|
||||
@@ -0,0 +1,302 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import moss_tts as adapter_module
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import SpeechServingContext
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.moss_tts import MossTTSAdapter
|
||||
from vllm_omni.model_executor.models.moss_tts import reference_encoder
|
||||
from vllm_omni.utils.speaker_cache import SpeakerEmbeddingCache
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("hf_config", "expected_codec_path"),
|
||||
[
|
||||
(SimpleNamespace(), "OpenMOSS-Team/MOSS-Audio-Tokenizer"),
|
||||
(
|
||||
SimpleNamespace(codec_model_name_or_path="/models/custom-moss-codec"),
|
||||
"/models/custom-moss-codec",
|
||||
),
|
||||
],
|
||||
ids=["default-codec", "configured-codec"],
|
||||
)
|
||||
def test_realtime_components_use_the_realtime_model_and_codec(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
hf_config: SimpleNamespace,
|
||||
expected_codec_path: str,
|
||||
) -> None:
|
||||
engine_client = SimpleNamespace(
|
||||
model_config=SimpleNamespace(
|
||||
model="OpenMOSS-Team/MOSS-TTS-Realtime",
|
||||
hf_config=hf_config,
|
||||
)
|
||||
)
|
||||
|
||||
server = MossTTSAdapter(SpeechServingContext(server=object(), engine_client=engine_client))
|
||||
|
||||
tokenizer = object()
|
||||
codec = type("Codec", (), {"to": lambda self, device: self, "eval": lambda self: self})()
|
||||
processor_calls = []
|
||||
|
||||
class Processor:
|
||||
def __init__(self, *, tokenizer):
|
||||
processor_calls.append(tokenizer)
|
||||
|
||||
class_calls = []
|
||||
tokenizer_calls = []
|
||||
codec_calls = []
|
||||
|
||||
def fake_get_class(class_reference, model_id):
|
||||
class_calls.append((class_reference, model_id))
|
||||
return Processor
|
||||
|
||||
def fake_load_tokenizer(model_id, *, trust_remote_code):
|
||||
tokenizer_calls.append((model_id, trust_remote_code))
|
||||
return tokenizer
|
||||
|
||||
def fake_load_codec(model_id, *, trust_remote_code):
|
||||
codec_calls.append((model_id, trust_remote_code))
|
||||
return codec
|
||||
|
||||
monkeypatch.setattr(adapter_module, "get_class_from_dynamic_module", fake_get_class)
|
||||
monkeypatch.setattr(adapter_module.AutoTokenizer, "from_pretrained", fake_load_tokenizer)
|
||||
monkeypatch.setattr(adapter_module.AutoModel, "from_pretrained", fake_load_codec)
|
||||
|
||||
components = server._get_moss_realtime_components()
|
||||
|
||||
assert components[0] is tokenizer
|
||||
assert components[2] is codec
|
||||
assert server._get_moss_realtime_components() is components
|
||||
assert class_calls == [
|
||||
(
|
||||
"processing_mossttsrealtime.MossTTSRealtimeProcessor",
|
||||
"OpenMOSS-Team/MOSS-TTS-Realtime",
|
||||
)
|
||||
]
|
||||
assert tokenizer_calls == [("OpenMOSS-Team/MOSS-TTS-Realtime", True)]
|
||||
assert codec_calls == [(expected_codec_path, True)]
|
||||
assert processor_calls == [tokenizer]
|
||||
|
||||
|
||||
def test_realtime_serving_builds_the_talker_prompt(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
server = MossTTSAdapter(SpeechServingContext(server=SimpleNamespace(uploaded_speakers={})))
|
||||
server._moss_variant = "realtime"
|
||||
tokenizer = object()
|
||||
processor = object()
|
||||
codec = object()
|
||||
request_thread = threading.get_ident()
|
||||
|
||||
def load_components():
|
||||
assert threading.get_ident() != request_thread
|
||||
return tokenizer, processor, codec
|
||||
|
||||
server._get_moss_realtime_components = load_components
|
||||
|
||||
reference_codes = torch.arange(64, dtype=torch.int64).reshape(4, 16)
|
||||
encode = AsyncMock(return_value=([reference_codes], {0: "reference-cache-key"}))
|
||||
monkeypatch.setattr(server, "_encode_moss_references", encode)
|
||||
|
||||
build_call = None
|
||||
|
||||
def fake_build(actual_tokenizer, actual_processor, text, actual_codes):
|
||||
nonlocal build_call
|
||||
build_call = (actual_tokenizer, actual_processor, text, actual_codes)
|
||||
return {
|
||||
"prompt_token_ids": [10, 11],
|
||||
"codes": {"ref": actual_codes},
|
||||
"ids": {"all": [12]},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(adapter_module, "build_realtime_prompt", fake_build)
|
||||
|
||||
params = asyncio.run(
|
||||
server._build_moss_tts_params(
|
||||
OpenAICreateSpeechRequest(
|
||||
input="speak this text",
|
||||
ref_audio="data:audio/wav;base64,AAAA",
|
||||
max_new_tokens=50,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
encode.assert_awaited_once()
|
||||
assert encode.call_args.kwargs == {"has_inline_ref_audio": False, "two_speaker": False}
|
||||
assert encode.call_args.args[0].ref_audio == "data:audio/wav;base64,AAAA"
|
||||
assert build_call == (tokenizer, processor, "speak this text", reference_codes)
|
||||
assert params == {
|
||||
"prompt_token_ids": [10, 11],
|
||||
"codes": {"ref": reference_codes},
|
||||
"ids": {"all": [12]},
|
||||
"max_new_frames": [50],
|
||||
"ref_audio_cache_key": "reference-cache-key",
|
||||
}
|
||||
assert "prompt_audio_array" not in params
|
||||
|
||||
|
||||
def test_cancelled_component_waiter_does_not_duplicate_codec_load(monkeypatch):
|
||||
adapter = MossTTSAdapter(
|
||||
SpeechServingContext(
|
||||
server=object(),
|
||||
engine_client=SimpleNamespace(
|
||||
model_config=SimpleNamespace(model="OpenMOSS-Team/MOSS-TTS-Realtime", hf_config=SimpleNamespace())
|
||||
),
|
||||
)
|
||||
)
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
second_started = threading.Event()
|
||||
loads = []
|
||||
codec = type("Codec", (), {"to": lambda self, device: self, "eval": lambda self: self})()
|
||||
|
||||
def load_codec(*args, **kwargs):
|
||||
loads.append(args)
|
||||
started.set()
|
||||
assert release.wait(5), "codec loader was not released"
|
||||
return codec
|
||||
|
||||
monkeypatch.setattr(adapter_module, "get_class_from_dynamic_module", lambda *args: lambda **kwargs: object())
|
||||
monkeypatch.setattr(adapter_module.AutoTokenizer, "from_pretrained", lambda *args, **kwargs: object())
|
||||
monkeypatch.setattr(adapter_module.AutoModel, "from_pretrained", load_codec)
|
||||
|
||||
def second_load():
|
||||
second_started.set()
|
||||
return adapter._get_moss_realtime_components()
|
||||
|
||||
async def run():
|
||||
first = asyncio.create_task(asyncio.to_thread(adapter._get_moss_realtime_components))
|
||||
try:
|
||||
assert await asyncio.to_thread(started.wait, 5)
|
||||
first.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await first
|
||||
second = asyncio.create_task(asyncio.to_thread(second_load))
|
||||
try:
|
||||
assert await asyncio.to_thread(second_started.wait, 5)
|
||||
finally:
|
||||
release.set()
|
||||
components = await asyncio.wait_for(second, 5)
|
||||
assert components[2] is codec
|
||||
assert adapter._get_moss_realtime_components() is components
|
||||
assert len(loads) == 1
|
||||
finally:
|
||||
release.set()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("voice_name", [None, "speaker"])
|
||||
def test_realtime_reference_cache_reuses_codes(voice_name):
|
||||
cache = SpeakerEmbeddingCache()
|
||||
resolve = AsyncMock(return_value=([0.0], 24000, "audio-key"))
|
||||
request_thread = threading.get_ident()
|
||||
encode_calls = []
|
||||
expected = torch.arange(64).reshape(4, 16)
|
||||
|
||||
def batch_encode(wavs, *, num_quantizers):
|
||||
assert threading.get_ident() != request_thread
|
||||
assert num_quantizers == 16
|
||||
encode_calls.append(wavs)
|
||||
return SimpleNamespace(audio_codes=expected.T.unsqueeze(1), audio_codes_lengths=torch.tensor([4]))
|
||||
|
||||
encoder = reference_encoder.build_reference_encoder(
|
||||
SimpleNamespace(batch_encode=batch_encode), variant="realtime", speaker_cache=cache
|
||||
)
|
||||
|
||||
async def run():
|
||||
kwargs = dict(
|
||||
resolve_ref_audio=resolve,
|
||||
get_artifact_key=lambda key: "content-key",
|
||||
voice_name=voice_name,
|
||||
voice_created_at=1,
|
||||
)
|
||||
try:
|
||||
first, first_key = await encoder.encode("reference", **kwargs)
|
||||
second, second_key = await encoder.encode("reference", **kwargs)
|
||||
assert torch.equal(first, expected)
|
||||
assert torch.equal(second, expected)
|
||||
assert first.dtype == torch.int64
|
||||
assert first_key == "audio-key"
|
||||
assert second_key == (None if voice_name else "audio-key")
|
||||
second.zero_()
|
||||
third, _ = await encoder.encode("another-reference", **kwargs)
|
||||
assert torch.equal(third, expected)
|
||||
assert len(encode_calls) == 1
|
||||
assert resolve.await_count == (1 if voice_name else 3)
|
||||
key = cache.make_cache_key(
|
||||
voice_name or "ref:content-key", "moss_tts_realtime_nq16", 1 if voice_name else 0
|
||||
)
|
||||
assert cache.get(key)["codes"].dtype == torch.int32
|
||||
if voice_name:
|
||||
kwargs["voice_created_at"] = 2
|
||||
await encoder.encode("reference", **kwargs)
|
||||
assert len(encode_calls) == 2
|
||||
assert resolve.await_count == 2
|
||||
finally:
|
||||
await encoder.aclose()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_realtime_reference_batch_trims_each_item():
|
||||
cache = SpeakerEmbeddingCache()
|
||||
codes = torch.arange(16 * 2 * 5).reshape(16, 2, 5)
|
||||
calls = []
|
||||
|
||||
def batch_encode(wavs, *, num_quantizers):
|
||||
calls.append(wavs)
|
||||
assert num_quantizers == 16
|
||||
assert [wav.shape for wav in wavs] == [torch.Size([8]), torch.Size([12])]
|
||||
return SimpleNamespace(audio_codes=codes, audio_codes_lengths=torch.tensor([3, 5]))
|
||||
|
||||
encoder = reference_encoder.build_reference_encoder(
|
||||
SimpleNamespace(batch_encode=batch_encode), variant="realtime", speaker_cache=cache
|
||||
)
|
||||
|
||||
async def resolve(ref):
|
||||
return [0.0] * (8 if ref == "short" else 12), 24000, ref
|
||||
|
||||
async def run():
|
||||
try:
|
||||
short, long = await asyncio.gather(
|
||||
encoder.encode("short", resolve_ref_audio=resolve, get_artifact_key=lambda key: key),
|
||||
encoder.encode("long", resolve_ref_audio=resolve, get_artifact_key=lambda key: key),
|
||||
)
|
||||
torch.testing.assert_close(short[0], codes[:, 0, :3].T)
|
||||
torch.testing.assert_close(long[0], codes[:, 1, :5].T)
|
||||
assert len(calls) == 1
|
||||
finally:
|
||||
await encoder.aclose()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_failed_realtime_reference_encoding_does_not_populate_cache():
|
||||
cache = SpeakerEmbeddingCache()
|
||||
resolve = AsyncMock(return_value=([0.0], 24000, "audio-key"))
|
||||
|
||||
def fail(*args, **kwargs):
|
||||
raise ValueError("invalid reference")
|
||||
|
||||
encoder = reference_encoder.build_reference_encoder(
|
||||
SimpleNamespace(batch_encode=fail), variant="realtime", speaker_cache=cache
|
||||
)
|
||||
|
||||
async def run():
|
||||
try:
|
||||
with pytest.raises(ValueError, match="invalid reference"):
|
||||
await encoder.encode("reference", resolve_ref_audio=resolve, get_artifact_key=lambda key: None)
|
||||
assert cache.memory_bytes() == 0
|
||||
finally:
|
||||
await encoder.aclose()
|
||||
|
||||
asyncio.run(run())
|
||||
@@ -20,6 +20,7 @@ def _make_app(engine_client):
|
||||
from vllm_omni.entrypoints.openai.api_server import router
|
||||
|
||||
app = FastAPI()
|
||||
app.state.api_server_count = 1
|
||||
app.include_router(router)
|
||||
app.state.engine_client = engine_client
|
||||
app.state.sleeping_stages = set()
|
||||
@@ -253,6 +254,7 @@ def pure_diffusion_app(pure_diffusion_engine, mocker):
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
app.state.api_server_count = 1
|
||||
app.include_router(router)
|
||||
|
||||
mocker.patch(
|
||||
|
||||
@@ -310,6 +310,29 @@ def test_handle_connection_closes_websocket_on_idle_timeout(monkeypatch):
|
||||
serving.infer.assert_not_called()
|
||||
|
||||
|
||||
def test_handle_connection_can_disable_idle_timeout(monkeypatch):
|
||||
monkeypatch.setattr(openpi_connection, "_pack", lambda obj: obj)
|
||||
|
||||
async def fail_wait_for(*_args, **_kwargs):
|
||||
pytest.fail("asyncio.wait_for should not be used when the idle timeout is disabled")
|
||||
|
||||
monkeypatch.setattr(openpi_connection.asyncio, "wait_for", fail_wait_for)
|
||||
websocket = FakeWebSocket([{"type": "websocket.disconnect"}])
|
||||
serving = _serving_mock()
|
||||
|
||||
asyncio.run(
|
||||
openpi_connection.RobotRealtimeConnection(
|
||||
websocket,
|
||||
serving,
|
||||
idle_timeout=None,
|
||||
).handle_connection()
|
||||
)
|
||||
|
||||
assert websocket.accepted is True
|
||||
assert websocket.closed is False
|
||||
serving.infer.assert_not_called()
|
||||
|
||||
|
||||
def test_handle_connection_keeps_session_state_per_websocket(monkeypatch):
|
||||
monkeypatch.setattr(openpi_connection, "_pack", lambda obj: obj)
|
||||
requests = {
|
||||
@@ -343,6 +366,73 @@ def test_handle_connection_keeps_session_state_per_websocket(monkeypatch):
|
||||
assert calls[2].kwargs == {"session_id": "session-b", "reset": True}
|
||||
|
||||
|
||||
def test_handle_connection_keeps_interleaved_parallel_session_state(monkeypatch):
|
||||
monkeypatch.setattr(openpi_connection, "_pack", lambda obj: obj)
|
||||
requests = {
|
||||
b"a1": {"prompt": "first-a", "session_id": "session-a"},
|
||||
b"b1": {"prompt": "first-b", "session_id": "session-b"},
|
||||
b"a2": {"prompt": "second-a", "session_id": "session-a"},
|
||||
b"b2": {"prompt": "second-b", "session_id": "session-b"},
|
||||
}
|
||||
monkeypatch.setattr(openpi_connection, "_unpack", lambda data: dict(requests[data]))
|
||||
serving = _serving_mock()
|
||||
websocket = FakeWebSocket(
|
||||
[{"type": "websocket.receive", "bytes": key} for key in (b"a1", b"b1", b"a2", b"b2")]
|
||||
+ [{"type": "websocket.disconnect"}]
|
||||
)
|
||||
|
||||
asyncio.run(openpi_connection.RobotRealtimeConnection(websocket, serving).handle_connection())
|
||||
|
||||
assert [call.kwargs for call in serving.infer.await_args_list] == [
|
||||
{"session_id": "session-a", "reset": True},
|
||||
{"session_id": "session-b", "reset": True},
|
||||
{"session_id": "session-a", "reset": False},
|
||||
{"session_id": "session-b", "reset": False},
|
||||
]
|
||||
|
||||
|
||||
def test_handle_connection_bounds_tracked_sessions_without_resetting_live_ones(monkeypatch):
|
||||
"""A client may hold one socket across many episodes, minting a fresh
|
||||
session id per episode and never sending ``reset`` (RoboLab does exactly
|
||||
this). Session tracking must stay bounded, and evicting finished episodes
|
||||
must not disturb the sessions that are still running.
|
||||
"""
|
||||
monkeypatch.setattr(openpi_connection, "_pack", lambda obj: obj)
|
||||
monkeypatch.setattr(openpi_connection, "MAX_TRACKED_SESSIONS", 8)
|
||||
|
||||
# One long-lived session interleaved with far more short episodes than the cap.
|
||||
episodes = 40
|
||||
keys = []
|
||||
requests = {}
|
||||
for episode in range(episodes):
|
||||
for session_id in (f"episode-{episode}", "live-env"):
|
||||
key = f"{session_id}@{episode}".encode()
|
||||
keys.append(key)
|
||||
requests[key] = {"prompt": "pick", "session_id": session_id}
|
||||
|
||||
monkeypatch.setattr(openpi_connection, "_unpack", lambda data: dict(requests[data]))
|
||||
serving = _serving_mock()
|
||||
websocket = FakeWebSocket(
|
||||
[{"type": "websocket.receive", "bytes": key} for key in keys] + [{"type": "websocket.disconnect"}]
|
||||
)
|
||||
connection = openpi_connection.RobotRealtimeConnection(websocket, serving)
|
||||
|
||||
asyncio.run(connection.handle_connection())
|
||||
|
||||
assert len(connection._seen_sessions) <= 8
|
||||
|
||||
resets_by_session: dict[str, list[bool]] = {}
|
||||
for call in serving.infer.await_args_list:
|
||||
resets_by_session.setdefault(call.kwargs["session_id"], []).append(call.kwargs["reset"])
|
||||
|
||||
# Each new episode resets exactly once, on its first observation.
|
||||
for episode in range(episodes):
|
||||
assert resets_by_session[f"episode-{episode}"] == [True]
|
||||
# The continuously active session is never evicted, so it resets only once
|
||||
# despite ``episodes`` other sessions churning past the cap.
|
||||
assert resets_by_session["live-env"] == [True] + [False] * (episodes - 1)
|
||||
|
||||
|
||||
def test_handle_connection_reset_endpoint_resets_next_infer(monkeypatch):
|
||||
monkeypatch.setattr(openpi_connection, "_pack", lambda obj: obj)
|
||||
requests = {
|
||||
|
||||
@@ -15,6 +15,7 @@ from starlette.testclient import TestClient
|
||||
|
||||
from vllm_omni.entrypoints.openpi import connection as openpi_connection
|
||||
from vllm_omni.entrypoints.openpi import serving as openpi_serving
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
@@ -137,6 +138,36 @@ def test_policy_server_config_reads_stage_config_model_config():
|
||||
assert serving.policy_server_config.to_dict() == policy_config
|
||||
|
||||
|
||||
@pytest.mark.parametrize("inline", [False, True], ids=["out_of_process", "inline"])
|
||||
def test_policy_server_config_reads_typed_runtime(inline):
|
||||
from tests.helpers.stage_config import get_deploy_config_path
|
||||
from vllm_omni.config.omni_config import VllmOmniConfig
|
||||
from vllm_omni.diffusion.models.pi0_pipeline_config import PI0_PIPELINE
|
||||
from vllm_omni.entrypoints.async_omni import AsyncOmni
|
||||
|
||||
typed_stage = VllmOmniConfig.from_pipeline_config(
|
||||
PI0_PIPELINE, deploy_config_path=get_deploy_config_path("pi0.yaml")
|
||||
).stage_configs[0]
|
||||
policy_config = typed_stage.diffusion_config.model_config["policy_server_config"]
|
||||
stage_client = SimpleNamespace(stage_type="diffusion")
|
||||
if inline:
|
||||
# Worker-enriched inline metadata remains authoritative over the
|
||||
# startup stage config; only the process client needs the fallback.
|
||||
policy_config = {**policy_config, "worker_metadata": "loaded"}
|
||||
stage_client.od_config = SimpleNamespace(model_config={"policy_server_config": policy_config})
|
||||
engine_client = AsyncOmni.__new__(AsyncOmni)
|
||||
engine_client.engine = SimpleNamespace(
|
||||
stage_clients=[stage_client],
|
||||
stage_configs=[typed_stage],
|
||||
stage_vllm_configs=[None],
|
||||
get_diffusion_od_config=lambda: SimpleNamespace(model_class_name="Pi0Pipeline"),
|
||||
)
|
||||
|
||||
serving = openpi_serving.ServingRealtimeRobotOpenPI(engine_client=engine_client)
|
||||
|
||||
assert serving.policy_server_config.to_dict() == policy_config
|
||||
|
||||
|
||||
def test_policy_server_config_reads_omegaconf_stage_config():
|
||||
engine_client = SimpleNamespace(
|
||||
get_diffusion_od_config=lambda: None,
|
||||
@@ -177,6 +208,22 @@ def test_create_policy_server_returns_none_without_policy_config():
|
||||
assert serving is None
|
||||
|
||||
|
||||
def test_legacy_policy_request_defaults_key_no_longer_controls_serving():
|
||||
od_config = SimpleNamespace(
|
||||
model_config={
|
||||
"policy_server_config": {},
|
||||
"policy_request_defaults": "malformed legacy value",
|
||||
}
|
||||
)
|
||||
|
||||
serving = openpi_serving.ServingRealtimeRobotOpenPI.create_policy_server(
|
||||
engine_client=SimpleNamespace(get_diffusion_od_config=lambda: od_config),
|
||||
model_name="generic-policy",
|
||||
)
|
||||
|
||||
assert serving is not None
|
||||
|
||||
|
||||
def test_policy_server_config_allows_explicit_empty_config():
|
||||
serving = openpi_serving.ServingRealtimeRobotOpenPI(
|
||||
engine_client=_engine_with_policy_config(policy_config={}),
|
||||
@@ -221,6 +268,54 @@ def test_build_request_uses_unique_engine_request_id_per_inference():
|
||||
assert request_a.request_id != request_b.request_id
|
||||
|
||||
|
||||
def test_build_request_clones_stage_defaults_before_protocol_fields():
|
||||
default_params = OmniDiffusionSamplingParams(
|
||||
num_inference_steps=12,
|
||||
output_type="latent",
|
||||
extra_args={
|
||||
"format_prompt_as_json": True,
|
||||
"session_id": "configured-session-must-not-win",
|
||||
"reset": False,
|
||||
"nested": {"values": []},
|
||||
},
|
||||
)
|
||||
od_config = SimpleNamespace(model_config={"policy_server_config": {}})
|
||||
engine_client = SimpleNamespace(
|
||||
get_diffusion_od_config=lambda: od_config,
|
||||
default_sampling_params_list=[default_params],
|
||||
)
|
||||
serving = openpi_serving.ServingRealtimeRobotOpenPI(
|
||||
engine_client=engine_client,
|
||||
model_name="custom-policy",
|
||||
)
|
||||
|
||||
request_a = serving._build_request(
|
||||
{"prompt": "pick up the object"},
|
||||
session_id="wire-session",
|
||||
reset=True,
|
||||
)
|
||||
request_b = serving._build_request(
|
||||
{"prompt": "pick up the object"},
|
||||
session_id="wire-session",
|
||||
reset=False,
|
||||
)
|
||||
request_c = serving._build_request(
|
||||
{"prompt": "pick up the object", "sampling_params": {"num_inference_steps": 4}},
|
||||
session_id="wire-session",
|
||||
reset=False,
|
||||
)
|
||||
|
||||
assert request_a.sampling_params.extra_args["format_prompt_as_json"] is True
|
||||
assert request_a.sampling_params.extra_args["session_id"] == "wire-session"
|
||||
assert request_a.sampling_params.extra_args["reset"] is True
|
||||
assert request_a.sampling_params.num_inference_steps == 12
|
||||
assert request_a.sampling_params.output_type == "latent"
|
||||
assert request_c.sampling_params.num_inference_steps == 4
|
||||
request_a.sampling_params.extra_args["nested"]["values"].append("request-a")
|
||||
assert request_b.sampling_params.extra_args["nested"] == {"values": []}
|
||||
assert default_params.extra_args["nested"] == {"values": []}
|
||||
|
||||
|
||||
def test_build_request_forwards_seed_to_sampling_params():
|
||||
"""``seed`` in the inference message is the engine-level seed; omitted, the request auto-seeds."""
|
||||
serving = openpi_serving.ServingRealtimeRobotOpenPI(engine_client=_engine_with_policy_config())
|
||||
@@ -233,6 +328,46 @@ def test_build_request_forwards_seed_to_sampling_params():
|
||||
assert isinstance(unseeded.sampling_params.seed, int)
|
||||
|
||||
|
||||
# Per-request inference parameters live in their own namespace so robot feature
|
||||
# names stay open-ended. Unknown keys inside it are ignored rather than rejected.
|
||||
def test_build_request_forwards_num_inference_steps():
|
||||
serving = openpi_serving.ServingRealtimeRobotOpenPI(engine_client=_engine_with_policy_config())
|
||||
|
||||
request = serving._build_request(
|
||||
{"prompt": "pick up the object", "sampling_params": {"num_inference_steps": 4}},
|
||||
session_id="session-a",
|
||||
reset=True,
|
||||
)
|
||||
|
||||
assert request.sampling_params.num_inference_steps == 4
|
||||
# The namespace is an engine concern; the pipeline must not see it as a feature.
|
||||
assert "sampling_params" not in request.sampling_params.extra_args["robot_obs"]
|
||||
|
||||
|
||||
def test_build_request_omits_num_inference_steps_when_not_sent():
|
||||
"""Absent must stay absent: the pipeline reads None as "use the configured
|
||||
default", so materialising a null here would be a different request."""
|
||||
serving = openpi_serving.ServingRealtimeRobotOpenPI(engine_client=_engine_with_policy_config())
|
||||
|
||||
request = serving._build_request({"prompt": "x"}, session_id="s", reset=True)
|
||||
|
||||
assert request.sampling_params.num_inference_steps is None
|
||||
|
||||
|
||||
def test_build_request_does_not_forward_arbitrary_observation_keys():
|
||||
"""A robot observation may carry any feature name; none of them become
|
||||
engine parameters."""
|
||||
serving = openpi_serving.ServingRealtimeRobotOpenPI(engine_client=_engine_with_policy_config())
|
||||
|
||||
request = serving._build_request(
|
||||
{"prompt": "x", "state": np.zeros(32), "guidance_scale": 7.5}, session_id="s", reset=True
|
||||
)
|
||||
|
||||
extra_args = request.sampling_params.extra_args
|
||||
assert set(extra_args) == {"reset", "session_id", "robot_obs"}
|
||||
assert extra_args["robot_obs"]["guidance_scale"] == 7.5
|
||||
|
||||
|
||||
def test_infer_keeps_session_state_but_uses_unique_engine_request_ids():
|
||||
engine = RecordingEngine()
|
||||
serving = openpi_serving.ServingRealtimeRobotOpenPI(engine_client=engine)
|
||||
@@ -257,6 +392,29 @@ def test_infer_keeps_session_state_but_uses_unique_engine_request_ids():
|
||||
assert sampling_params_b.extra_args["reset"] is False
|
||||
|
||||
|
||||
def test_infer_yields_event_loop_while_engine_is_running():
|
||||
class DelayedEngine(RecordingEngine):
|
||||
def generate(self, *, prompt, request_id, sampling_params_list):
|
||||
async def _generate():
|
||||
await asyncio.sleep(0.03)
|
||||
yield SimpleNamespace(multimodal_output={"actions": [0.0]})
|
||||
|
||||
return _generate()
|
||||
|
||||
serving = openpi_serving.ServingRealtimeRobotOpenPI(engine_client=DelayedEngine())
|
||||
|
||||
async def run_test():
|
||||
infer_task = asyncio.create_task(serving.infer({"prompt": "pick"}, session_id="session-a", reset=True))
|
||||
ticks = 0
|
||||
while not infer_task.done():
|
||||
await asyncio.sleep(0.005)
|
||||
ticks += 1
|
||||
await infer_task
|
||||
return ticks
|
||||
|
||||
assert asyncio.run(run_test()) >= 2
|
||||
|
||||
|
||||
def test_two_websocket_clients_without_session_id_do_not_conflict(monkeypatch):
|
||||
monkeypatch.setattr(openpi_connection, "_pack", _json_pack)
|
||||
monkeypatch.setattr(openpi_connection, "_unpack", _json_unpack)
|
||||
@@ -362,3 +520,17 @@ def test_extract_actions_does_not_iterate_result_object():
|
||||
actions = serving._extract_actions(IterableResult())
|
||||
|
||||
np.testing.assert_allclose(actions, np.array([[1.0, 2.0, 3.0]], dtype=np.float32))
|
||||
|
||||
|
||||
def test_drop_session_calls_dreamzero_close_hook():
|
||||
closed: list[str] = []
|
||||
pipeline = SimpleNamespace(close_ar_diffusion_session=lambda session_id: closed.append(session_id))
|
||||
engine_client = SimpleNamespace(
|
||||
model_config={"policy_server_config": TEST_POLICY_SERVER_CONFIG},
|
||||
model_runner=SimpleNamespace(pipeline=pipeline),
|
||||
)
|
||||
serving = openpi_serving.ServingRealtimeRobotOpenPI(engine_client=engine_client)
|
||||
|
||||
serving.drop_session("rollout-1")
|
||||
|
||||
assert closed == ["rollout-1"]
|
||||
@@ -18,21 +18,27 @@ import io
|
||||
import json
|
||||
import os
|
||||
import wave
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from pathlib import Path
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import websockets
|
||||
import yaml
|
||||
|
||||
from tests.e2e.online_serving.helpers.minicpmo_4_5_duplex import validated_input_wav
|
||||
from tests.e2e.online_serving.helpers.minicpmo_4_5_duplex import validated_input_wav, validated_soft_interrupt_wav
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.media import (
|
||||
convert_audio_bytes_to_text,
|
||||
cosine_similarity_text,
|
||||
generate_synthetic_audio,
|
||||
)
|
||||
from tests.helpers.runtime import OmniServerParams
|
||||
from tests.helpers.runtime import OmniServer, OmniServerParams, get_model_prefix
|
||||
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
|
||||
from vllm_omni.config.stage_config import load_deploy_config
|
||||
from vllm_omni.entrypoints.duplex.server_vad import (
|
||||
from vllm_omni.engine.duplex.contracts import duplex_resource_request_belongs_to_session
|
||||
from vllm_omni.engine.duplex.vad import (
|
||||
SILERO_VAD_FILENAME,
|
||||
SILERO_VAD_REPO_ID,
|
||||
SILERO_VAD_REVISION,
|
||||
@@ -175,7 +181,7 @@ async def _run_realtime_audio_roundtrip(
|
||||
if event_type == "session.created":
|
||||
continue
|
||||
|
||||
if event_type == "response.audio.delta":
|
||||
if event_type == "response.output_audio.delta":
|
||||
delta_events += 1
|
||||
sr = event.get("sample_rate_hz")
|
||||
if isinstance(sr, int) and sr > 0:
|
||||
@@ -195,7 +201,7 @@ async def _run_realtime_audio_roundtrip(
|
||||
final_text = event.get("text", "") or "".join(text_chunks)
|
||||
continue
|
||||
|
||||
if event_type == "response.audio.done":
|
||||
if event_type == "response.output_audio.done":
|
||||
break
|
||||
|
||||
if event_type == "error":
|
||||
@@ -315,7 +321,7 @@ def _server_vad_pcm16_input() -> bytes:
|
||||
def _assert_realtime_smoke(result: dict) -> None:
|
||||
out_pcm = result["output_pcm"]
|
||||
assert result["delta_events"] >= 1
|
||||
assert out_pcm, "No output PCM from response.audio.delta"
|
||||
assert out_pcm, "No output PCM from response.output_audio.delta"
|
||||
assert len(out_pcm) % 2 == 0
|
||||
assert len(out_pcm) >= 4096, "Output audio unexpectedly small"
|
||||
assert result["output_sample_rate"] > 0
|
||||
@@ -361,6 +367,15 @@ def _assert_realtime_accuracy(
|
||||
|
||||
|
||||
class TestQwen3OmniRealtimeWebSocket:
|
||||
@pytest.fixture(scope="class")
|
||||
def omni_server(self, request, run_level):
|
||||
# Release the legacy turn deployment before the duplex class below
|
||||
# starts another model server on the same pair of GPUs.
|
||||
from tests.helpers.fixtures.runtime import omni_fixture_lock
|
||||
from tests.helpers.runtime import iter_omni_server
|
||||
|
||||
yield from iter_omni_server(request, run_level, omni_fixture_lock)
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
|
||||
@@ -388,7 +403,7 @@ class TestQwen3OmniRealtimeWebSocket:
|
||||
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
|
||||
@pytest.mark.parametrize("omni_server", realtime_async_chunk_server_params, indirect=True)
|
||||
def test_long_audio_response_completes_async_chunk(self, omni_server) -> None:
|
||||
"""Regression for #6474: long async-chunk audio must emit response.audio.done."""
|
||||
"""Regression for #6474: long async-chunk audio must emit response.output_audio.done."""
|
||||
issue_6474_pcm16 = _synthetic_pcm16_input(
|
||||
phrase_text=ISSUE_6474_SYNTH_PHRASE_TEXT,
|
||||
)
|
||||
@@ -450,8 +465,8 @@ class TestQwen3OmniRealtimeWebSocket:
|
||||
"input_audio_buffer.speech_stopped",
|
||||
"input_audio_buffer.committed",
|
||||
"response.created",
|
||||
"response.audio.delta",
|
||||
"response.audio.done",
|
||||
"response.output_audio.delta",
|
||||
"response.output_audio.done",
|
||||
"response.done",
|
||||
]
|
||||
input_item_ids: list[str] = []
|
||||
@@ -459,7 +474,7 @@ class TestQwen3OmniRealtimeWebSocket:
|
||||
for events in turns:
|
||||
event_types = [event["type"] for event in events]
|
||||
for event_type in required_sequence:
|
||||
if event_type != "response.audio.delta":
|
||||
if event_type != "response.output_audio.delta":
|
||||
assert event_types.count(event_type) == 1, event_types
|
||||
positions = [event_types.index(event_type) for event_type in required_sequence]
|
||||
assert positions == sorted(positions)
|
||||
@@ -486,7 +501,7 @@ class TestQwen3OmniRealtimeWebSocket:
|
||||
output_pcm = b"".join(
|
||||
base64.b64decode(event["delta"])
|
||||
for event in events
|
||||
if event["type"] == "response.audio.delta" and event.get("delta")
|
||||
if event["type"] == "response.output_audio.delta" and event.get("delta")
|
||||
)
|
||||
assert output_pcm
|
||||
assert created["id"] == done["id"]
|
||||
@@ -518,3 +533,394 @@ class TestQwen3OmniRealtimeWebSocket:
|
||||
|
||||
_assert_realtime_smoke(result)
|
||||
_assert_realtime_accuracy(result)
|
||||
|
||||
|
||||
# These regressions use the engine-owned Qwen duplex plugin. The older class
|
||||
# above intentionally exercises the separate turn/STT deployment.
|
||||
_DUPLEX_REPEATS = int(os.environ.get("VLLM_OMNI_TEST_QWEN_DUPLEX_REPEATS", "20"))
|
||||
_DUPLEX_LONG_INSTRUCTIONS = "Answer the user's question in English using at least eight complete sentences."
|
||||
_DUPLEX_SHORT_INSTRUCTIONS = "Answer the user's question in one short English sentence."
|
||||
|
||||
|
||||
class _DuplexTestRuntime(NamedTuple):
|
||||
server: OmniServer
|
||||
processor: Any
|
||||
artifacts: Path
|
||||
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def qwen_duplex_server(tmp_path_factory, cached_silero_vad_artifact, run_level):
|
||||
"""Load real weights once for all interruption repetitions and history checks."""
|
||||
from transformers import AutoProcessor
|
||||
|
||||
assert run_level in {"advanced_model", "full_model"}, "Duplex playback acceptance requires real weights"
|
||||
artifacts = tmp_path_factory.mktemp("qwen_duplex_playback")
|
||||
deploy = artifacts / "deploy.yaml"
|
||||
# An absolute base keeps the production YAML's relative inheritance intact
|
||||
# when the test's Silero override lives in a temporary directory.
|
||||
deploy.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"base_config": get_deploy_config_path("qwen3_omni_duplex.yaml"),
|
||||
"duplex_session": {"server_vad_model_path": cached_silero_vad_artifact},
|
||||
}
|
||||
)
|
||||
)
|
||||
assert load_deploy_config(deploy).session_mode == "duplex"
|
||||
model = get_model_prefix() + MODEL
|
||||
processor = AutoProcessor.from_pretrained(model, trust_remote_code=True)
|
||||
with OmniServer(
|
||||
model,
|
||||
["--deploy-config", str(deploy), "--init-timeout", "900", "--stage-init-timeout", "600"],
|
||||
env_dict={"VLLM_OMNI_QWEN_VISUAL_DEBUG_DIR": str(artifacts)},
|
||||
) as server:
|
||||
yield _DuplexTestRuntime(server=server, processor=processor, artifacts=artifacts)
|
||||
|
||||
|
||||
def _duplex_fixture_pcm(path: Path, *, duration_s: float | None = None) -> bytes:
|
||||
"""Replay checked-in speech at the web client's negotiated 24 kHz PCM16 rate."""
|
||||
pcm = np.frombuffer(_pcm16_mono_16k_from_wav_bytes(path.read_bytes()), dtype="<i2")
|
||||
if duration_s is not None:
|
||||
pcm = pcm[: int(duration_s * 16000)]
|
||||
positions = np.arange(len(pcm) * 3 // 2) * (2.0 / 3.0)
|
||||
return np.interp(positions, np.arange(len(pcm)), pcm).astype("<i2").tobytes()
|
||||
|
||||
|
||||
def _duplex_response_id(event: dict) -> str | None:
|
||||
return event.get("response_id") or event.get("response", {}).get("id")
|
||||
|
||||
|
||||
def _duplex_response_events(events: list[dict], response_id: str, event_type: str) -> list[dict]:
|
||||
return [event for event in events if event["type"] == event_type and _duplex_response_id(event) == response_id]
|
||||
|
||||
|
||||
async def _duplex_receive_until(ws, events: list[dict], predicate, *, timeout_s: float = 180) -> None:
|
||||
# One deadline covers the whole operation, including an endless stream that
|
||||
# would otherwise reset a per-recv timeout forever.
|
||||
async def receive() -> None:
|
||||
while not predicate():
|
||||
raw = await ws.recv()
|
||||
assert isinstance(raw, str), "Expected JSON Realtime events"
|
||||
event = json.loads(raw)
|
||||
events.append(event)
|
||||
assert event.get("type") != "error", event
|
||||
|
||||
await asyncio.wait_for(receive(), timeout=timeout_s)
|
||||
|
||||
|
||||
async def _duplex_send_pcm(ws, pcm: bytes, *, paced: bool = False, trailing_silence: bool = True) -> None:
|
||||
if trailing_silence:
|
||||
pcm += bytes(24000 * 2) # One second exceeds the configured 500 ms endpoint.
|
||||
chunk_bytes = 24000 * 2 // 5
|
||||
for offset in range(0, len(pcm), chunk_bytes):
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "input_audio_buffer.append",
|
||||
"audio": base64.b64encode(pcm[offset : offset + chunk_bytes]).decode(),
|
||||
}
|
||||
)
|
||||
)
|
||||
if paced:
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
|
||||
async def _duplex_add_image(ws) -> None:
|
||||
from PIL import Image
|
||||
|
||||
image = io.BytesIO()
|
||||
Image.new("RGB", (448, 336), (32, 96, 160)).save(image, format="JPEG")
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"id": "camera_1",
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": "data:image/jpeg;base64," + base64.b64encode(image.getvalue()).decode(),
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _duplex_recorded_session(runtime, log_path: Path, *, instructions: str, vad: bool = True):
|
||||
events: list[dict] = []
|
||||
server = runtime.server
|
||||
async with websockets.connect(
|
||||
f"ws://{server.host}:{server.port}/v1/realtime?duplex=1", max_size=64 * 1024 * 1024
|
||||
) as ws:
|
||||
failed = True
|
||||
try:
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": {
|
||||
"model": server.model,
|
||||
"instructions": instructions,
|
||||
"temperature": 0,
|
||||
"max_output_tokens": 384 if vad else 64,
|
||||
"audio": {
|
||||
"input": {
|
||||
"format": {"type": "audio/pcm", "rate": 24000},
|
||||
"turn_detection": {
|
||||
"type": "server_vad",
|
||||
"threshold": 0.5,
|
||||
"prefix_padding_ms": 300,
|
||||
"silence_duration_ms": 500,
|
||||
"create_response": True,
|
||||
"interrupt_response": True,
|
||||
}
|
||||
if vad
|
||||
else None,
|
||||
},
|
||||
"output": {"format": {"type": "audio/pcm", "rate": 24000}},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
await _duplex_receive_until(ws, events, lambda: any(e["type"] == "session.updated" for e in events))
|
||||
session = next(e["session"] for e in events if e["type"] == "session.updated")
|
||||
if vad:
|
||||
accepted = session["audio"]["input"]["turn_detection"]
|
||||
assert accepted["interrupt_response"] is True
|
||||
assert accepted["create_response"] is True
|
||||
await _duplex_add_image(ws)
|
||||
yield ws, events, session["id"]
|
||||
failed = False
|
||||
finally:
|
||||
# Closing the transport alone leaves a resumable session occupying
|
||||
# the deployment's single session slot. Always release the session.
|
||||
try:
|
||||
await ws.send(json.dumps({"type": "session.close"}))
|
||||
await _duplex_receive_until(
|
||||
ws, events, lambda: any(e["type"] == "session.closed" for e in events), timeout_s=15
|
||||
)
|
||||
except Exception:
|
||||
if not failed:
|
||||
raise
|
||||
finally:
|
||||
log_path.write_text(json.dumps(events, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def _duplex_assert_prompt(
|
||||
runtime,
|
||||
session_id: str,
|
||||
*,
|
||||
audio_count: int,
|
||||
instructions: str,
|
||||
request_count: int,
|
||||
assistant_texts: list[str] | None = None,
|
||||
):
|
||||
captures = []
|
||||
for path in runtime.artifacts.glob("*/input.json"):
|
||||
capture = json.loads(path.read_text())
|
||||
if duplex_resource_request_belongs_to_session(capture["request_id"], session_id):
|
||||
captures.append((path.stat().st_mtime_ns, capture))
|
||||
assert len(captures) == request_count, captures
|
||||
capture = max(captures, key=lambda item: item[0])[1]
|
||||
assert capture["audio_count"] == audio_count
|
||||
# The camera belongs to the first user turn and leaves with its audio.
|
||||
image_retained = request_count <= 4
|
||||
assert len(capture["images"]) == int(image_retained)
|
||||
messages: list[dict[str, Any]] = [{"role": "system", "content": instructions}]
|
||||
if assistant_texts is None:
|
||||
assistant_texts = [""] * (audio_count - 1)
|
||||
assert len(assistant_texts) == audio_count - 1
|
||||
for index in range(audio_count):
|
||||
if index:
|
||||
messages.append({"role": "assistant", "content": assistant_texts[index - 1]})
|
||||
parts = [{"type": "image"}] if index == 0 and image_retained else []
|
||||
parts.append({"type": "audio"})
|
||||
messages.append({"role": "user", "content": parts})
|
||||
expected = runtime.processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
||||
assert capture["prompt"] == expected
|
||||
# Use the checkpoint tokenizer too: a repr(messages) processor stub cannot
|
||||
# establish that an empty assistant survives as a real turn separator.
|
||||
tokenizer = runtime.processor.tokenizer
|
||||
actual_ids = tokenizer.encode(capture["prompt"], add_special_tokens=False)
|
||||
separator_ids = tokenizer.encode("<|im_start|>assistant\n<|im_end|>\n", add_special_tokens=False)
|
||||
assert sum(
|
||||
actual_ids[index : index + len(separator_ids)] == separator_ids for index in range(len(actual_ids))
|
||||
) == assistant_texts.count("")
|
||||
|
||||
|
||||
def _duplex_assert_completed_audio(events: list[dict], response_id: str) -> None:
|
||||
terminal = _duplex_response_events(events, response_id, "response.done")
|
||||
assert len(terminal) == 1, terminal
|
||||
assert terminal[0]["response"]["status"] == "completed", terminal
|
||||
audio = _duplex_response_events(events, response_id, "response.output_audio.delta")
|
||||
assert sum(len(base64.b64decode(event["delta"])) for event in audio) > 4800
|
||||
assert any(
|
||||
event.get("delta")
|
||||
for event in events
|
||||
if _duplex_response_id(event) == response_id
|
||||
and event["type"] in {"response.output_text.delta", "response.output_audio_transcript.delta"}
|
||||
), "The follow-up must produce text as well as audio"
|
||||
|
||||
|
||||
async def _run_duplex_playback_interruption(runtime, log_path: Path, *, generation_finished: bool) -> None:
|
||||
async with _duplex_recorded_session(runtime, log_path, instructions=_DUPLEX_LONG_INSTRUCTIONS) as (
|
||||
ws,
|
||||
events,
|
||||
session_id,
|
||||
):
|
||||
await _duplex_send_pcm(ws, _duplex_fixture_pcm(validated_input_wav()))
|
||||
await _duplex_receive_until(ws, events, lambda: any(e["type"] == "response.created" for e in events))
|
||||
old_id = next(_duplex_response_id(e) for e in events if e["type"] == "response.created")
|
||||
assert old_id
|
||||
await _duplex_receive_until(
|
||||
ws,
|
||||
events,
|
||||
lambda: (
|
||||
sum(
|
||||
len(base64.b64decode(e["delta"]))
|
||||
for e in _duplex_response_events(events, old_id, "response.output_audio.delta")
|
||||
)
|
||||
> 4800
|
||||
),
|
||||
)
|
||||
if generation_finished:
|
||||
await _duplex_receive_until(ws, events, lambda: _duplex_response_events(events, old_id, "response.done"))
|
||||
await ws.send(
|
||||
json.dumps({"type": "playback.ack", "response_id": old_id, "played_ms": 100, "committed_ms": 100})
|
||||
)
|
||||
await _duplex_receive_until(ws, events, lambda: any(e["type"] == "playback.acknowledged" for e in events))
|
||||
ack = next(e["event"] for e in events if e["type"] == "playback.acknowledged")
|
||||
assert ack["item_id"] == f"item_{old_id}"
|
||||
assert ack["committed_ms"] == 100
|
||||
assert ack["playback"]["sent_ms"] > 100
|
||||
cursor = len(events)
|
||||
sender = asyncio.create_task(
|
||||
_duplex_send_pcm(ws, _duplex_fixture_pcm(validated_soft_interrupt_wav(), duration_s=4.7), paced=True)
|
||||
)
|
||||
try:
|
||||
await _duplex_receive_until(
|
||||
ws, events, lambda: any(e["type"] == "input_audio_buffer.speech_started" for e in events[cursor:])
|
||||
)
|
||||
speech_index = next(
|
||||
i for i in range(cursor, len(events)) if events[i]["type"] == "input_audio_buffer.speech_started"
|
||||
)
|
||||
if not generation_finished:
|
||||
assert not _duplex_response_events(events[:speech_index], old_id, "response.done"), (
|
||||
"Generation completed before speech interruption; the in-progress scenario was not exercised"
|
||||
)
|
||||
await _duplex_receive_until(
|
||||
ws,
|
||||
events,
|
||||
lambda: any(e["type"] == "response.done" and _duplex_response_id(e) != old_id for e in events[cursor:]),
|
||||
)
|
||||
await sender
|
||||
finally:
|
||||
sender.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await sender
|
||||
new_id = next(
|
||||
_duplex_response_id(e)
|
||||
for e in events[cursor:]
|
||||
if e["type"] == "response.created" and _duplex_response_id(e) != old_id
|
||||
)
|
||||
# Include everything observed through session.closed when checking duplicates.
|
||||
terminals = _duplex_response_events(events, old_id, "response.done")
|
||||
assert len(terminals) == 1, terminals
|
||||
assert terminals[0]["response"]["status"] == ("completed" if generation_finished else "cancelled")
|
||||
if generation_finished:
|
||||
cleared = _duplex_response_events(events, old_id, "output_audio_buffer.cleared")
|
||||
assert len(cleared) == 1, cleared
|
||||
boundary = events.index(cleared[0])
|
||||
else:
|
||||
boundary = events.index(terminals[0])
|
||||
assert not _duplex_response_events(events[boundary + 1 :], old_id, "response.output_audio.delta")
|
||||
assert new_id and new_id != old_id
|
||||
_duplex_assert_completed_audio(events, new_id)
|
||||
_duplex_assert_prompt(runtime, session_id, audio_count=2, instructions=_DUPLEX_LONG_INSTRUCTIONS, request_count=2)
|
||||
|
||||
|
||||
async def _run_duplex_history_pruning(runtime, log_path: Path) -> None:
|
||||
async with _duplex_recorded_session(runtime, log_path, instructions=_DUPLEX_SHORT_INSTRUCTIONS, vad=False) as (
|
||||
ws,
|
||||
events,
|
||||
session_id,
|
||||
):
|
||||
response_ids = []
|
||||
heard_texts: list[str] = []
|
||||
pcm = _duplex_fixture_pcm(validated_input_wav())
|
||||
for turn in range(6):
|
||||
cursor = len(events)
|
||||
await _duplex_send_pcm(ws, pcm, trailing_silence=False)
|
||||
await ws.send(json.dumps({"type": "input_audio_buffer.commit"}))
|
||||
await ws.send(json.dumps({"type": "response.create"}))
|
||||
await _duplex_receive_until(ws, events, lambda: any(e["type"] == "response.done" for e in events[cursor:]))
|
||||
response_id = next(_duplex_response_id(e) for e in events[cursor:] if e["type"] == "response.done")
|
||||
assert response_id and response_id not in response_ids
|
||||
response_ids.append(response_id)
|
||||
_duplex_assert_completed_audio(events, response_id)
|
||||
_duplex_assert_prompt(
|
||||
runtime,
|
||||
session_id,
|
||||
audio_count=min(turn + 1, 4),
|
||||
instructions=_DUPLEX_SHORT_INSTRUCTIONS,
|
||||
request_count=turn + 1,
|
||||
assistant_texts=heard_texts[-3:],
|
||||
)
|
||||
# Complete playback so pruning is checked against real assistant
|
||||
# answers paired with retained user audio. Repeated empty assistant
|
||||
# turns can make Qwen emit EOS; interruption tests cover those slots.
|
||||
done = _duplex_response_events(events, response_id, "response.done")[0]["response"]
|
||||
played_ms = done["metadata"]["playback"]["sent_ms"]
|
||||
heard_texts.append(
|
||||
"".join(
|
||||
e["delta"]
|
||||
for e in _duplex_response_events(events, response_id, "response.output_audio_transcript.delta")
|
||||
).strip()
|
||||
)
|
||||
assert heard_texts[-1]
|
||||
cursor = len(events)
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "playback.ack",
|
||||
"response_id": response_id,
|
||||
"played_ms": played_ms,
|
||||
"committed_ms": played_ms,
|
||||
}
|
||||
)
|
||||
)
|
||||
await _duplex_receive_until(
|
||||
ws, events, lambda: any(e["type"] == "playback.acknowledged" for e in events[cursor:])
|
||||
)
|
||||
ack = next(e["event"] for e in events[cursor:] if e["type"] == "playback.acknowledged")
|
||||
assert ack["item_id"] == f"item_{response_id}"
|
||||
assert ack["committed_ms"] == ack["playback"]["sent_ms"] == played_ms
|
||||
assert ack["history_committed"] is True
|
||||
for response_id in response_ids:
|
||||
_duplex_assert_completed_audio(events, response_id)
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=2)
|
||||
class TestQwen3OmniDuplexPlayback:
|
||||
@pytest.mark.parametrize("generation_finished", [False, True], ids=["generating", "queued-playback"])
|
||||
@pytest.mark.parametrize("repetition", range(_DUPLEX_REPEATS))
|
||||
def test_speech_interrupts_playback(
|
||||
self, qwen_duplex_server, tmp_path: Path, generation_finished: bool, repetition: int
|
||||
):
|
||||
"""Reuse one live deployment; every repetition opens an independent call."""
|
||||
asyncio.run(
|
||||
_run_duplex_playback_interruption(
|
||||
qwen_duplex_server, tmp_path / f"events-{repetition}.json", generation_finished=generation_finished
|
||||
)
|
||||
)
|
||||
|
||||
def test_mixed_image_audio_history_pruning(self, qwen_duplex_server, tmp_path: Path):
|
||||
asyncio.run(_run_duplex_history_pruning(qwen_duplex_server, tmp_path / "events.json"))
|
||||
@@ -0,0 +1,95 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
from collections import OrderedDict
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from vllm_omni.entrypoints.openai.serving_speech import (
|
||||
_REF_AUDIO_RESOLVE_CACHE_MAX_BYTES,
|
||||
_REF_AUDIO_RESOLVE_CACHE_MAX_ENTRIES,
|
||||
OmniOpenAIServingSpeech,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache():
|
||||
server = object.__new__(OmniOpenAIServingSpeech)
|
||||
server._diffusion_mode = False
|
||||
server.model_config = None
|
||||
server._ref_audio_resolve_cache = OrderedDict()
|
||||
server._ref_audio_resolve_cache_bytes = 0
|
||||
server._ref_audio_resolve_cache_max_entries = _REF_AUDIO_RESOLVE_CACHE_MAX_ENTRIES
|
||||
server._ref_audio_resolve_cache_max_bytes = _REF_AUDIO_RESOLVE_CACHE_MAX_BYTES
|
||||
server._ref_audio_model_artifact_ready = set()
|
||||
server._request_ref_audio_artifact_keys = {}
|
||||
return server
|
||||
|
||||
|
||||
def _put(cache, key, samples, artifact=None):
|
||||
cache._put_resolved_ref_audio(key, samples, 24000, artifact or key)
|
||||
|
||||
|
||||
def test_compact_storage_and_default_capacity(cache):
|
||||
assert cache._ref_audio_resolve_cache_max_entries == 1024
|
||||
assert cache._ref_audio_resolve_cache_max_bytes == 512 * 1024 * 1024
|
||||
_put(cache, "a", [0.0, 0.25, -0.5, 1.0])
|
||||
value = cache._ref_audio_resolve_cache["a"]
|
||||
assert value[0].dtype == np.float32
|
||||
assert value[2] == cache._ref_audio_resolve_cache_bytes == 16
|
||||
|
||||
|
||||
def test_cached_float32_roundtrip_and_mutation_isolation(cache):
|
||||
source = "data:audio/wav;base64,already-cached"
|
||||
key = hashlib.sha1(source.encode()).hexdigest()
|
||||
values = np.random.default_rng(42).standard_normal(48000).astype(np.float32).tolist()
|
||||
expected = values.copy()
|
||||
_put(cache, key, values)
|
||||
values[0] = 99.0
|
||||
first, sample_rate, _ = asyncio.run(cache._resolve_ref_audio(source))
|
||||
assert first == expected
|
||||
first[0] = 100.0
|
||||
second, _, _ = asyncio.run(cache._resolve_ref_audio(source))
|
||||
assert sample_rate == 24000
|
||||
assert second == expected
|
||||
|
||||
|
||||
def test_entry_eviction_invalidates_artifact_readiness(cache):
|
||||
cache._ref_audio_resolve_cache_max_entries = 2
|
||||
_put(cache, "a", [0.0])
|
||||
cache._ref_audio_model_artifact_ready.add(("a", False))
|
||||
_put(cache, "b", [0.0])
|
||||
_put(cache, "c", [0.0])
|
||||
assert list(cache._ref_audio_resolve_cache) == ["b", "c"]
|
||||
assert ("a", False) not in cache._ref_audio_model_artifact_ready
|
||||
|
||||
|
||||
def test_byte_budget_evicts_and_rejects_oversized_entries(cache):
|
||||
cache._ref_audio_resolve_cache_max_bytes = 16
|
||||
_put(cache, "a", [0.0] * 3)
|
||||
_put(cache, "b", [0.0] * 3)
|
||||
assert list(cache._ref_audio_resolve_cache) == ["b"]
|
||||
assert cache._ref_audio_resolve_cache_bytes == 12
|
||||
_put(cache, "too-large", [0.0] * 5)
|
||||
assert "too-large" not in cache._ref_audio_resolve_cache
|
||||
|
||||
|
||||
def test_replacement_accounts_bytes_and_preserves_referenced_artifact(cache):
|
||||
_put(cache, "a", [0.0] * 3, "same")
|
||||
_put(cache, "alias", [0.0] * 2, "same")
|
||||
cache._ref_audio_model_artifact_ready.add(("same", False))
|
||||
_put(cache, "a", [1.0] * 4, "new")
|
||||
assert cache._ref_audio_resolve_cache_bytes == 24
|
||||
assert ("same", False) in cache._ref_audio_model_artifact_ready
|
||||
|
||||
|
||||
def test_pool_accepts_666_references(cache):
|
||||
for index in range(666):
|
||||
_put(cache, str(index), [0.0] * 8)
|
||||
assert len(cache._ref_audio_resolve_cache) == 666
|
||||
assert cache._ref_audio_resolve_cache_bytes == 666 * 8 * 4
|
||||
@@ -0,0 +1,105 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("SPEAKER_SAMPLES_DIR", str(tmp_path))
|
||||
return OmniOpenAIServingSpeech.__new__(OmniOpenAIServingSpeech)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stereo", [False, True])
|
||||
def test_finalized_waveform_owns_contiguous_numeric_storage(server, stereo):
|
||||
import gc
|
||||
|
||||
source = np.linspace(-1, 1, 96000, dtype=np.float64).reshape(48000, 2)
|
||||
decoded = source if stereo else source[:, 0]
|
||||
expected = np.asarray(decoded, dtype=np.float32)
|
||||
if stereo:
|
||||
expected = expected.mean(axis=-1)
|
||||
waveform, sr, artifact, duration = server._finalize_fetched_ref_audio(decoded, 24000)
|
||||
assert waveform.dtype == np.float32
|
||||
assert waveform.flags.c_contiguous and waveform.flags.owndata
|
||||
assert not np.shares_memory(waveform, decoded)
|
||||
assert not gc.is_tracked(waveform)
|
||||
np.testing.assert_array_equal(waveform, expected)
|
||||
assert artifact == server._make_ref_audio_artifact_cache_key(expected, sr)
|
||||
assert duration == 2.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_array_resolver_reuses_buffer_and_legacy_lists_are_temporary(server, mocker):
|
||||
server._init_speaker_storage()
|
||||
server._diffusion_mode = True
|
||||
server._allowed_local_media_path = ""
|
||||
server._ref_audio_cache_key = mocker.AsyncMock(return_value="locator")
|
||||
server._media_connector = mocker.Mock()
|
||||
server._media_connector.fetch_audio_async = mocker.AsyncMock(
|
||||
return_value=(np.linspace(-1, 1, 24000, dtype=np.float32), 24000)
|
||||
)
|
||||
first, sr, key = await server._resolve_ref_audio_array("reference")
|
||||
second, _, _ = await server._resolve_ref_audio_array("reference")
|
||||
legacy, _, _ = await server._resolve_ref_audio("reference")
|
||||
assert first is second
|
||||
assert isinstance(legacy, list)
|
||||
np.testing.assert_array_equal(first, legacy)
|
||||
legacy[0] = 100
|
||||
assert first[0] == -1
|
||||
assert server._ref_audio_resolve_cache[key][0] is first
|
||||
assert server._ref_audio_resolve_cache_bytes == first.nbytes == 96000
|
||||
server._media_connector.fetch_audio_async.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("variant", [None, "local", "delay", "realtime"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_moss_array_resolution_keeps_nano_list_transport(mocker, variant):
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import SpeechServingContext
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.moss_tts import _MossTTSAdapterBase
|
||||
|
||||
server = mocker.Mock()
|
||||
server._resolve_ref_audio = mocker.AsyncMock(return_value=([0.0], 24000, "key"))
|
||||
waveform = np.zeros(1, dtype=np.float32)
|
||||
server._resolve_ref_audio_array = mocker.AsyncMock(return_value=(waveform, 24000, "key"))
|
||||
adapter = _MossTTSAdapterBase.__new__(_MossTTSAdapterBase)
|
||||
adapter.ctx = SpeechServingContext(server=server)
|
||||
adapter._moss_variant = variant
|
||||
result, _, _ = await adapter._resolve_ref_audio("reference")
|
||||
if variant is None:
|
||||
assert isinstance(result, list)
|
||||
server._resolve_ref_audio_array.assert_not_awaited()
|
||||
else:
|
||||
assert result is waveform
|
||||
server._resolve_ref_audio.assert_not_awaited()
|
||||
|
||||
|
||||
def test_waveform_cache_eviction_releases_owned_buffer(server):
|
||||
import weakref
|
||||
|
||||
server._init_speaker_storage()
|
||||
server._ref_audio_resolve_cache_max_entries = 1
|
||||
waveform = np.zeros(24000, dtype=np.float32)
|
||||
ref = weakref.ref(waveform)
|
||||
server._put_resolved_ref_audio("first", waveform, 24000, "artifact-first")
|
||||
del waveform
|
||||
assert ref() is not None
|
||||
server._put_resolved_ref_audio("second", np.zeros(24000, dtype=np.float32), 24000, "artifact-second")
|
||||
assert ref() is None
|
||||
assert server._ref_audio_resolve_cache_bytes == 96000
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entries, budget", [(1, 1000), (10, 8)])
|
||||
def test_numeric_waveform_cache_limits(server, entries, budget):
|
||||
server._init_speaker_storage()
|
||||
server._ref_audio_resolve_cache_max_entries = entries
|
||||
server._ref_audio_resolve_cache_max_bytes = budget
|
||||
server._put_resolved_ref_audio("a", np.zeros(2, dtype=np.float32), 24000, "artifact-a")
|
||||
server._put_resolved_ref_audio("b", np.ones(2, dtype=np.float32), 24000, "artifact-b")
|
||||
assert list(server._ref_audio_resolve_cache) == ["b"]
|
||||
assert server._ref_audio_resolve_cache_bytes == 8
|
||||
@@ -1,429 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import sys
|
||||
import threading
|
||||
import wave
|
||||
import weakref
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from vllm_omni.entrypoints.duplex.protocol import (
|
||||
DuplexSession,
|
||||
DuplexSessionConfig,
|
||||
)
|
||||
from vllm_omni.entrypoints.duplex.server_vad import (
|
||||
ServerVADConfig,
|
||||
ServerVADFrame,
|
||||
ServerVADPipeline,
|
||||
SileroVADBackend,
|
||||
SileroVADBackendProvider,
|
||||
SpeechEndpointDecision,
|
||||
ThresholdEndpointPolicy,
|
||||
parse_session_turn_detection,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class SequenceDetector:
|
||||
frame_samples = 160
|
||||
|
||||
def __init__(self, probabilities: list[float]) -> None:
|
||||
self.probabilities = probabilities
|
||||
|
||||
def new_state(self) -> int:
|
||||
return 0
|
||||
|
||||
def infer(self, frame: np.ndarray, state: object) -> tuple[float, object]:
|
||||
index = int(state)
|
||||
probability = self.probabilities[index] if index < len(self.probabilities) else 0.0
|
||||
return probability, index + 1
|
||||
|
||||
|
||||
def test_server_vad_config_defaults_and_validation():
|
||||
config = ServerVADConfig.from_value({"type": "server_vad"})
|
||||
|
||||
assert config.type == "server_vad"
|
||||
assert config.threshold == 0.5
|
||||
assert config.prefix_padding_ms == 300
|
||||
assert config.silence_duration_ms == 500
|
||||
assert config.create_response is True
|
||||
assert config.interrupt_response is None
|
||||
assert "interrupt_response" not in config.as_dict()
|
||||
assert config.min_speech_duration_ms is None
|
||||
assert config.as_dict()["type"] == config.type
|
||||
|
||||
interrupting = ServerVADConfig.from_value(
|
||||
{
|
||||
"type": "server_vad",
|
||||
"interrupt_response": True,
|
||||
"min_speech_duration_ms": 96,
|
||||
}
|
||||
)
|
||||
assert interrupting.interrupt_response is True
|
||||
assert interrupting.min_speech_duration_ms == 96
|
||||
for value in (None, 0, "true"):
|
||||
with pytest.raises(ValueError, match="interrupt_response must be a boolean"):
|
||||
ServerVADConfig.from_value({"type": "server_vad", "interrupt_response": value})
|
||||
for enabled in (False, True):
|
||||
_, aliased = parse_session_turn_detection(
|
||||
{
|
||||
"turn_detection": {"type": "server_vad"},
|
||||
"audio": {"input": {"turn_detection": {"type": "server_vad", "interrupt_response": enabled}}},
|
||||
}
|
||||
)
|
||||
assert aliased is not None and aliased.interrupt_response is enabled
|
||||
with pytest.raises(ValueError, match="Unknown"):
|
||||
ServerVADConfig.from_value({"type": "server_vad", "semantic_eagerness": "high"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("prefix_padding_ms", [0, 10, 30])
|
||||
async def test_server_vad_pipeline_handles_arbitrary_chunk_boundaries(prefix_padding_ms: int):
|
||||
detector = SequenceDetector([0.0, 0.9, 0.8, 0.0, 0.0])
|
||||
pipeline = ServerVADPipeline(
|
||||
detector,
|
||||
ServerVADConfig(prefix_padding_ms=prefix_padding_ms, silence_duration_ms=20),
|
||||
)
|
||||
samples = np.zeros(detector.frame_samples * 5, dtype=np.float32)
|
||||
|
||||
batches = [
|
||||
await pipeline.push(samples[:73]),
|
||||
await pipeline.push(samples[73:431]),
|
||||
await pipeline.push(samples[431:]),
|
||||
]
|
||||
frames = [frame for batch in batches for frame in batch.frames]
|
||||
|
||||
assert len(frames) == 5
|
||||
assert [index for index, frame in enumerate(frames) if frame.decision.speech_started] == [1]
|
||||
assert [index for index, frame in enumerate(frames) if frame.decision.speech_stopped] == [4]
|
||||
assert frames[1].decision.audio_start_ms == max(0, 10 - prefix_padding_ms)
|
||||
assert frames[4].decision.audio_end_ms == 50
|
||||
|
||||
pipeline.reset()
|
||||
reset_batch = await pipeline.push(samples)
|
||||
assert [index for index, frame in enumerate(reset_batch.frames) if frame.decision.speech_started] == [1]
|
||||
assert [index for index, frame in enumerate(reset_batch.frames) if frame.decision.speech_stopped] == [4]
|
||||
# Keep session-relative timestamps, but never reference prefix audio
|
||||
# discarded by reset at 50 ms.
|
||||
assert reset_batch.frames[1].decision.audio_start_ms == max(50, 60 - prefix_padding_ms)
|
||||
assert reset_batch.frames[4].decision.audio_end_ms == 100
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_vad_retained_audio_does_not_keep_input_chunk():
|
||||
pipeline = ServerVADPipeline(SequenceDetector([]), ServerVADConfig())
|
||||
session = DuplexSession("server-vad-memory", DuplexSessionConfig())
|
||||
samples = np.zeros(160 * 100 + 1, dtype=np.float32)
|
||||
source_ref = weakref.ref(samples)
|
||||
batch = await pipeline.push(samples)
|
||||
session.append_server_vad_frame(
|
||||
batch.frames[-1].samples,
|
||||
speech_started=False,
|
||||
speech_stopped=False,
|
||||
prefix_samples=80,
|
||||
)
|
||||
session.append_server_vad_frame(
|
||||
batch.frames[-1].samples,
|
||||
speech_started=True,
|
||||
speech_stopped=False,
|
||||
prefix_samples=80,
|
||||
)
|
||||
del batch, samples
|
||||
# Allow the completed to_thread callback to release its result, too.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert source_ref() is None
|
||||
assert pipeline.scratch_bytes == 4
|
||||
assert session.server_vad_utterance_bytes == (80 + 160) * 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_vad_pipeline_pcm16_resampling_is_split_invariant():
|
||||
sample_index = np.arange(2_400, dtype=np.int32)
|
||||
source = ((sample_index * 7919) % 65_536 - 32_768).astype("<i2")
|
||||
# Supply ample silence after the signal so the streaming resampler can
|
||||
# emit a complete set of VAD frames without depending on its FIR width.
|
||||
right_context = np.zeros(240, dtype="<i2")
|
||||
continuous_source = np.concatenate((source, right_context))
|
||||
|
||||
async def process(chunk_sizes: list[int]) -> tuple[np.ndarray, list[SpeechEndpointDecision]]:
|
||||
pipeline = ServerVADPipeline(
|
||||
SequenceDetector([0.0] * 10),
|
||||
ServerVADConfig(),
|
||||
)
|
||||
frames: list[ServerVADFrame] = []
|
||||
offset = 0
|
||||
for chunk_size in chunk_sizes:
|
||||
chunk = continuous_source[offset : offset + chunk_size]
|
||||
batch = await pipeline.push_pcm16(chunk.tobytes(), source_sample_rate_hz=24_000)
|
||||
frames.extend(batch.frames)
|
||||
offset += chunk_size
|
||||
assert offset == continuous_source.size
|
||||
return np.concatenate([frame.samples for frame in frames]), [frame.decision for frame in frames]
|
||||
|
||||
whole_samples, whole_decisions = await process([continuous_source.size])
|
||||
split_samples, split_decisions = await process([7, 13, 511, 512, 513, continuous_source.size - 1_556])
|
||||
|
||||
np.testing.assert_array_equal(split_samples, whole_samples)
|
||||
assert whole_samples.size == 1_600
|
||||
assert split_decisions == whole_decisions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_vad_pipeline_reset_clears_resampler_and_source_rate_lock():
|
||||
pipeline = ServerVADPipeline(
|
||||
SequenceDetector([0.0]),
|
||||
ServerVADConfig(),
|
||||
)
|
||||
|
||||
batch = await pipeline.push_pcm16(
|
||||
np.asarray([30_000, -30_000], dtype="<i2").tobytes(),
|
||||
source_sample_rate_hz=24_000,
|
||||
)
|
||||
assert not batch.frames
|
||||
|
||||
pipeline.reset()
|
||||
|
||||
source = np.zeros(160, dtype="<i2")
|
||||
source[:3] = [-32768, 0, 32767]
|
||||
batch = await pipeline.push_pcm16(source.tobytes(), source_sample_rate_hz=16_000)
|
||||
|
||||
assert len(batch.frames) == 1
|
||||
np.testing.assert_array_equal(
|
||||
batch.frames[0].samples[:3],
|
||||
np.asarray([-1.0, 0.0, 32767 / 32768], dtype=np.float32),
|
||||
)
|
||||
|
||||
|
||||
def test_threshold_endpoint_policy_uses_silero_v62_exit_hysteresis():
|
||||
policy = ThresholdEndpointPolicy(
|
||||
ServerVADConfig(threshold=0.5, silence_duration_ms=20),
|
||||
sample_rate_hz=16_000,
|
||||
)
|
||||
frame_samples = 160
|
||||
|
||||
started = policy.update(0.8, frame_start_sample=0, frame_samples=frame_samples)
|
||||
assert started.speech_started is True
|
||||
|
||||
# Values below the activation threshold but above threshold - 0.15 must
|
||||
# not begin a silence candidate or close an active speech segment.
|
||||
for frame_index in range(1, 5):
|
||||
decision = policy.update(
|
||||
0.4,
|
||||
frame_start_sample=frame_index * frame_samples,
|
||||
frame_samples=frame_samples,
|
||||
)
|
||||
assert decision.speech_stopped is False
|
||||
|
||||
first_silence = policy.update(
|
||||
0.2,
|
||||
frame_start_sample=5 * frame_samples,
|
||||
frame_samples=frame_samples,
|
||||
)
|
||||
stopped = policy.update(
|
||||
0.2,
|
||||
frame_start_sample=6 * frame_samples,
|
||||
frame_samples=frame_samples,
|
||||
)
|
||||
|
||||
assert first_silence.speech_stopped is False
|
||||
assert stopped.speech_stopped is True
|
||||
|
||||
|
||||
def test_threshold_endpoint_policy_honors_minimum_speech_duration():
|
||||
policy = ThresholdEndpointPolicy(
|
||||
ServerVADConfig(prefix_padding_ms=0, min_speech_duration_ms=25),
|
||||
sample_rate_hz=16_000,
|
||||
)
|
||||
frame_samples = 160
|
||||
|
||||
assert not policy.update(0.9, frame_start_sample=0, frame_samples=frame_samples).speech_started
|
||||
assert not policy.update(0.9, frame_start_sample=160, frame_samples=frame_samples).speech_started
|
||||
started = policy.update(0.9, frame_start_sample=320, frame_samples=frame_samples)
|
||||
|
||||
assert started.speech_started is True
|
||||
assert started.audio_start_ms == 0
|
||||
|
||||
|
||||
def test_threshold_endpoint_policy_clamps_silero_exit_threshold():
|
||||
policy = ThresholdEndpointPolicy(
|
||||
ServerVADConfig(threshold=0.1, silence_duration_ms=20),
|
||||
sample_rate_hz=16_000,
|
||||
)
|
||||
frame_samples = 160
|
||||
|
||||
started = policy.update(0.9, frame_start_sample=0, frame_samples=frame_samples)
|
||||
assert started.speech_started is True
|
||||
|
||||
first_silence = policy.update(
|
||||
0.0,
|
||||
frame_start_sample=frame_samples,
|
||||
frame_samples=frame_samples,
|
||||
)
|
||||
stopped = policy.update(
|
||||
0.0,
|
||||
frame_start_sample=2 * frame_samples,
|
||||
frame_samples=frame_samples,
|
||||
)
|
||||
|
||||
assert first_silence.speech_stopped is False
|
||||
assert stopped.speech_stopped is True
|
||||
# The committed audio ends at 30 ms: 10 ms of speech followed by the
|
||||
# 20 ms of silence required to detect the endpoint.
|
||||
assert stopped.audio_end_ms == 30
|
||||
assert stopped.endpoint_delay_ms == 20
|
||||
|
||||
|
||||
def test_silero_backend_matches_upstream_v62_streaming_contract(monkeypatch, tmp_path):
|
||||
class FakeSessionOptions:
|
||||
inter_op_num_threads = 0
|
||||
intra_op_num_threads = 0
|
||||
|
||||
class FakeInferenceSession:
|
||||
def __init__(self, path: str, *, providers: list[str], sess_options: object) -> None:
|
||||
self.providers = providers
|
||||
self.sess_options = sess_options
|
||||
self.calls: list[dict[str, np.ndarray]] = []
|
||||
self.run_barrier: threading.Barrier | None = None
|
||||
sessions.append(self)
|
||||
|
||||
def get_inputs(self) -> list[SimpleNamespace]:
|
||||
return [SimpleNamespace(name=name) for name in ("input", "state", "sr")]
|
||||
|
||||
def run(self, _outputs: object, inputs: dict[str, np.ndarray]) -> list[np.ndarray]:
|
||||
self.calls.append({name: np.array(value, copy=True) for name, value in inputs.items()})
|
||||
if self.run_barrier is not None:
|
||||
self.run_barrier.wait(timeout=5)
|
||||
return [
|
||||
np.asarray([[0.75]], dtype=np.float32),
|
||||
np.full((2, 1, 128), len(self.calls), dtype=np.float32),
|
||||
]
|
||||
|
||||
sessions: list[FakeInferenceSession] = []
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"onnxruntime",
|
||||
SimpleNamespace(
|
||||
SessionOptions=FakeSessionOptions,
|
||||
InferenceSession=FakeInferenceSession,
|
||||
),
|
||||
)
|
||||
|
||||
model_path = tmp_path / "silero_vad.onnx"
|
||||
model_path.write_bytes(b"fake-model")
|
||||
backend = SileroVADBackend(model_path)
|
||||
session = sessions[0]
|
||||
|
||||
assert session.providers == ["CPUExecutionProvider"]
|
||||
assert session.sess_options.inter_op_num_threads == 1
|
||||
assert session.sess_options.intra_op_num_threads == 1
|
||||
assert session.calls[0]["input"].shape == (1, 576)
|
||||
assert session.calls[0]["state"].shape == (2, 1, 128)
|
||||
np.testing.assert_array_equal(session.calls[0]["state"], np.zeros((2, 1, 128), dtype=np.float32))
|
||||
assert session.calls[0]["sr"].shape == ()
|
||||
assert session.calls[0]["sr"].item() == 16_000
|
||||
|
||||
state = backend.new_state()
|
||||
first_frame = np.arange(backend.frame_samples, dtype=np.float32)
|
||||
probability, state = backend.infer(first_frame, state)
|
||||
first_call = session.calls[1]
|
||||
|
||||
assert probability == pytest.approx(0.75)
|
||||
np.testing.assert_array_equal(
|
||||
first_call["input"][:, : backend.context_samples],
|
||||
np.zeros((1, backend.context_samples), dtype=np.float32),
|
||||
)
|
||||
np.testing.assert_array_equal(
|
||||
first_call["input"][:, backend.context_samples :],
|
||||
first_frame[None, :],
|
||||
)
|
||||
np.testing.assert_array_equal(first_call["state"], np.zeros((2, 1, 128), dtype=np.float32))
|
||||
|
||||
second_frame = -first_frame
|
||||
backend.infer(second_frame, state)
|
||||
second_call = session.calls[2]
|
||||
np.testing.assert_array_equal(
|
||||
second_call["input"][:, : backend.context_samples],
|
||||
first_frame[None, -backend.context_samples :],
|
||||
)
|
||||
np.testing.assert_array_equal(
|
||||
second_call["input"][:, backend.context_samples :],
|
||||
second_frame[None, :],
|
||||
)
|
||||
np.testing.assert_array_equal(
|
||||
second_call["state"],
|
||||
np.full((2, 1, 128), 2, dtype=np.float32),
|
||||
)
|
||||
|
||||
# Independent pipeline states may enter the shared ORT session concurrently.
|
||||
session.run_barrier = threading.Barrier(2)
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
futures = [
|
||||
executor.submit(backend.infer, first_frame, backend.new_state()),
|
||||
executor.submit(backend.infer, second_frame, backend.new_state()),
|
||||
]
|
||||
assert [future.result()[0] for future in futures] == pytest.approx([0.75, 0.75])
|
||||
|
||||
|
||||
def test_duplex_session_owns_server_vad_prefix_and_utterance_audio():
|
||||
session = DuplexSession("server-vad-session", DuplexSessionConfig())
|
||||
frame = np.zeros(160, dtype=np.float32)
|
||||
|
||||
session.reserve_input_bytes(frame.nbytes * 3, limit=frame.nbytes * 4)
|
||||
released = session.append_server_vad_frame(
|
||||
frame,
|
||||
speech_started=False,
|
||||
speech_stopped=False,
|
||||
prefix_samples=160,
|
||||
)
|
||||
assert released == 0
|
||||
released = session.append_server_vad_frame(
|
||||
frame,
|
||||
speech_started=False,
|
||||
speech_stopped=False,
|
||||
prefix_samples=160,
|
||||
)
|
||||
assert released == frame.nbytes
|
||||
session.release_input_bytes(released)
|
||||
session.append_server_vad_frame(
|
||||
frame,
|
||||
speech_started=True,
|
||||
speech_stopped=False,
|
||||
prefix_samples=160,
|
||||
)
|
||||
|
||||
assert session.server_vad_utterance_bytes == frame.nbytes * 2
|
||||
assert session.stage_server_vad_audio_for_commit() is True
|
||||
committed = session.commit_user_input()
|
||||
assert committed is not None
|
||||
assert committed.message["role"] == "user"
|
||||
audio_url = committed.message["content"][0]["audio_url"]["url"]
|
||||
assert audio_url.startswith("data:audio/wav;base64,")
|
||||
wav_bytes = base64.b64decode(audio_url.partition(",")[2])
|
||||
with wave.open(io.BytesIO(wav_bytes), "rb") as wav_file:
|
||||
assert wav_file.getnchannels() == 1
|
||||
assert wav_file.getsampwidth() == 2
|
||||
assert wav_file.getframerate() == 16_000
|
||||
assert wav_file.getnframes() == frame.size * 2
|
||||
|
||||
|
||||
def test_silero_provider_rejects_missing_or_invalid_local_artifact(tmp_path):
|
||||
missing = SileroVADBackendProvider(model_path=str(tmp_path / "missing.onnx"))
|
||||
with pytest.raises(RuntimeError, match="does not exist"):
|
||||
missing.get()
|
||||
|
||||
invalid_path = tmp_path / "silero_vad.onnx"
|
||||
invalid_path.write_bytes(b"not-the-pinned-model")
|
||||
invalid = SileroVADBackendProvider(model_path=str(invalid_path))
|
||||
with pytest.raises(RuntimeError, match="checksum mismatch"):
|
||||
invalid.get()
|
||||
@@ -27,7 +27,7 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
@dataclass
|
||||
class _EngineClientStub:
|
||||
stage_configs: list[dict[str, object]]
|
||||
stage_configs: list[object]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -149,6 +149,37 @@ async def test_chat_reference_audio_reaches_code2wav_transport(
|
||||
assert code2wav_payload.meta.ref_audio_sr == 16000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typed_stage_enables_reference_audio(
|
||||
serving_chat: OmniOpenAIServingChat,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
reference_audio_url = "data:audio/wav;base64,AAAA"
|
||||
reference_waveform = np.array([0.25, -0.5], dtype=np.float32)
|
||||
serving_chat.engine_client = _EngineClientStub(
|
||||
[
|
||||
SimpleNamespace(
|
||||
model_config=SimpleNamespace(model_arch="MiniCPMO45OmniForConditionalGeneration"),
|
||||
model_stage="llm",
|
||||
)
|
||||
]
|
||||
)
|
||||
serving_chat.model_config = _ModelConfigStub()
|
||||
engine_prompt: TokensPrompt = {"type": "tokens", "prompt_token_ids": [1]}
|
||||
media_connector = mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.serving_chat.MediaConnector",
|
||||
).return_value
|
||||
media_connector.fetch_audio_async = mocker.AsyncMock(return_value=(reference_waveform, 16000))
|
||||
|
||||
await serving_chat._attach_minicpmo45_reference_audio(
|
||||
engine_prompt,
|
||||
_chat_request(reference_audio_url),
|
||||
)
|
||||
|
||||
media_connector.fetch_audio_async.assert_awaited_once_with(reference_audio_url)
|
||||
assert MINICPMO45_REFERENCE_AUDIO_KEY in cast(dict[str, object], engine_prompt)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("request_shape", ["extra_body", "model_extra"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_reference_audio_accepts_request_extra_mappings(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Regression tests for MiniCPM-o 4.5 audio-streaming output-kind coercion.
|
||||
|
||||
Chat ``stream=true`` coerces every AR stage to DELTA. For MiniCPM-o 4.5 that
|
||||
@@ -33,6 +34,13 @@ def _stage(model_arch: str, model_stage: str) -> SimpleNamespace:
|
||||
return SimpleNamespace(engine_args=SimpleNamespace(model_arch=model_arch, model_stage=model_stage))
|
||||
|
||||
|
||||
def _typed_stage(model_arch: str, model_stage: str) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
model_config=SimpleNamespace(model_arch=model_arch),
|
||||
model_stage=model_stage,
|
||||
)
|
||||
|
||||
|
||||
def _minicpmo45_stages():
|
||||
return [_stage(MINICPMO45_ARCH, "llm"), _stage(MINICPMO45_ARCH, "tts")]
|
||||
|
||||
@@ -50,6 +58,20 @@ def test_thinker_forced_final_only_talker_stays_delta(serving_chat):
|
||||
assert out[1].output_kind is RequestOutputKind.DELTA
|
||||
|
||||
|
||||
def test_typed_stages_force_thinker_final_only(serving_chat):
|
||||
serving_chat.engine_client = SimpleNamespace(
|
||||
stage_configs=[
|
||||
_typed_stage(MINICPMO45_ARCH, "llm"),
|
||||
_typed_stage(MINICPMO45_ARCH, "tts"),
|
||||
]
|
||||
)
|
||||
|
||||
out = serving_chat._fix_minicpmo45_audio_stream_output_kinds(_delta_params(), ["text", "audio"])
|
||||
|
||||
assert out[0].output_kind is RequestOutputKind.FINAL_ONLY
|
||||
assert out[1].output_kind is RequestOutputKind.DELTA
|
||||
|
||||
|
||||
@pytest.mark.parametrize("modalities", [None, [], ["text"]])
|
||||
def test_non_audio_request_is_untouched(serving_chat, modalities):
|
||||
serving_chat.engine_client = SimpleNamespace(stage_configs=_minicpmo45_stages())
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Regression tests for multistage generation input construction."""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -94,6 +95,34 @@ def test_build_multistage_generation_inputs_applies_stage_specific_overrides(ser
|
||||
assert engine.default_sampling_params_list[2].lora_request is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("extra_body", [{}, {"bot_task": "think"}])
|
||||
def test_build_multistage_generation_inputs_leaves_mm_uuids_to_content_hash(serving_chat, extra_body):
|
||||
"""Fixed image UUIDs collide across requests with the same processor kwargs.
|
||||
|
||||
Leave UUID generation to vLLM so cache keys depend on image content.
|
||||
"""
|
||||
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
|
||||
|
||||
engine = SimpleNamespace(
|
||||
stage_configs=[
|
||||
SimpleNamespace(stage_type="llm", is_comprehension=True),
|
||||
SimpleNamespace(stage_type="diffusion", is_comprehension=False),
|
||||
],
|
||||
default_sampling_params_list=[SamplingParams(temperature=0.0), OmniDiffusionSamplingParams()],
|
||||
)
|
||||
for images in ([Image.new("RGB", (32, 32), color="red")], [Image.new("RGB", (32, 32), c) for c in ("red", "blue")]):
|
||||
engine_prompt, _ = OmniOpenAIServingChat._build_multistage_generation_inputs(
|
||||
serving_chat,
|
||||
engine=engine,
|
||||
prompt="edit me",
|
||||
extra_body=dict(extra_body),
|
||||
reference_images=images,
|
||||
gen_params=OmniDiffusionSamplingParams(seed=0),
|
||||
)
|
||||
assert engine_prompt["multi_modal_data"]
|
||||
assert "multi_modal_uuids" not in engine_prompt
|
||||
|
||||
|
||||
def test_prepare_multistage_multimodal_inputs_defers_downstream_modalities(serving_chat):
|
||||
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
|
||||
|
||||
@@ -538,3 +567,84 @@ def test_build_multistage_generation_inputs_no_stop_token_ids_without_size(servi
|
||||
assert sampling_params_list[0].stop_token_ids == [], (
|
||||
"Without bot_task, AR stage stop_token_ids must be the default empty list"
|
||||
)
|
||||
|
||||
|
||||
def test_build_multistage_generation_inputs_omitted_bot_task_matches_prompt_default(serving_chat):
|
||||
"""Regression: an omitted bot_task must resolve identically for the AR
|
||||
prompt and for its stop_token_ids.
|
||||
|
||||
``build_prompt_tokens``/``build_prompt`` default an omitted bot_task
|
||||
per-task via the ``_DEFAULT_BOT_TASK`` sentinel (``"think"`` for the base
|
||||
``it2i`` task, which isn't itself a key in ``_TASK_PRESETS``).
|
||||
``resolve_stop_token_ids`` must normalize from that same omitted-or-not
|
||||
starting point. Passing the raw -- still ``None`` -- outer ``bot_task``
|
||||
variable instead of mirroring ``build_kwargs``'s own omitted-or-not
|
||||
``"bot_task"`` entry made it normalize ``bot_task=None`` (plain mode), so
|
||||
it fell through to the full ``<img_ratio_*>`` range instead of the
|
||||
think/recaption terminator pair.
|
||||
|
||||
An explicit (non-"auto") size is required to observe this: with
|
||||
``need_ratio=True`` the ratio range is returned regardless of bot_task, so
|
||||
the two paths only visibly disagree once a concrete size selects the
|
||||
narrower stop set.
|
||||
"""
|
||||
from vllm_omni.diffusion.models.hunyuan_image3.prompt_utils import (
|
||||
HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS,
|
||||
)
|
||||
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
|
||||
|
||||
class FakeTokenizer:
|
||||
SPECIAL = {
|
||||
"<|startoftext|>": 1,
|
||||
"<img>": 2,
|
||||
"<recaption>": 4,
|
||||
}
|
||||
|
||||
def convert_tokens_to_ids(self, tok: str) -> int:
|
||||
return self.SPECIAL.get(tok, 0)
|
||||
|
||||
def encode(self, text: str, add_special_tokens: bool = False) -> list[int]:
|
||||
return list(range(100, 100 + len(text)))
|
||||
|
||||
def _build(extra_body):
|
||||
engine = SimpleNamespace(
|
||||
stage_configs=[
|
||||
SimpleNamespace(stage_type="llm", is_comprehension=True),
|
||||
SimpleNamespace(stage_type="diffusion", is_comprehension=False),
|
||||
],
|
||||
default_sampling_params_list=[
|
||||
SamplingParams(temperature=0.0),
|
||||
OmniDiffusionSamplingParams(),
|
||||
],
|
||||
)
|
||||
_, sampling_params_list = OmniOpenAIServingChat._build_multistage_generation_inputs(
|
||||
serving_chat,
|
||||
engine=engine,
|
||||
prompt="draw a cat",
|
||||
extra_body=extra_body,
|
||||
reference_images=[Image.new("RGB", (32, 32), color="red")],
|
||||
gen_params=OmniDiffusionSamplingParams(height=768, width=1024),
|
||||
tokenizer=FakeTokenizer(),
|
||||
)
|
||||
return sampling_params_list[0].stop_token_ids
|
||||
|
||||
# bot_task omitted entirely; use_system_prompt is what carries the request
|
||||
# into the AR-prompt path.
|
||||
omitted = _build({"use_system_prompt": "en_recaption"})
|
||||
# Same request with the default spelled out explicitly.
|
||||
explicit = _build({"bot_task": "think", "use_system_prompt": "en_recaption"})
|
||||
|
||||
expected = [
|
||||
HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["</think>"],
|
||||
HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["</recaption>"],
|
||||
]
|
||||
assert omitted == expected, (
|
||||
f"omitted bot_task with an explicit size must resolve stop_token_ids for the "
|
||||
f"default 'think' bot_task ({expected}); got {omitted}. A long list here is the "
|
||||
f"full <img_ratio_*> range, meaning resolve_stop_token_ids normalized bot_task=None "
|
||||
f"(plain mode) instead of agreeing with build_prompt_tokens's default."
|
||||
)
|
||||
assert omitted == explicit, (
|
||||
"omitting bot_task must resolve the same stop_token_ids as passing the default "
|
||||
f"explicitly; omitted={omitted} explicit={explicit}"
|
||||
)
|
||||
@@ -1,4 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""
|
||||
Unit tests for OmniOpenAIServingChat sampling params handling.
|
||||
|
||||
@@ -8,6 +9,7 @@ are correctly applied to the comprehension stage while preserving YAML defaults.
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
@@ -326,6 +328,64 @@ def test_mixed_consumer_keeps_root_common_args_with_nested_extras(mock_engine_cl
|
||||
assert captured["prompt"]["negative_prompt"] == "avoid blur"
|
||||
|
||||
|
||||
def test_text_only_request_reaches_engine_with_comprehension_task_mode(mock_engine_client, mocker: MockerFixture):
|
||||
"""Exercise the production chat path, not only the tagging helper."""
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
mock_engine_client.stage_configs = [
|
||||
SimpleNamespace(stage_type="llm", is_comprehension=True),
|
||||
SimpleNamespace(stage_type="diffusion", is_comprehension=False),
|
||||
]
|
||||
mock_engine_client.default_sampling_params_list = [
|
||||
SamplingParams(),
|
||||
OmniDiffusionSamplingParams(),
|
||||
]
|
||||
mock_engine_client.output_modalities = ["text", "image"]
|
||||
mock_engine_client.errored = False
|
||||
mock_engine_client.renderer = SimpleNamespace(get_tokenizer=lambda: object())
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def results():
|
||||
if False:
|
||||
yield None
|
||||
|
||||
def generate(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return results()
|
||||
|
||||
mock_engine_client.generate = generate
|
||||
serving_chat = build_serving_chat(
|
||||
engine_client=mock_engine_client,
|
||||
models=SimpleNamespace(model_name=lambda _: "test"),
|
||||
online_renderer=SimpleNamespace(validate_chat_template=lambda **_: None),
|
||||
trust_request_chat_template=True,
|
||||
)
|
||||
serving_chat._diffusion_mode = False
|
||||
serving_chat._diffusion_extra_body_params = frozenset()
|
||||
mocker.patch.multiple(
|
||||
serving_chat,
|
||||
_check_model=mocker.AsyncMock(return_value=None),
|
||||
_maybe_get_adapters=mocker.Mock(return_value=None),
|
||||
_effective_chat_template_kwargs=mocker.Mock(return_value={}),
|
||||
_preprocess_chat=mocker.AsyncMock(return_value=([], [{"prompt": "raw"}])),
|
||||
_base_request_id=mocker.Mock(return_value="test"),
|
||||
_log_inputs=mocker.Mock(),
|
||||
chat_completion_full_generator=mocker.AsyncMock(return_value="done"),
|
||||
)
|
||||
request = ChatCompletionRequest(
|
||||
model="test",
|
||||
messages=[{"role": "user", "content": "describe this image"}],
|
||||
modalities=["text"],
|
||||
)
|
||||
|
||||
assert asyncio.run(serving_chat._create_chat_completion(request)) == "done"
|
||||
|
||||
sampling_params_list = cast(list[Any], captured["sampling_params_list"])
|
||||
assert sampling_params_list[0].extra_args == {"ar_task_mode": "comprehension"}
|
||||
assert sampling_params_list[1].extra_args == {}
|
||||
assert captured["output_modalities"] == ["text"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_request(mocker: MockerFixture):
|
||||
"""Create a mock request with all OpenAI sampling params set to None."""
|
||||
@@ -865,3 +925,54 @@ class TestResolveHeightWidth:
|
||||
h, w = OmniOpenAIServingChat._resolve_height_width_from_extra_body({"size": "invalid"})
|
||||
assert h is None
|
||||
assert w is None
|
||||
|
||||
|
||||
# Tests for _apply_text_chat_ar_task_mode (#6088)
|
||||
|
||||
|
||||
def _tag_request(modalities):
|
||||
return SimpleNamespace(modalities=modalities)
|
||||
|
||||
|
||||
def _apply_tag(params, modalities):
|
||||
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
|
||||
|
||||
OmniOpenAIServingChat._apply_text_chat_ar_task_mode(params, _tag_request(modalities))
|
||||
return params
|
||||
|
||||
|
||||
@pytest.mark.parametrize("modalities", [[], ["text"]])
|
||||
def test_text_only_chat_tags_ar_stage_as_comprehension(modalities):
|
||||
ar = SamplingParams()
|
||||
params = _apply_tag([ar], modalities)
|
||||
assert params[0].extra_args == {"ar_task_mode": "comprehension"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("modalities", [["image"], ["text", "audio"], ["video"]])
|
||||
def test_non_text_output_request_is_untouched(modalities):
|
||||
ar = SamplingParams()
|
||||
_apply_tag([ar], modalities)
|
||||
assert ar.extra_args is None
|
||||
|
||||
|
||||
def test_diffusion_stage_params_are_untouched():
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
ar = SamplingParams()
|
||||
dit = OmniDiffusionSamplingParams()
|
||||
dit_extra_before = getattr(dit, "extra_args", None)
|
||||
_apply_tag([ar, dit], ["text"])
|
||||
assert ar.extra_args == {"ar_task_mode": "comprehension"}
|
||||
assert getattr(dit, "extra_args", None) == dit_extra_before
|
||||
|
||||
|
||||
def test_explicit_caller_ar_task_mode_is_preserved():
|
||||
ar = SamplingParams(extra_args={"ar_task_mode": "generation"})
|
||||
_apply_tag([ar], ["text"])
|
||||
assert ar.extra_args["ar_task_mode"] == "generation"
|
||||
|
||||
|
||||
def test_existing_extra_args_are_merged_not_replaced():
|
||||
ar = SamplingParams(extra_args={"custom": 1})
|
||||
_apply_tag([ar], None)
|
||||
assert ar.extra_args == {"custom": 1, "ar_task_mode": "comprehension"}
|
||||
@@ -27,6 +27,7 @@ from pydantic import ValidationError
|
||||
from pytest_mock import MockerFixture
|
||||
from vllm.entrypoints.serve.engine.protocol import ErrorInfo, ErrorResponse
|
||||
|
||||
from vllm_omni.config.stage_config import StagePipelineConfig
|
||||
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
||||
from vllm_omni.diffusion.sched.request_scheduler import RequestScheduler
|
||||
from vllm_omni.diffusion.sched.step_scheduler import StepScheduler
|
||||
@@ -1039,6 +1040,38 @@ class TestTTSMethods:
|
||||
asyncio.run(server._prepare_speech_generation(request))
|
||||
server.shutdown()
|
||||
|
||||
def test_prepare_speech_rejects_typed_non_tts_omni_model(self, mocker: MockerFixture):
|
||||
"""Typed Qwen3-Omni stages must hit the same talker guard as legacy stages."""
|
||||
mock_engine_client = mocker.MagicMock()
|
||||
mock_engine_client.errored = False
|
||||
mock_engine_client.tts_max_instructions_length = None
|
||||
mock_engine_client.stage_configs = [
|
||||
SimpleNamespace(
|
||||
stage_pipeline_config=StagePipelineConfig(stage_id=index, model_stage=model_stage),
|
||||
model_config=SimpleNamespace(model_arch=None),
|
||||
worker_type=worker_type,
|
||||
)
|
||||
for index, (model_stage, worker_type) in enumerate(
|
||||
(
|
||||
("thinker", "ar"),
|
||||
("talker", "ar"),
|
||||
("code2wav", "generation"),
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
mock_models = mocker.MagicMock()
|
||||
mock_models.is_base_model.return_value = True
|
||||
server = OmniOpenAIServingSpeech(
|
||||
engine_client=mock_engine_client,
|
||||
models=mock_models,
|
||||
request_logger=mocker.MagicMock(),
|
||||
)
|
||||
request = OpenAICreateSpeechRequest(input="Hello world")
|
||||
with pytest.raises(ValueError, match="only supported for dedicated TTS models"):
|
||||
asyncio.run(server._prepare_speech_generation(request))
|
||||
server.shutdown()
|
||||
|
||||
def test_estimate_prompt_len_fallback(self, speech_server):
|
||||
"""Test prompt length estimation falls back to 2048 when model is unavailable."""
|
||||
tts_params = {"text": ["Hello"], "task_type": ["CustomVoice"]}
|
||||
@@ -1559,7 +1592,8 @@ class TestTTSMethods:
|
||||
|
||||
assert first[1] == 24000
|
||||
assert second[1] == 24000
|
||||
assert first[0] is second[0]
|
||||
assert first[0] == second[0]
|
||||
assert first[0] is not second[0]
|
||||
assert first[0][0] == pytest.approx(float(wav[0]), abs=1e-4)
|
||||
cache_key = first[2]
|
||||
assert speech_server._get_resolved_ref_audio_artifact_key(
|
||||
@@ -1826,6 +1860,53 @@ class TestTTSMethods:
|
||||
assert prompt_a["additional_information"]["ref_audio_cache_key"] == "key_aaa"
|
||||
assert prompt_b["additional_information"]["ref_audio_cache_key"] == "key_bbb"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_higgs_v2_cache_salt_changes_for_same_shaped_reference_audio(self, speech_server, mocker):
|
||||
"""Higgs v2 must salt identical placeholder prompts by resolved audio content."""
|
||||
build_voice_clone_prompt = mocker.patch(
|
||||
"vllm_omni.model_executor.models.higgs_audio_v2.higgs_audio_v2_tokenizer.build_voice_clone_prompt"
|
||||
)
|
||||
build_voice_clone_prompt.side_effect = lambda _processor, _input, wav, _sr, _ref_text: {
|
||||
"prompt_token_ids": [1, 151700, 151700, 2],
|
||||
"audio_input_ids": torch.tensor([[100 if wav[0] < 0.5 else 900, 2], [3, 4]], dtype=torch.long),
|
||||
"audio_input_ids_mask": torch.ones(2, dtype=torch.bool),
|
||||
}
|
||||
|
||||
speech_server._tts_model_type = "higgs_audio_v2"
|
||||
speech_server._adapter = speech_server._get_tts_adapter()
|
||||
speech_server._adapter._resolve_higgs_audio_v2_processor = mocker.AsyncMock(return_value=object())
|
||||
|
||||
req_a = OpenAICreateSpeechRequest(
|
||||
input="hello",
|
||||
ref_audio="file:///data/spk.wav",
|
||||
ref_text="transcript",
|
||||
)
|
||||
req_b = OpenAICreateSpeechRequest(
|
||||
input="hello",
|
||||
ref_audio="file:///data/spk.wav",
|
||||
ref_text="transcript",
|
||||
)
|
||||
speech_server._adapter._resolve_ref_audio = mocker.AsyncMock(
|
||||
side_effect=[([0.1] * 48000, 24000, "key_aaa"), ([0.9] * 48000, 24000, "key_bbb")]
|
||||
)
|
||||
|
||||
prompt_a = await speech_server._adapter._build_higgs_audio_v2_params(req_a)
|
||||
prompt_b = await speech_server._adapter._build_higgs_audio_v2_params(req_b)
|
||||
|
||||
assert prompt_a["prompt_token_ids"] == [1, 151700, 151700, 2]
|
||||
assert prompt_a["prompt_token_ids"] == prompt_b["prompt_token_ids"]
|
||||
assert (
|
||||
prompt_a["additional_information"]["audio_input_ids"].shape
|
||||
== prompt_b["additional_information"]["audio_input_ids"].shape
|
||||
)
|
||||
assert not torch.equal(
|
||||
prompt_a["additional_information"]["audio_input_ids"],
|
||||
prompt_b["additional_information"]["audio_input_ids"],
|
||||
)
|
||||
assert prompt_a["cache_salt"] != prompt_b["cache_salt"]
|
||||
assert prompt_a["additional_information"]["ref_audio_cache_key"] == "key_aaa"
|
||||
assert prompt_b["additional_information"]["ref_audio_cache_key"] == "key_bbb"
|
||||
|
||||
# ── MossReferenceEncoder: speaker cache invalidation ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -2241,6 +2322,7 @@ class TestTTSMethods:
|
||||
|
||||
assert params["task_type"] == ["Base"]
|
||||
assert params["non_streaming_mode"] == [True]
|
||||
assert "full_utterance_decode" not in params
|
||||
|
||||
def test_build_tts_params_base_omits_non_streaming_mode_by_default(self, speech_server):
|
||||
"""Base task should keep using the model default when no override is sent."""
|
||||
@@ -2255,6 +2337,7 @@ class TestTTSMethods:
|
||||
|
||||
assert params["task_type"] == ["Base"]
|
||||
assert "non_streaming_mode" not in params
|
||||
assert "full_utterance_decode" not in params
|
||||
|
||||
def test_build_tts_params_explicit_non_streaming_mode_overrides_voicedesign_default(self, speech_server):
|
||||
"""Explicit false should not be replaced by the VoiceDesign fallback."""
|
||||
@@ -2269,6 +2352,38 @@ class TestTTSMethods:
|
||||
|
||||
assert params["task_type"] == ["VoiceDesign"]
|
||||
assert params["non_streaming_mode"] == [False]
|
||||
assert "full_utterance_decode" not in params
|
||||
|
||||
def test_build_tts_params_streaming_voicedesign_keeps_prompt_mode_not_full_decode(self, speech_server):
|
||||
"""Streaming VoiceDesign defaults: prompt-mode True, no full_utterance_decode."""
|
||||
req = OpenAICreateSpeechRequest(
|
||||
input="Hello",
|
||||
task_type="VoiceDesign",
|
||||
instructions="warm and calm",
|
||||
stream=True,
|
||||
response_format="pcm",
|
||||
)
|
||||
|
||||
params = speech_server._build_tts_params(req)
|
||||
|
||||
assert params["task_type"] == ["VoiceDesign"]
|
||||
assert params["non_streaming_mode"] == [True]
|
||||
assert "full_utterance_decode" not in params
|
||||
|
||||
def test_build_tts_params_streaming_customvoice_explicit_non_streaming_mode_still_windowed(self, speech_server):
|
||||
"""Explicit non_streaming_mode=True must not inject full_utterance_decode."""
|
||||
req = OpenAICreateSpeechRequest(
|
||||
input="Hello",
|
||||
voice="Vivian",
|
||||
non_streaming_mode=True,
|
||||
stream=True,
|
||||
response_format="pcm",
|
||||
)
|
||||
|
||||
params = speech_server._build_tts_params(req)
|
||||
|
||||
assert params["non_streaming_mode"] == [True]
|
||||
assert "full_utterance_decode" not in params
|
||||
|
||||
def test_load_supported_speakers(self, mocker: MockerFixture):
|
||||
"""Test _load_supported_speakers."""
|
||||
@@ -3287,7 +3402,7 @@ class TestStreamingResponse:
|
||||
finalized_tts_params = {"_qwen3_tts_effective_max_tokens": [192]}
|
||||
captured: dict = {}
|
||||
|
||||
async def prepare(_request, request_id=None):
|
||||
async def prepare(_request, request_id=None, arrival_time=None):
|
||||
return request_id, object(), finalized_tts_params
|
||||
|
||||
async def generate_chunks(
|
||||
@@ -3296,6 +3411,7 @@ class TestStreamingResponse:
|
||||
_response_format="pcm",
|
||||
raw_request=None,
|
||||
request_start_s=None,
|
||||
request_arrival_ts=None,
|
||||
include_sample_rate=False,
|
||||
usage_acc=None,
|
||||
tts_params=None,
|
||||
@@ -3758,6 +3874,7 @@ def test_api_server_create_speech_wraps_error_response_status(mocker: MockerFixt
|
||||
def _make_api_server_request(handler, *, method: str = "POST", path: str = "/v1/audio/voices") -> Request:
|
||||
app = FastAPI()
|
||||
app.state.openai_serving_speech = handler
|
||||
app.state.api_server_count = 1
|
||||
scope = {
|
||||
"type": "http",
|
||||
"app": app,
|
||||
@@ -4919,89 +5036,6 @@ class TestMingFlashOmniTTSServing:
|
||||
assert prompt["additional_information"]["voice_name"] == "test"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dots_tts_server(mocker: MockerFixture):
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.tts_adapters.base.load_supported_speakers",
|
||||
return_value=set(),
|
||||
)
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.tts_adapters.base.load_codec_frame_rate",
|
||||
return_value=None,
|
||||
)
|
||||
|
||||
mock_engine_client = mocker.MagicMock()
|
||||
mock_engine_client.errored = False
|
||||
mock_engine_client.model_config = mocker.MagicMock(
|
||||
model="dots-studio/dots.tts-soar",
|
||||
)
|
||||
mock_engine_client.default_sampling_params_list = [
|
||||
SimpleNamespace(max_tokens=2048, min_tokens=None, extra_args=None)
|
||||
]
|
||||
mock_engine_client.tts_batch_max_items = 32
|
||||
mock_engine_client.generate = mocker.MagicMock(return_value="generator")
|
||||
mock_engine_client.stage_configs = [
|
||||
SimpleNamespace(
|
||||
engine_args=SimpleNamespace(model_stage="latent_generator", model_arch="DotsTTSForConditionalGeneration"),
|
||||
tts_args={},
|
||||
)
|
||||
]
|
||||
|
||||
mock_models = mocker.MagicMock()
|
||||
mock_models.is_base_model.return_value = True
|
||||
|
||||
return OmniOpenAIServingSpeech(
|
||||
engine_client=mock_engine_client,
|
||||
models=mock_models,
|
||||
request_logger=mocker.MagicMock(),
|
||||
)
|
||||
|
||||
|
||||
class TestDotsTTSServing:
|
||||
def test_dots_tts_prompt_validation(self, dots_tts_server):
|
||||
request = OpenAICreateSpeechRequest(input="Hello", ref_text="Reference transcript")
|
||||
error = dots_tts_server._validate_tts_request(request)
|
||||
assert error is not None
|
||||
assert "ref_text" in error
|
||||
|
||||
request = OpenAICreateSpeechRequest(input="Hello", ref_audio="data:audio/wav;base64,abc")
|
||||
error = dots_tts_server._validate_tts_request(request)
|
||||
assert error is not None
|
||||
assert "ref_audio" in error
|
||||
|
||||
request = OpenAICreateSpeechRequest(input="Hello", voice="test")
|
||||
error = dots_tts_server._validate_tts_request(request)
|
||||
assert error is not None
|
||||
assert "voice" in error
|
||||
|
||||
request = OpenAICreateSpeechRequest(input="Hello", speaker_embedding=[1, 2, 3])
|
||||
error = dots_tts_server._validate_tts_request(request)
|
||||
assert error is not None
|
||||
assert "speaker_embedding" in error
|
||||
|
||||
request = OpenAICreateSpeechRequest(input="Hello", x_vector_only_mode=True)
|
||||
error = dots_tts_server._validate_tts_request(request)
|
||||
assert error is not None
|
||||
assert "x_vector_only_mode" in error
|
||||
|
||||
def test_dots_tts_adapter_awaits_async_prompt_builder(self, dots_tts_server, mocker: MockerFixture):
|
||||
build_prompt_async = mocker.patch.object(
|
||||
dots_tts_server._adapter,
|
||||
"_build_prompt_async",
|
||||
new=mocker.AsyncMock(return_value={"prompt_token_ids": [1, 2, 3]}),
|
||||
)
|
||||
request = OpenAICreateSpeechRequest(input="Hello")
|
||||
asyncio.run(dots_tts_server._prepare_speech_generation(request))
|
||||
build_prompt_async.assert_awaited_once_with("Hello")
|
||||
|
||||
def test_dots_tts_adapter_apply_sampling_overrides(self, dots_tts_server, mocker: MockerFixture):
|
||||
mocker.patch.object(dots_tts_server._adapter, "build", return_value=PreparedRequest(prompt="Hello"))
|
||||
request = OpenAICreateSpeechRequest(input="Hello", max_new_tokens=10)
|
||||
asyncio.run(dots_tts_server._prepare_speech_generation(request))
|
||||
sampling_params_list = dots_tts_server.engine_client.generate.call_args.kwargs["sampling_params_list"]
|
||||
assert sampling_params_list[0].max_tokens == 10
|
||||
|
||||
|
||||
class TestTTSAsyncOffloading:
|
||||
"""Tests for event-loop-safe offloading of blocking TTS operations."""
|
||||
|
||||
@@ -5098,6 +5132,7 @@ class TestTTSAsyncOffloading:
|
||||
|
||||
def test_prepare_speech_generation_awaits_voxtral_async(self, voxtral_server, mocker: MockerFixture):
|
||||
"""Voxtral path in _prepare_speech_generation should call the async wrapper."""
|
||||
voxtral_server._adapter._encoder_loaded = mocker.AsyncMock(return_value=False)
|
||||
voxtral_server._adapter._build_prompt_async = mocker.AsyncMock(
|
||||
return_value={
|
||||
"prompt_token_ids": [1, 2, 3],
|
||||
@@ -5176,6 +5211,52 @@ class TestTTSAsyncOffloading:
|
||||
for call in log_info.call_args_list
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("word_timestamps", [False, True])
|
||||
def test_streaming_timestamps_retain_complete_audio(self, qwen3_tts_server, mocker, word_timestamps):
|
||||
from vllm import SamplingParams
|
||||
from vllm.sampling_params import RequestOutputKind
|
||||
|
||||
defaults = [SamplingParams(), SamplingParams()]
|
||||
qwen3_tts_server.engine_client.default_sampling_params_list = defaults
|
||||
qwen3_tts_server._adapter.validate = mocker.MagicMock(return_value=None)
|
||||
qwen3_tts_server._adapter._build_tts_params = mocker.MagicMock(
|
||||
return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]}
|
||||
)
|
||||
qwen3_tts_server._adapter._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
|
||||
request = OpenAICreateSpeechRequest(
|
||||
input="hello", stream=True, response_format="pcm", word_timestamps=word_timestamps
|
||||
)
|
||||
asyncio.run(qwen3_tts_server._prepare_speech_generation(request))
|
||||
|
||||
params = qwen3_tts_server.engine_client.generate.call_args.kwargs["sampling_params_list"]
|
||||
expected = RequestOutputKind.CUMULATIVE if word_timestamps else RequestOutputKind.DELTA
|
||||
assert all(sp.output_kind == expected for sp in params)
|
||||
assert all(sp.output_kind == RequestOutputKind.CUMULATIVE for sp in defaults)
|
||||
assert all(sp is not original for sp, original in zip(params, defaults))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cumulative_timestamp_audio_emits_each_sample_once(self, qwen3_tts_server):
|
||||
audio = torch.linspace(-0.5, 0.5, 2400)
|
||||
|
||||
async def cumulative_generator():
|
||||
for end in (800, 1600, 2400, 2400):
|
||||
yield OmniRequestOutput(
|
||||
request_id="req-cumulative",
|
||||
final_output_type="audio",
|
||||
_multimodal_output={"audio": audio[:end], "sr": 24000},
|
||||
)
|
||||
|
||||
chunks = [
|
||||
chunk
|
||||
async for chunk in qwen3_tts_server._generate_pcm_chunks(
|
||||
cumulative_generator(), "req-cumulative", cumulative_audio=True
|
||||
)
|
||||
]
|
||||
assert len(chunks) == 3
|
||||
assert len(b"".join(chunks)) == 2400 * 2
|
||||
decoded = np.frombuffer(b"".join(chunks), dtype=np.int16)
|
||||
assert np.all(np.diff(decoded.astype(np.int32)) >= 0)
|
||||
|
||||
def test_prepare_speech_generation_treats_sse_as_streaming(self, qwen3_tts_server, mocker: MockerFixture):
|
||||
"""stream_format=sse should request delta-style multimodal outputs."""
|
||||
qwen3_tts_server._adapter.validate = mocker.MagicMock(return_value=None)
|
||||
@@ -5253,6 +5334,7 @@ class TestTTSAsyncOffloading:
|
||||
"""FINAL_ONLY streaming for async_chunk=False is scoped to qwen3_tts only."""
|
||||
voxtral_server.engine_client.model_config.async_chunk = False
|
||||
mocker.patch.object(voxtral_server._get_tts_adapter(), "validate", return_value=None)
|
||||
voxtral_server._adapter._encoder_loaded = mocker.AsyncMock(return_value=False)
|
||||
voxtral_server._adapter._build_prompt_async = mocker.AsyncMock(
|
||||
return_value={
|
||||
"prompt_token_ids": [1, 2, 3],
|
||||
@@ -5286,9 +5368,34 @@ class TestTTSAsyncOffloading:
|
||||
|
||||
assert tts_params["task_type"] == ["VoiceDesign"]
|
||||
assert tts_params["non_streaming_mode"] == [False]
|
||||
assert "full_utterance_decode" not in tts_params
|
||||
prompt = qwen3_tts_server.engine_client.generate.call_args.kwargs["prompt"]
|
||||
assert prompt["additional_information"] is tts_params
|
||||
assert prompt["additional_information"]["non_streaming_mode"] == [False]
|
||||
assert "full_utterance_decode" not in prompt["additional_information"]
|
||||
|
||||
def test_prepare_speech_generation_qwen3_streaming_voicedesign_default_params(
|
||||
self, qwen3_tts_server, mocker: MockerFixture
|
||||
):
|
||||
"""Streaming VoiceDesign defaults keep prompt-mode True without full_utterance_decode."""
|
||||
qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None)
|
||||
qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
|
||||
|
||||
request = OpenAICreateSpeechRequest(
|
||||
input="hello",
|
||||
task_type="VoiceDesign",
|
||||
instructions="warm and calm",
|
||||
stream=True,
|
||||
response_format="pcm",
|
||||
)
|
||||
_request_id, _generator, tts_params = asyncio.run(qwen3_tts_server._prepare_speech_generation(request))
|
||||
|
||||
assert tts_params["task_type"] == ["VoiceDesign"]
|
||||
assert tts_params["non_streaming_mode"] == [True]
|
||||
assert "full_utterance_decode" not in tts_params
|
||||
prompt = qwen3_tts_server.engine_client.generate.call_args.kwargs["prompt"]
|
||||
assert prompt["additional_information"]["non_streaming_mode"] == [True]
|
||||
assert "full_utterance_decode" not in prompt["additional_information"]
|
||||
|
||||
def test_prepare_speech_generation_qwen3_base_non_streaming_mode_true(
|
||||
self, qwen3_tts_server, mocker: MockerFixture
|
||||
@@ -5311,9 +5418,11 @@ class TestTTSAsyncOffloading:
|
||||
assert tts_params["task_type"] == ["Base"]
|
||||
assert tts_params["ref_text"] == ["reference transcript"]
|
||||
assert tts_params["non_streaming_mode"] == [True]
|
||||
assert "full_utterance_decode" not in tts_params
|
||||
prompt = qwen3_tts_server.engine_client.generate.call_args.kwargs["prompt"]
|
||||
assert prompt["additional_information"] is tts_params
|
||||
assert prompt["additional_information"]["non_streaming_mode"] == [True]
|
||||
assert "full_utterance_decode" not in prompt["additional_information"]
|
||||
|
||||
def test_qwen3_repeated_ref_audio_hot_path_sends_cache_key_without_waveform(self, qwen3_tts_server):
|
||||
"""After a ref artifact is marked ready, repeated requests avoid ref_audio payload IPC."""
|
||||
@@ -5322,7 +5431,7 @@ class TestTTSAsyncOffloading:
|
||||
ref_audio = "data:audio/wav;base64,same"
|
||||
qwen3_tts_server._put_resolved_ref_audio(
|
||||
hashlib.sha1(ref_audio.encode("utf-8")).hexdigest(),
|
||||
wav_list,
|
||||
np.asarray(wav_list, dtype=np.float32),
|
||||
24000,
|
||||
artifact_key,
|
||||
)
|
||||
@@ -5358,9 +5467,9 @@ class TestTTSAsyncOffloading:
|
||||
qwen3_tts_server._ref_audio_resolve_cache_max_entries = 1
|
||||
qwen3_tts_server._ref_audio_resolve_cache_max_bytes = 1_000_000
|
||||
|
||||
qwen3_tts_server._put_resolved_ref_audio("ref-a", [0.0] * 8, 24000, "artifact-a")
|
||||
qwen3_tts_server._put_resolved_ref_audio("ref-a", np.zeros(8, dtype=np.float32), 24000, "artifact-a")
|
||||
qwen3_tts_server._ref_audio_model_artifact_ready.add(("artifact-a", False))
|
||||
qwen3_tts_server._put_resolved_ref_audio("ref-b", [0.0] * 8, 24000, "artifact-b")
|
||||
qwen3_tts_server._put_resolved_ref_audio("ref-b", np.zeros(8, dtype=np.float32), 24000, "artifact-b")
|
||||
|
||||
assert ("artifact-a", False) not in qwen3_tts_server._ref_audio_model_artifact_ready
|
||||
assert "artifact-b" in {entry[3] for entry in qwen3_tts_server._ref_audio_resolve_cache.values()}
|
||||
@@ -5607,7 +5716,7 @@ class TestTTSAsyncOffloading:
|
||||
def test_qwen3_xvector_ready_artifact_does_not_enable_icl_artifact_only(self, qwen3_tts_server):
|
||||
# An x-vector-only artifact (speaker embedding, no ref_code) must not enable
|
||||
# the artifact-only path for a later ICL request with the same ref_audio (#5049).
|
||||
qwen3_tts_server._put_resolved_ref_audio("ref-a", [0.0] * 8, 24000, "artifact-a")
|
||||
qwen3_tts_server._put_resolved_ref_audio("ref-a", np.zeros(8, dtype=np.float32), 24000, "artifact-a")
|
||||
qwen3_tts_server._track_ref_audio_artifact_warmup("req-xvec", "artifact-a", x_vector_only=True)
|
||||
qwen3_tts_server._mark_ref_audio_artifact_ready_for_request("req-xvec")
|
||||
|
||||
@@ -5615,7 +5724,7 @@ class TestTTSAsyncOffloading:
|
||||
assert qwen3_tts_server._adapter._qwen3_tts_can_use_ref_audio_artifact_only(icl_params, "artifact-a") is False
|
||||
|
||||
def test_qwen3_xvector_ready_artifact_still_reusable_by_xvector_request(self, qwen3_tts_server):
|
||||
qwen3_tts_server._put_resolved_ref_audio("ref-a", [0.0] * 8, 24000, "artifact-a")
|
||||
qwen3_tts_server._put_resolved_ref_audio("ref-a", np.zeros(8, dtype=np.float32), 24000, "artifact-a")
|
||||
qwen3_tts_server._track_ref_audio_artifact_warmup("req-xvec", "artifact-a", x_vector_only=True)
|
||||
qwen3_tts_server._mark_ref_audio_artifact_ready_for_request("req-xvec")
|
||||
|
||||
@@ -5623,7 +5732,7 @@ class TestTTSAsyncOffloading:
|
||||
assert qwen3_tts_server._adapter._qwen3_tts_can_use_ref_audio_artifact_only(xvec_params, "artifact-a") is True
|
||||
|
||||
def test_qwen3_icl_ready_artifact_enables_icl_artifact_only(self, qwen3_tts_server):
|
||||
qwen3_tts_server._put_resolved_ref_audio("ref-a", [0.0] * 8, 24000, "artifact-a")
|
||||
qwen3_tts_server._put_resolved_ref_audio("ref-a", np.zeros(8, dtype=np.float32), 24000, "artifact-a")
|
||||
qwen3_tts_server._track_ref_audio_artifact_warmup("req-icl", "artifact-a", x_vector_only=False)
|
||||
qwen3_tts_server._mark_ref_audio_artifact_ready_for_request("req-icl")
|
||||
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Prometheus metric coverage for the Speech API audio path."""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.entrypoints.openai import serving_speech as speech_module
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class _MetricsStub:
|
||||
def __init__(self) -> None:
|
||||
self.ttfp_calls: list[tuple[str, str, float]] = []
|
||||
self.underrun_calls: list[tuple[str, str, float]] = []
|
||||
self.continuity_calls: list[tuple[str, str, int]] = []
|
||||
self.abort_calls: list[str] = []
|
||||
self.completed = 0
|
||||
|
||||
def inc_speech_stream_completed(self) -> None:
|
||||
self.completed += 1
|
||||
|
||||
def inc_speech_stream_aborted(self, reason: str) -> None:
|
||||
self.abort_calls.append(reason)
|
||||
|
||||
def observe_audio_ttfp(self, stage: str, replica: str, seconds: float) -> None:
|
||||
self.ttfp_calls.append((stage, replica, seconds))
|
||||
|
||||
def observe_audio_underrun(self, stage: str, replica: str, seconds: float) -> None:
|
||||
self.underrun_calls.append((stage, replica, seconds))
|
||||
|
||||
def inc_audio_continuity_ok(self, stage: str, replica: str, threshold_ms: int) -> None:
|
||||
self.continuity_calls.append((stage, replica, threshold_ms))
|
||||
|
||||
|
||||
def _serving(metrics: _MetricsStub, *, adapter=None) -> OmniOpenAIServingSpeech:
|
||||
serving = OmniOpenAIServingSpeech.__new__(OmniOpenAIServingSpeech)
|
||||
serving._tts_model_type = "qwen3_tts"
|
||||
serving.engine_client = SimpleNamespace(mod_metrics=metrics, request_states={})
|
||||
serving._get_tts_adapter = lambda: adapter
|
||||
serving._speech_output_policies = {}
|
||||
serving.create_audio = lambda audio_obj: SimpleNamespace(
|
||||
audio_data=b"\0\0" * int(audio_obj.audio_tensor.size),
|
||||
media_type="audio/pcm",
|
||||
)
|
||||
serving._mark_ref_audio_artifact_ready_for_request = lambda request_id: None
|
||||
serving._discard_ref_audio_artifact_warmup = lambda request_id: None
|
||||
return serving
|
||||
|
||||
|
||||
def _result(samples: int = 320, *, stage_id: int = 1, replica_id: int | None = 2) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
multimodal_output={"audio": torch.zeros(samples), "sr": 16000},
|
||||
stage_id=stage_id,
|
||||
replica_id=replica_id,
|
||||
)
|
||||
|
||||
|
||||
async def _generate(*results):
|
||||
for result in results:
|
||||
yield result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_speech_observes_ttfp_once_on_first_pcm_payload(monkeypatch):
|
||||
metrics = _MetricsStub()
|
||||
serving = _serving(metrics)
|
||||
monkeypatch.setattr("vllm_omni.entrypoints.openai.serving_speech.time.time", lambda: 100.25)
|
||||
|
||||
chunks = serving._generate_audio_chunks(
|
||||
_generate(_result(), _result()),
|
||||
request_id="speech-test",
|
||||
request_arrival_ts=100.0,
|
||||
)
|
||||
assert len([chunk async for chunk in chunks]) == 2
|
||||
assert metrics.ttfp_calls == [("1", "2", pytest.approx(0.25))]
|
||||
assert metrics.underrun_calls == [("1", "2", pytest.approx(0.0))]
|
||||
assert metrics.continuity_calls == [("1", "2", 100)]
|
||||
assert metrics.abort_calls == []
|
||||
assert metrics.completed == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("before_pcm", [True, False])
|
||||
@pytest.mark.parametrize(
|
||||
("error_type", "reason"),
|
||||
[(asyncio.CancelledError, "cancelled"), (speech_module.EngineDeadError, "engine_dead"), (RuntimeError, "error")],
|
||||
)
|
||||
async def test_speech_stream_abort_counted_once_without_continuity(before_pcm, error_type, reason):
|
||||
metrics = _MetricsStub()
|
||||
serving = _serving(metrics)
|
||||
|
||||
async def results():
|
||||
if not before_pcm:
|
||||
yield _result()
|
||||
raise error_type()
|
||||
|
||||
chunks = serving._generate_audio_chunks(results(), request_id="speech-test", request_arrival_ts=100.0)
|
||||
with pytest.raises(error_type):
|
||||
_ = [chunk async for chunk in chunks]
|
||||
|
||||
assert metrics.abort_calls == [reason]
|
||||
assert metrics.completed == 0
|
||||
assert len(metrics.ttfp_calls) == (0 if before_pcm else 1)
|
||||
assert metrics.underrun_calls == []
|
||||
assert metrics.continuity_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_speech_stream_close_counted_once_without_continuity():
|
||||
metrics = _MetricsStub()
|
||||
serving = _serving(metrics)
|
||||
chunks = serving._generate_audio_chunks(
|
||||
_generate(_result(), _result()), request_id="speech-test", request_arrival_ts=100.0
|
||||
)
|
||||
assert await anext(chunks)
|
||||
await chunks.aclose()
|
||||
await chunks.aclose()
|
||||
assert metrics.abort_calls == ["closed"]
|
||||
assert metrics.completed == 0
|
||||
assert metrics.underrun_calls == []
|
||||
assert metrics.continuity_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_speech_retries_ttfp_labels_without_moving_first_packet_time(monkeypatch):
|
||||
metrics = _MetricsStub()
|
||||
serving = _serving(metrics)
|
||||
clock = {"now": 100.25, "perf": 0.25}
|
||||
monkeypatch.setattr(speech_module.time, "time", lambda: clock["now"])
|
||||
monkeypatch.setattr(speech_module.time, "perf_counter", lambda: clock["perf"])
|
||||
|
||||
async def results():
|
||||
yield _result(replica_id=None)
|
||||
clock["now"] = 101.0
|
||||
clock["perf"] = 0.26
|
||||
yield _result(replica_id=2)
|
||||
clock["now"] = 101.25
|
||||
clock["perf"] = 0.27
|
||||
yield _result(replica_id=2)
|
||||
|
||||
chunks = serving._generate_audio_chunks(
|
||||
results(),
|
||||
request_id="speech-test",
|
||||
request_arrival_ts=100.0,
|
||||
)
|
||||
assert len([chunk async for chunk in chunks]) == 3
|
||||
assert metrics.ttfp_calls == [("1", "2", pytest.approx(0.25))]
|
||||
assert metrics.underrun_calls == [("1", "2", pytest.approx(0.0))]
|
||||
assert metrics.continuity_calls == [("1", "2", 100)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("mode", ["raw", "sse", "pcm"])
|
||||
async def test_speech_disconnect_during_send_closes_entire_generator_chain(mode):
|
||||
metrics = _MetricsStub()
|
||||
serving = _serving(metrics)
|
||||
closed = []
|
||||
|
||||
async def source():
|
||||
try:
|
||||
yield _result()
|
||||
yield _result()
|
||||
finally:
|
||||
closed.append(True)
|
||||
|
||||
# Retain the source to prove cleanup does not depend on garbage collection.
|
||||
generator = source()
|
||||
method = {
|
||||
"raw": serving._generate_audio_chunks,
|
||||
"sse": serving._generate_audio_sse_events,
|
||||
"pcm": serving._generate_pcm_chunks,
|
||||
}[mode]
|
||||
stream = method(generator, request_id="speech-test", request_arrival_ts=100.0)
|
||||
|
||||
async def send(message):
|
||||
if message["type"] == "http.response.body":
|
||||
raise OSError("client disconnected")
|
||||
|
||||
if mode == "pcm":
|
||||
# WebSocket handlers already explicitly close their PCM iterator.
|
||||
assert await anext(stream)
|
||||
await stream.aclose()
|
||||
else:
|
||||
response = speech_module._SpeechStreamingResponse(stream)
|
||||
with pytest.raises(OSError, match="client disconnected"):
|
||||
await response.stream_response(send)
|
||||
assert closed == [True]
|
||||
assert metrics.abort_calls == ["closed"]
|
||||
assert metrics.completed == 0
|
||||
assert metrics.underrun_calls == []
|
||||
assert metrics.continuity_calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arrival_ts", [0.0, -1.0])
|
||||
def test_speech_ttfp_invalid_arrival_does_not_consume_guard(arrival_ts):
|
||||
metrics = _MetricsStub()
|
||||
serving = _serving(metrics)
|
||||
state = SimpleNamespace(
|
||||
external_request_id="speech-test",
|
||||
request_arrival_ts=arrival_ts,
|
||||
first_audio_ts=None,
|
||||
audio_emit_stage_id=None,
|
||||
audio_emit_replica_id=None,
|
||||
)
|
||||
serving.engine_client.request_states = {"internal-test": state}
|
||||
|
||||
assert serving._observe_speech_audio_ttfp(request_id="speech-test", result=_result(), first_packet_ts=100.25) == (
|
||||
1,
|
||||
2,
|
||||
False,
|
||||
)
|
||||
assert metrics.ttfp_calls == []
|
||||
assert state.first_audio_ts is None
|
||||
assert state.audio_emit_stage_id is None
|
||||
assert state.audio_emit_replica_id is None
|
||||
|
||||
state.request_arrival_ts = 100.0
|
||||
assert serving._observe_speech_audio_ttfp(request_id="speech-test", result=_result(), first_packet_ts=100.25) == (
|
||||
1,
|
||||
2,
|
||||
True,
|
||||
)
|
||||
assert metrics.ttfp_calls == [("1", "2", pytest.approx(0.25))]
|
||||
assert state.first_audio_ts == 100.25
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("during_send", [False, True])
|
||||
async def test_speech_response_task_cancellation_closes_source(during_send):
|
||||
metrics = _MetricsStub()
|
||||
serving = _serving(metrics)
|
||||
waiting = asyncio.Event()
|
||||
blocker = asyncio.Event()
|
||||
closed = []
|
||||
|
||||
async def source():
|
||||
try:
|
||||
if not during_send:
|
||||
waiting.set()
|
||||
await blocker.wait()
|
||||
yield _result()
|
||||
finally:
|
||||
closed.append(True)
|
||||
|
||||
async def send(message):
|
||||
if during_send and message["type"] == "http.response.body":
|
||||
waiting.set()
|
||||
await blocker.wait()
|
||||
|
||||
generator = source()
|
||||
stream = serving._generate_audio_chunks(generator, request_id="speech-test", request_arrival_ts=100.0)
|
||||
response = speech_module._SpeechStreamingResponse(stream)
|
||||
task = asyncio.create_task(response.stream_response(send))
|
||||
try:
|
||||
await asyncio.wait_for(waiting.wait(), timeout=5)
|
||||
finally:
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
assert closed == [True]
|
||||
assert metrics.abort_calls == ["closed" if during_send else "cancelled"]
|
||||
assert metrics.completed == 0
|
||||
assert metrics.underrun_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_speech_does_not_count_empty_payload_as_first_packet(monkeypatch):
|
||||
metrics = _MetricsStub()
|
||||
serving = _serving(metrics)
|
||||
monkeypatch.setattr("vllm_omni.entrypoints.openai.serving_speech.time.time", lambda: 100.25)
|
||||
|
||||
chunks = serving._generate_audio_chunks(
|
||||
_generate(_result(samples=0), _result()),
|
||||
request_id="speech-test",
|
||||
request_arrival_ts=100.0,
|
||||
)
|
||||
assert len([chunk async for chunk in chunks]) == 2
|
||||
assert metrics.ttfp_calls == [("1", "2", pytest.approx(0.25))]
|
||||
assert metrics.underrun_calls == [("1", "2", pytest.approx(0.0))]
|
||||
assert metrics.continuity_calls == [("1", "2", 100)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_speech_does_not_finalize_continuity_on_error(monkeypatch):
|
||||
metrics = _MetricsStub()
|
||||
serving = _serving(metrics)
|
||||
monkeypatch.setattr("vllm_omni.entrypoints.openai.serving_speech.time.time", lambda: 100.25)
|
||||
|
||||
async def failing_stream():
|
||||
yield _result()
|
||||
raise RuntimeError("stream failed")
|
||||
|
||||
chunks = serving._generate_audio_chunks(
|
||||
failing_stream(),
|
||||
request_id="speech-test",
|
||||
request_arrival_ts=100.0,
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="stream failed"):
|
||||
_ = [chunk async for chunk in chunks]
|
||||
|
||||
assert len(metrics.ttfp_calls) == 1
|
||||
assert metrics.underrun_calls == []
|
||||
assert metrics.continuity_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_speech_does_not_finalize_continuity_on_validation_error(monkeypatch):
|
||||
metrics = _MetricsStub()
|
||||
|
||||
def reject_generation(_tts_params, **_kwargs):
|
||||
raise RuntimeError("generation validation failed")
|
||||
|
||||
adapter = SimpleNamespace(validates_generation=True, validate_generation=reject_generation)
|
||||
serving = _serving(metrics, adapter=adapter)
|
||||
monkeypatch.setattr("vllm_omni.entrypoints.openai.serving_speech.time.time", lambda: 100.25)
|
||||
|
||||
chunks = serving._generate_audio_chunks(
|
||||
_generate(_result()),
|
||||
request_id="speech-test",
|
||||
request_arrival_ts=100.0,
|
||||
tts_params={"task_type": ["Base"]},
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="generation validation failed"):
|
||||
_ = [chunk async for chunk in chunks]
|
||||
|
||||
assert len(metrics.ttfp_calls) == 1
|
||||
assert metrics.underrun_calls == []
|
||||
assert metrics.continuity_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_speech_reports_late_chunk_as_underrun(monkeypatch):
|
||||
metrics = _MetricsStub()
|
||||
serving = _serving(metrics)
|
||||
monkeypatch.setattr("vllm_omni.entrypoints.openai.serving_speech.time.time", lambda: 100.25)
|
||||
perf_times = iter((0.0, 0.1, 0.1, 2.0, 2.0))
|
||||
monkeypatch.setattr("vllm_omni.entrypoints.openai.serving_speech.time.perf_counter", lambda: next(perf_times))
|
||||
|
||||
chunks = serving._generate_audio_chunks(
|
||||
_generate(_result(), _result()),
|
||||
request_id="speech-test",
|
||||
request_arrival_ts=100.0,
|
||||
)
|
||||
assert len([chunk async for chunk in chunks]) == 2
|
||||
assert metrics.underrun_calls[0][:2] == ("1", "2")
|
||||
assert metrics.underrun_calls[0][2] > 0.1
|
||||
assert metrics.continuity_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_streaming_speech_does_not_observe_ttfp():
|
||||
metrics = _MetricsStub()
|
||||
serving = _serving(metrics)
|
||||
serving._audio_encode_speed = lambda _request: 1.0
|
||||
|
||||
async def prepare(_request, **_kwargs):
|
||||
result = _result()
|
||||
result.metrics = {}
|
||||
return "speech-test", _generate(result), {}
|
||||
|
||||
serving._prepare_speech_generation = prepare
|
||||
request = OpenAICreateSpeechRequest(input="hello", response_format="pcm")
|
||||
|
||||
audio_data, media_type = await serving._generate_audio_bytes(request, request_arrival_ts=100.0)
|
||||
|
||||
assert audio_data
|
||||
assert media_type == "audio/pcm"
|
||||
assert metrics.ttfp_calls == []
|
||||
assert metrics.completed == 0
|
||||
assert metrics.abort_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("flush_only", [False, True])
|
||||
@pytest.mark.parametrize("response_format", ["pcm", "wav"])
|
||||
async def test_resampled_speech_records_flush_with_audio_producer(monkeypatch, flush_only, response_format):
|
||||
metrics = _MetricsStub()
|
||||
serving = _serving(metrics)
|
||||
clock = {"now": 0.0}
|
||||
finalized = []
|
||||
original_finalize = speech_module.observe_audio_streaming_finalize
|
||||
|
||||
def capture_finalize(*args, **kwargs):
|
||||
finalized.append(kwargs)
|
||||
original_finalize(*args, **kwargs)
|
||||
|
||||
class BufferedResampler:
|
||||
def __init__(self, source_rate, target_rate):
|
||||
assert (source_rate, target_rate) == (16000, 24000)
|
||||
|
||||
def process(self, chunk, *, final=False):
|
||||
clock["now"] = 0.5 if final else 0.25
|
||||
if not final and flush_only:
|
||||
return np.empty(0, dtype=np.float32)
|
||||
return np.zeros(2400, dtype=np.float32)
|
||||
|
||||
monkeypatch.setattr(speech_module, "StreamingAudioResampler", BufferedResampler)
|
||||
monkeypatch.setattr(speech_module, "observe_audio_streaming_finalize", capture_finalize)
|
||||
monkeypatch.setattr(speech_module.time, "time", lambda: 100.0 + clock["now"])
|
||||
monkeypatch.setattr(speech_module.time, "perf_counter", lambda: clock["now"])
|
||||
# The last result is not audio and must not supply the flush metric labels.
|
||||
non_audio = SimpleNamespace(multimodal_output={"timestamps": []}, stage_id=9, replica_id=8)
|
||||
chunks = [
|
||||
chunk
|
||||
async for chunk in serving._generate_audio_chunks(
|
||||
_generate(_result(), non_audio),
|
||||
request_id="speech-test",
|
||||
response_format=response_format,
|
||||
request_start_s=0.0,
|
||||
request_arrival_ts=100.0,
|
||||
target_sample_rate=24000,
|
||||
)
|
||||
]
|
||||
|
||||
if response_format == "wav":
|
||||
assert chunks.pop(0).startswith(b"RIFF")
|
||||
expected_arrivals = [0.5] if flush_only else [0.25, 0.5]
|
||||
assert [len(chunk) for chunk in chunks] == [4800] * len(expected_arrivals)
|
||||
assert metrics.ttfp_calls == [("1", "2", pytest.approx(expected_arrivals[0]))]
|
||||
assert len(finalized) == 1
|
||||
assert finalized[0]["sample_rate"] == 24000
|
||||
assert finalized[0]["channels"] == 1
|
||||
assert finalized[0]["chunk_bytes"] == [4800] * len(expected_arrivals)
|
||||
assert finalized[0]["chunk_arrival_times_s"] == expected_arrivals
|
||||
assert metrics.underrun_calls == [("1", "2", pytest.approx(0.0 if flush_only else 0.15))]
|
||||
assert metrics.continuity_calls == ([("1", "2", 100)] if flush_only else [])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("empty_pcm", [False, True])
|
||||
async def test_resampled_speech_without_pcm_does_not_emit_metrics(monkeypatch, empty_pcm):
|
||||
metrics = _MetricsStub()
|
||||
serving = _serving(metrics)
|
||||
|
||||
class EmptyResampler:
|
||||
def __init__(self, source_rate, target_rate):
|
||||
pass
|
||||
|
||||
def process(self, chunk, *, final=False):
|
||||
# Also cover a nonempty flush waveform whose encoder emits no PCM.
|
||||
return np.zeros(10 if final and empty_pcm else 0, dtype=np.float32)
|
||||
|
||||
monkeypatch.setattr(speech_module, "StreamingAudioResampler", EmptyResampler)
|
||||
serving.create_audio = lambda audio_obj: SimpleNamespace(audio_data=b"", media_type="audio/pcm")
|
||||
chunks = [
|
||||
chunk
|
||||
async for chunk in serving._generate_audio_chunks(
|
||||
_generate(_result()),
|
||||
request_id="speech-test",
|
||||
request_arrival_ts=100.0,
|
||||
target_sample_rate=24000,
|
||||
)
|
||||
]
|
||||
assert not any(chunks)
|
||||
assert metrics.ttfp_calls == []
|
||||
assert metrics.underrun_calls == []
|
||||
assert metrics.continuity_calls == []
|
||||
@@ -48,7 +48,15 @@ def _build_test_app(
|
||||
speech_service.forced_aligner_enabled = False
|
||||
|
||||
async def mock_generate_pcm_chunks(
|
||||
_generator, _request_id, *, include_sample_rate=False, tts_params=None, collect=None
|
||||
_generator,
|
||||
_request_id,
|
||||
*,
|
||||
request_start_s=None,
|
||||
request_arrival_ts=None,
|
||||
include_sample_rate=False,
|
||||
tts_params=None,
|
||||
collect=None,
|
||||
cumulative_audio=False,
|
||||
):
|
||||
for chunk in (b"\x01\x02", b"\x03\x04\x05"):
|
||||
yield (chunk, 24000) if include_sample_rate else chunk
|
||||
@@ -249,6 +257,7 @@ class TestStreamingSpeechWebSocket:
|
||||
|
||||
def test_streaming_multiple_binary_frames(self, mocker: MockerFixture):
|
||||
captured_requests = []
|
||||
captured_timing = {}
|
||||
captured_tts_params = []
|
||||
|
||||
speech_service = mocker.MagicMock(spec=OmniOpenAIServingSpeech)
|
||||
@@ -257,13 +266,27 @@ class TestStreamingSpeechWebSocket:
|
||||
speech_service.engine_client.abort = mocker.AsyncMock()
|
||||
speech_service.forced_aligner_enabled = False
|
||||
|
||||
async def mock_prepare_speech_generation(request):
|
||||
async def mock_prepare_speech_generation(request, *, arrival_time=None):
|
||||
captured_requests.append(request)
|
||||
assert arrival_time is not None
|
||||
captured_timing["prepare_arrival"] = arrival_time
|
||||
return "req-stream", object(), {"_qwen3_tts_effective_max_tokens": [192]}
|
||||
|
||||
speech_service._prepare_speech_generation = mock_prepare_speech_generation
|
||||
|
||||
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False, tts_params=None):
|
||||
async def mock_generate_pcm_chunks(
|
||||
_generator,
|
||||
_request_id,
|
||||
*,
|
||||
request_start_s=None,
|
||||
request_arrival_ts=None,
|
||||
include_sample_rate=False,
|
||||
tts_params=None,
|
||||
):
|
||||
assert request_start_s is not None
|
||||
assert request_arrival_ts is not None
|
||||
captured_timing["chunk_start"] = request_start_s
|
||||
captured_timing["chunk_arrival"] = request_arrival_ts
|
||||
captured_tts_params.append(tts_params)
|
||||
for chunk in (b"\x01\x02", b"\x03\x04\x05", b"\x06"):
|
||||
yield (chunk, 24000) if include_sample_rate else chunk
|
||||
@@ -309,6 +332,8 @@ class TestStreamingSpeechWebSocket:
|
||||
assert captured_requests[0].stream is True
|
||||
assert captured_requests[0].response_format == "pcm"
|
||||
assert captured_requests[0].initial_codec_chunk_frames == 12
|
||||
assert captured_timing["prepare_arrival"] == captured_timing["chunk_arrival"]
|
||||
assert captured_timing["chunk_start"] > 0
|
||||
assert captured_tts_params == [{"_qwen3_tts_effective_max_tokens": [192]}]
|
||||
assert speech_service._generate_audio_bytes.await_count == 0
|
||||
|
||||
@@ -341,8 +366,9 @@ class TestStreamingSpeechWebSocket:
|
||||
speech_service.engine_client.abort = mocker.AsyncMock()
|
||||
speech_service.forced_aligner_enabled = True
|
||||
|
||||
async def mock_prepare_speech_generation(request):
|
||||
async def mock_prepare_speech_generation(request, *, arrival_time=None):
|
||||
captured_requests.append(request)
|
||||
assert arrival_time is not None
|
||||
return "req-stream", object(), {}
|
||||
|
||||
speech_service._prepare_speech_generation = mock_prepare_speech_generation
|
||||
@@ -353,8 +379,19 @@ class TestStreamingSpeechWebSocket:
|
||||
# The forced-aligner stage rides the same generator: its pooling output
|
||||
# is surfaced via the ``collect`` channel once the audio has streamed.
|
||||
async def mock_generate_pcm_chunks(
|
||||
_generator, _request_id, *, include_sample_rate=False, tts_params=None, collect=None
|
||||
_generator,
|
||||
_request_id,
|
||||
*,
|
||||
request_start_s=None,
|
||||
request_arrival_ts=None,
|
||||
include_sample_rate=False,
|
||||
tts_params=None,
|
||||
collect=None,
|
||||
cumulative_audio=False,
|
||||
):
|
||||
assert request_start_s is not None
|
||||
assert request_arrival_ts is not None
|
||||
assert cumulative_audio is True
|
||||
for chunk in (first_chunk, second_chunk):
|
||||
yield (chunk, 1000) if include_sample_rate else chunk
|
||||
if collect is not None:
|
||||
@@ -438,7 +475,15 @@ class TestStreamingSpeechWebSocket:
|
||||
speech_service._prepare_speech_generation = mocker.AsyncMock(return_value=("req", object(), {}))
|
||||
|
||||
async def mock_generate_pcm_chunks(
|
||||
_generator, _request_id, *, include_sample_rate=False, tts_params=None, collect=None
|
||||
_generator,
|
||||
_request_id,
|
||||
*,
|
||||
request_start_s=None,
|
||||
request_arrival_ts=None,
|
||||
include_sample_rate=False,
|
||||
tts_params=None,
|
||||
collect=None,
|
||||
cumulative_audio=False,
|
||||
):
|
||||
chunk = b"\x01" * 1000
|
||||
yield (chunk, 1000) if include_sample_rate else chunk
|
||||
@@ -620,7 +665,15 @@ class TestStreamingSpeechWebSocket:
|
||||
speech_service.engine_client.abort = mocker.AsyncMock()
|
||||
speech_service.forced_aligner_enabled = False
|
||||
|
||||
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False, tts_params=None):
|
||||
async def mock_generate_pcm_chunks(
|
||||
_generator,
|
||||
_request_id,
|
||||
*,
|
||||
request_start_s=None,
|
||||
request_arrival_ts=None,
|
||||
include_sample_rate=False,
|
||||
tts_params=None,
|
||||
):
|
||||
yield b"\x01\x02"
|
||||
raise RuntimeError("stream boom")
|
||||
|
||||
@@ -716,7 +769,15 @@ class TestStreamingSpeechWebSocket:
|
||||
speech_service.engine_client.abort = mocker.AsyncMock()
|
||||
speech_service.forced_aligner_enabled = False
|
||||
|
||||
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False, tts_params=None):
|
||||
async def mock_generate_pcm_chunks(
|
||||
_generator,
|
||||
_request_id,
|
||||
*,
|
||||
request_start_s=None,
|
||||
request_arrival_ts=None,
|
||||
include_sample_rate=False,
|
||||
tts_params=None,
|
||||
):
|
||||
yield b"\x01\x02"
|
||||
|
||||
speech_service._generate_pcm_chunks = mock_generate_pcm_chunks
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Unit tests for `/v1/realtime/video` WebSocket video output streaming."""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -7,12 +7,13 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from http import HTTPStatus
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi import FastAPI, WebSocket
|
||||
from fastapi import FastAPI, HTTPException, WebSocket
|
||||
from pytest_mock import MockerFixture
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
@@ -957,3 +958,100 @@ class TestStreamingVideoOutputPromptUpdate:
|
||||
"transition_chunks": 2,
|
||||
},
|
||||
)
|
||||
|
||||
def test_composite_prompt_and_camera_interaction_forwarded(self, mocker: MockerFixture):
|
||||
"""Prompt + camera multi_modal_data is admitted and forwarded to the engine."""
|
||||
|
||||
async def mock_generate(*_args, **_kwargs):
|
||||
yield OmniRequestOutput.from_diffusion(
|
||||
request_id="req-ws",
|
||||
images=[_fake_video_frames(2)],
|
||||
final_output_type="image",
|
||||
finished=False,
|
||||
)
|
||||
await asyncio.sleep(0.3)
|
||||
yield OmniRequestOutput.from_diffusion(
|
||||
request_id="req-ws",
|
||||
images=[_fake_video_frames(2)],
|
||||
final_output_type="image",
|
||||
finished=True,
|
||||
)
|
||||
|
||||
app, _handler, engine_client = _build_test_app(
|
||||
mocker=mocker,
|
||||
streaming_chunks=[(b"mp4-chunk-0", False), (b"mp4-chunk-1", True)],
|
||||
mock_generate=mock_generate,
|
||||
)
|
||||
|
||||
camera_payload = {
|
||||
"mode": "target",
|
||||
"data": {"translation": [0.0, 0.0, 1.0]},
|
||||
}
|
||||
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime/video") as ws:
|
||||
ws.send_json({"type": "session.start", "prompt": "initial scene"})
|
||||
start = ws.receive_json()
|
||||
assert start["type"] == "video.start"
|
||||
request_id = start["request_id"]
|
||||
|
||||
assert _receive_video_chunk(ws)[1] == b"mp4-chunk-0"
|
||||
|
||||
ws.send_json(
|
||||
{
|
||||
"type": "session.interaction",
|
||||
"interaction": {
|
||||
"event_id": "combo-1",
|
||||
"event": {
|
||||
"prompt": "new scene",
|
||||
"multi_modal_data": {"camera": camera_payload},
|
||||
},
|
||||
"transition_chunks": 2,
|
||||
},
|
||||
}
|
||||
)
|
||||
queued = ws.receive_json()
|
||||
assert queued["type"] == "session.interaction.queued"
|
||||
assert queued["event_id"] == "combo-1"
|
||||
|
||||
assert _receive_video_chunk(ws)[1] == b"mp4-chunk-1"
|
||||
assert ws.receive_json()["type"] == "session.done"
|
||||
|
||||
engine_client.submit_interaction_async.assert_awaited_once_with(
|
||||
request_id,
|
||||
interaction={
|
||||
"event_id": "combo-1",
|
||||
"event": {
|
||||
"prompt": "new scene",
|
||||
"multi_modal_data": {"camera": camera_payload},
|
||||
},
|
||||
"transition_chunks": 2,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TestStreamingVideoOutputExtraParams:
|
||||
"""extra_params handling when building streaming generation inputs."""
|
||||
|
||||
@staticmethod
|
||||
def _handler() -> OmniStreamingVideoOutputHandler:
|
||||
handler = object.__new__(OmniStreamingVideoOutputHandler)
|
||||
handler._engine_client = SimpleNamespace(default_sampling_params_list=[])
|
||||
return handler
|
||||
|
||||
async def test_preencode_mp4_is_rejected_for_streaming_sessions(self):
|
||||
"""Worker-side pre-encoding emits one progressive MP4 the fMP4 encoder cannot consume."""
|
||||
request = VideoGenerationRequest(prompt="p", extra_params={"preencode_mp4": True})
|
||||
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
await self._handler()._build_prompt_and_sampling_params(request)
|
||||
|
||||
assert excinfo.value.status_code == HTTPStatus.BAD_REQUEST.value
|
||||
assert "preencode_mp4" in excinfo.value.detail
|
||||
|
||||
async def test_other_extra_params_still_reach_the_engine(self):
|
||||
request = VideoGenerationRequest(prompt="p", extra_params={"flow_shift": 3.0})
|
||||
|
||||
_, gen_params, _ = await self._handler()._build_prompt_and_sampling_params(request)
|
||||
|
||||
assert gen_params.extra_args["flow_shift"] == 3.0
|
||||
@@ -1,19 +1,26 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Tests for the serving-layer streaming video WebSocket handler."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import gc
|
||||
import io
|
||||
import json
|
||||
import threading
|
||||
import weakref
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from PIL import Image
|
||||
from vllm.sampling_params import RequestOutputKind, SamplingParams
|
||||
|
||||
from tests.helpers.serving_chat import build_serving_chat
|
||||
from vllm_omni.config.stage_config import StageConfig
|
||||
from vllm_omni.entrypoints.openai import video_stream_base, video_stream_envs
|
||||
from vllm_omni.entrypoints.openai.serving_video_stream import (
|
||||
QwenOmniStreamingVideoHandler,
|
||||
@@ -38,10 +45,10 @@ def _b64(data: bytes) -> str:
|
||||
|
||||
def _text_result(text: str) -> OmniRequestOutput:
|
||||
class Output:
|
||||
pass
|
||||
text: str
|
||||
|
||||
class RequestOutput:
|
||||
pass
|
||||
outputs: list[Output]
|
||||
|
||||
output = Output()
|
||||
output.text = text
|
||||
@@ -52,10 +59,10 @@ def _text_result(text: str) -> OmniRequestOutput:
|
||||
|
||||
def _audio_result(audio_data: Any) -> OmniRequestOutput:
|
||||
class Output:
|
||||
pass
|
||||
multimodal_output: dict[str, Any]
|
||||
|
||||
class RequestOutput:
|
||||
pass
|
||||
outputs: list[Output]
|
||||
|
||||
output = Output()
|
||||
output.multimodal_output = {"audio": audio_data}
|
||||
@@ -138,6 +145,130 @@ async def test_receive_config_accepts_client_legacy_aliases():
|
||||
assert config.frame_filter_threshold == 0.87
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def run_sampling_session(monkeypatch):
|
||||
async def run(config_overrides):
|
||||
engine = MagicMock()
|
||||
engine.stage_configs = [
|
||||
StageConfig(stage_id=index, model_stage=name)
|
||||
for index, name in enumerate(("thinker", "talker", "code2wav"))
|
||||
]
|
||||
engine.default_sampling_params_list = [
|
||||
SamplingParams(temperature=0.4, max_tokens=64, top_p=0.85),
|
||||
SamplingParams(temperature=0.7, max_tokens=96),
|
||||
SamplingParams(temperature=0.8, max_tokens=128),
|
||||
]
|
||||
|
||||
async def generate(**kwargs):
|
||||
yield _text_result("answer")
|
||||
|
||||
engine.generate.side_effect = generate
|
||||
handler = QwenOmniStreamingVideoHandler(
|
||||
chat_service=build_serving_chat(engine_client=engine),
|
||||
engine_client=engine,
|
||||
)
|
||||
monkeypatch.setattr(handler, "_preprocess_to_engine_prompt", AsyncMock(return_value={"prompt": "video"}))
|
||||
ws = MockWebSocket(
|
||||
[
|
||||
json.dumps(
|
||||
{
|
||||
"type": "session.config",
|
||||
"model": "test",
|
||||
"modalities": ["text"],
|
||||
"enable_frame_filter": False,
|
||||
**config_overrides,
|
||||
}
|
||||
),
|
||||
json.dumps({"type": "video.frame", "data": _b64(_make_jpeg())}),
|
||||
json.dumps({"type": "video.query", "text": "describe"}),
|
||||
json.dumps({"type": "video.done"}),
|
||||
]
|
||||
)
|
||||
await handler.handle_session(ws)
|
||||
return ws, engine
|
||||
|
||||
return run
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("async_chunk_mode", ["on", "off"])
|
||||
@pytest.mark.parametrize("num_stage_overrides", [1, 3])
|
||||
async def test_video_sampling_params_reach_engine(
|
||||
run_sampling_session, monkeypatch, async_chunk_mode, num_stage_overrides
|
||||
):
|
||||
monkeypatch.setenv("VLLM_VIDEO_ASYNC_CHUNK", async_chunk_mode)
|
||||
overrides = [
|
||||
{"temperature": 0.2, "max_tokens": 1, "seed": 42},
|
||||
{"temperature": 0.6, "max_tokens": 12},
|
||||
{"temperature": 0.9, "max_tokens": 24},
|
||||
][:num_stage_overrides]
|
||||
|
||||
ws, engine = await run_sampling_session({"sampling_params_list": overrides})
|
||||
|
||||
assert not [msg for msg in ws.sent if msg["type"] == "error"]
|
||||
engine.generate.assert_called_once()
|
||||
params = engine.generate.call_args.kwargs["sampling_params_list"]
|
||||
assert len(params) == 3
|
||||
for index, param in enumerate(params):
|
||||
assert isinstance(param, SamplingParams)
|
||||
default = engine.default_sampling_params_list[index]
|
||||
expected = (
|
||||
overrides[index]
|
||||
if index < len(overrides)
|
||||
else {
|
||||
"temperature": default.temperature,
|
||||
"max_tokens": default.max_tokens,
|
||||
}
|
||||
)
|
||||
assert param.temperature == expected["temperature"]
|
||||
assert param.max_tokens == expected["max_tokens"]
|
||||
assert param.output_kind == RequestOutputKind.DELTA
|
||||
assert param is not default
|
||||
assert params[0].seed == 42
|
||||
assert all(param.output_kind == RequestOutputKind.CUMULATIVE for param in engine.default_sampling_params_list)
|
||||
assert next(msg for msg in ws.sent if msg["type"] == "response.text.done")["text"] == "answer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_video_sampling_params_provided_stage_uses_constructor_defaults(run_sampling_session):
|
||||
ws, engine = await run_sampling_session({"sampling_params_list": [{"temperature": 0.2}]})
|
||||
|
||||
assert not [msg for msg in ws.sent if msg["type"] == "error"]
|
||||
engine.generate.assert_called_once()
|
||||
params = engine.generate.call_args.kwargs["sampling_params_list"]
|
||||
constructor_defaults = SamplingParams()
|
||||
assert params[0].temperature == 0.2
|
||||
for field in ("max_tokens", "top_p"):
|
||||
assert getattr(params[0], field) == getattr(constructor_defaults, field)
|
||||
assert getattr(params[0], field) != getattr(engine.default_sampling_params_list[0], field)
|
||||
assert len(params) == 3
|
||||
for param, default in zip(params[1:], engine.default_sampling_params_list[1:]):
|
||||
assert param.temperature == default.temperature
|
||||
assert param.max_tokens == default.max_tokens
|
||||
assert param.top_p == default.top_p
|
||||
assert param is not default
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("config_overrides", [{}, {"sampling_params_list": None}, {"sampling_params_list": []}])
|
||||
async def test_video_sampling_params_omitted_preserve_engine_defaults(run_sampling_session, config_overrides):
|
||||
ws, engine = await run_sampling_session(config_overrides)
|
||||
|
||||
assert not [msg for msg in ws.sent if msg["type"] == "error"]
|
||||
engine.generate.assert_called_once()
|
||||
assert engine.generate.call_args.kwargs.get("sampling_params_list") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("invalid_params", [{"temperature": -1}, {"max_tokens": 0}, {"unknown_sampling_option": 1}])
|
||||
async def test_video_sampling_params_invalid_do_not_reach_engine(run_sampling_session, invalid_params):
|
||||
ws, engine = await run_sampling_session({"sampling_params_list": [invalid_params]})
|
||||
|
||||
engine.generate.assert_not_called()
|
||||
assert any(msg["type"] == "error" for msg in ws.sent)
|
||||
assert ws.sent[-1]["type"] == "session.done"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_video_frame_ack_reports_receiver_buffer_state():
|
||||
ws = MockWebSocket(
|
||||
@@ -244,6 +375,200 @@ async def test_video_frames_consumed_is_emitted_after_engine_uses_frame_prompt()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"num_frames,bad_index,expected_indices",
|
||||
[
|
||||
pytest.param(2, 4, [0], id="bad-last-no-refill"),
|
||||
pytest.param(1, 4, [], id="all-selected-frames-bad"),
|
||||
pytest.param(8, 4, [0, 1, 2, 3], id="below-sampling-limit"),
|
||||
pytest.param(2, 1, [0, 4], id="bad-frame-not-selected"),
|
||||
pytest.param(2, 0, [4], id="bad-first-frame"),
|
||||
],
|
||||
)
|
||||
async def test_video_frames_consumed_excludes_bad_frame_in_query_snapshot(
|
||||
monkeypatch, num_frames, bad_index, expected_indices
|
||||
):
|
||||
"""A query queued before bad-frame cleanup must report only its prompt images."""
|
||||
query_queued = asyncio.Event()
|
||||
decode_failed = asyncio.Event()
|
||||
release_decode = asyncio.Event()
|
||||
pong_blocked = asyncio.Event()
|
||||
release_pong = asyncio.Event()
|
||||
turn_done = asyncio.Event()
|
||||
original_to_thread = asyncio.to_thread
|
||||
bad_image = b"not-a-jpeg"
|
||||
frames = [_make_jpeg(r=index * 40) for index in range(5)]
|
||||
frames[bad_index] = bad_image
|
||||
engine_prompts = []
|
||||
|
||||
class ObservedQueue(asyncio.Queue):
|
||||
def put_nowait(self, item):
|
||||
super().put_nowait(item)
|
||||
if isinstance(item, dict):
|
||||
if item.get("type") == "video.query":
|
||||
query_queued.set()
|
||||
elif item.get("type") == "_internal.frame_decode_failed":
|
||||
decode_failed.set()
|
||||
|
||||
async def gated_to_thread(function, *args, **kwargs):
|
||||
if function is video_stream_base._decode_frame_bytes and args[0] == bad_image:
|
||||
await release_decode.wait()
|
||||
return await original_to_thread(function, *args, **kwargs)
|
||||
|
||||
class BackpressuredWebSocket(TimedWebSocket):
|
||||
async def send_json(self, data):
|
||||
await super().send_json(data)
|
||||
if data["type"] == "pong":
|
||||
pong_blocked.set()
|
||||
await release_pong.wait()
|
||||
elif data["type"] == "response.text.done":
|
||||
turn_done.set()
|
||||
|
||||
class OneOutputEngine:
|
||||
async def generate(self, **kwargs):
|
||||
engine_prompts.append(kwargs["prompt"])
|
||||
yield _text_result("visible")
|
||||
|
||||
class CapturingHandler(QwenOmniStreamingVideoHandler):
|
||||
async def _preprocess_to_engine_prompt(self, request):
|
||||
return {"messages": request.messages}
|
||||
|
||||
# Construct the client queue before observing the server's message queue.
|
||||
ws = BackpressuredWebSocket()
|
||||
monkeypatch.setattr(asyncio, "Queue", ObservedQueue)
|
||||
monkeypatch.setattr(asyncio, "to_thread", gated_to_thread)
|
||||
handler = CapturingHandler(chat_service=object(), engine_client=OneOutputEngine(), idle_timeout=5.0)
|
||||
ws.put(
|
||||
{
|
||||
"type": "session.config",
|
||||
"model": "test",
|
||||
"modalities": ["text"],
|
||||
"num_frames": num_frames,
|
||||
"enable_frame_filter": False,
|
||||
}
|
||||
)
|
||||
for index, frame in enumerate(frames):
|
||||
ws.put({"type": "video.frame", "data": _b64(frame), "frame_id": f"frame-{index}", "pts_ms": index * 100})
|
||||
ws.put({"type": "ping"})
|
||||
ws.put({"type": "video.query", "text": "describe"})
|
||||
task = asyncio.create_task(handler.handle_session(ws))
|
||||
try:
|
||||
# Hold the processor at a WebSocket send. Queue the query first, then
|
||||
# finish the real failing decode so its cleanup lands behind the query.
|
||||
await asyncio.wait_for(pong_blocked.wait(), timeout=5.0)
|
||||
await asyncio.wait_for(query_queued.wait(), timeout=5.0)
|
||||
release_decode.set()
|
||||
await asyncio.wait_for(decode_failed.wait(), timeout=5.0)
|
||||
release_pong.set()
|
||||
await asyncio.wait_for(turn_done.wait(), timeout=5.0)
|
||||
ws.put({"type": "video.done"})
|
||||
await asyncio.wait_for(task, timeout=5.0)
|
||||
finally:
|
||||
release_decode.set()
|
||||
release_pong.set()
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
assert len(engine_prompts) == 1
|
||||
images = [part for part in engine_prompts[0]["messages"][-1]["content"] if part["type"].startswith("image_")]
|
||||
# Expected positions preserve sample-then-filter: do not refill a dropped
|
||||
# position or shift the remaining metadata when a frame fails to decode.
|
||||
assert len(images) == len(expected_indices)
|
||||
for image, index in zip(images, expected_indices):
|
||||
if image["type"] == "image_pil":
|
||||
assert image["image_pil"].tobytes() == Image.open(io.BytesIO(frames[index])).convert("RGB").tobytes()
|
||||
else:
|
||||
assert image["image_url"]["url"] == f"data:image/jpeg;base64,{_b64(frames[index])}"
|
||||
consumed_events = [message for message in ws.sent if message.get("type") == "video.frames.consumed"]
|
||||
assert len(consumed_events) == 1
|
||||
consumed = consumed_events[0]
|
||||
expected_ids = [f"frame-{index}" for index in expected_indices]
|
||||
assert consumed["frame_ids"] == expected_ids
|
||||
assert [frame["frame_id"] for frame in consumed["frames"]] == expected_ids
|
||||
assert [frame["pts_ms"] for frame in consumed["frames"]] == [index * 100 for index in expected_indices]
|
||||
assert consumed["latest_pts_ms"] == (expected_indices[-1] * 100 if expected_indices else None)
|
||||
assert {"type": "error", "message": "Frame decode failed"} in ws.sent
|
||||
assert ws.sent[-1]["type"] == "session.done"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"num_frames,frame_ids,repeat_image,expected_indices",
|
||||
[
|
||||
pytest.param(2, ["f0", "f1", "f2", "f3", "f4"], False, [0, 4], id="stride-and-last"),
|
||||
pytest.param(1, ["f0", "f1", "f2"], False, [2], id="last-only"),
|
||||
pytest.param(8, ["f0", "f1", "f2"], False, [0, 1, 2], id="below-sampling-limit"),
|
||||
pytest.param(2, [None, "f1", None, "f3", None], False, [0, 4], id="unidentified-selected-frames"),
|
||||
pytest.param(2, [None, None, None], False, [0, 2], id="no-frame-ids"),
|
||||
pytest.param(2, ["same", "same", "same"], False, [0, 2], id="repeated-frame-id"),
|
||||
pytest.param(2, ["f0", "f1", "f2"], True, [0, 2], id="repeated-image"),
|
||||
],
|
||||
)
|
||||
async def test_video_frame_selection_preserves_uncached_frames(
|
||||
monkeypatch, num_frames, frame_ids, repeat_image, expected_indices
|
||||
):
|
||||
"""Prewarm misses still use image URLs, with metadata paired by buffer position."""
|
||||
engine_prompts = []
|
||||
pending_decode = asyncio.Event()
|
||||
|
||||
async def blocked_to_thread(*args, **kwargs):
|
||||
await pending_decode.wait()
|
||||
|
||||
monkeypatch.setattr(asyncio, "to_thread", blocked_to_thread)
|
||||
|
||||
class OneOutputEngine:
|
||||
async def generate(self, **kwargs):
|
||||
engine_prompts.append(kwargs["prompt"])
|
||||
yield _text_result("visible")
|
||||
|
||||
class CapturingHandler(QwenOmniStreamingVideoHandler):
|
||||
async def _preprocess_to_engine_prompt(self, request):
|
||||
return {"messages": request.messages}
|
||||
|
||||
frames = [_b64(_make_jpeg(r=0 if repeat_image else index * 40)) for index in range(len(frame_ids))]
|
||||
messages = [
|
||||
{
|
||||
"type": "session.config",
|
||||
"model": "test",
|
||||
"modalities": ["text"],
|
||||
"num_frames": num_frames,
|
||||
"enable_frame_filter": False,
|
||||
},
|
||||
*[
|
||||
{"type": "video.frame", "data": frame, "frame_id": frame_ids[index], "pts_ms": index * 100}
|
||||
for index, frame in enumerate(frames)
|
||||
],
|
||||
{"type": "video.query", "text": "describe"},
|
||||
{"type": "video.done"},
|
||||
]
|
||||
ws = MockWebSocket([json.dumps(message) for message in messages])
|
||||
handler = CapturingHandler(chat_service=object(), engine_client=OneOutputEngine())
|
||||
await asyncio.wait_for(handler.handle_session(ws), timeout=5.0)
|
||||
|
||||
assert not [message for message in ws.sent if message["type"] == "error"]
|
||||
assert len(engine_prompts) == 1
|
||||
content = engine_prompts[0]["messages"][-1]["content"]
|
||||
assert content == [
|
||||
*[
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{frames[index]}"}}
|
||||
for index in expected_indices
|
||||
],
|
||||
{"type": "text", "text": "describe"},
|
||||
]
|
||||
consumed_events = [message for message in ws.sent if message["type"] == "video.frames.consumed"]
|
||||
if not any(frame_ids):
|
||||
assert consumed_events == []
|
||||
else:
|
||||
assert len(consumed_events) == 1
|
||||
consumed = consumed_events[0]
|
||||
assert consumed["frame_ids"] == [frame_ids[index] for index in expected_indices if frame_ids[index] is not None]
|
||||
assert [frame["frame_id"] for frame in consumed["frames"]] == [frame_ids[index] for index in expected_indices]
|
||||
assert [frame["pts_ms"] for frame in consumed["frames"]] == [index * 100 for index in expected_indices]
|
||||
assert consumed["latest_pts_ms"] == expected_indices[-1] * 100
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audio_in_video_sets_mm_processor_kwargs():
|
||||
captured_requests = []
|
||||
@@ -455,6 +780,54 @@ async def test_async_chunk_mode_is_read_by_engine_path_at_runtime(monkeypatch):
|
||||
assert not any(m.get("type") == "response.text.delta" for m in ws_off.sent)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("async_chunk_mode", ["on", "off"])
|
||||
async def test_audio_events_use_output_audio_names(monkeypatch, async_chunk_mode):
|
||||
class AudioEngine:
|
||||
def generate(self, **_kwargs):
|
||||
async def _gen():
|
||||
yield _audio_result([torch.zeros(1)])
|
||||
|
||||
return _gen()
|
||||
|
||||
class AudioHandler(QwenOmniStreamingVideoHandler):
|
||||
async def _preprocess_to_engine_prompt(self, request):
|
||||
return {"prompt": "x"}
|
||||
|
||||
monkeypatch.setenv("VLLM_VIDEO_ASYNC_CHUNK", async_chunk_mode)
|
||||
monkeypatch.setattr(
|
||||
OmniStreamingVideoHandler,
|
||||
"_encode_audio_wav_b64",
|
||||
staticmethod(lambda _audio: "audio-b64"),
|
||||
)
|
||||
|
||||
ws = MockWebSocket()
|
||||
handler = AudioHandler(chat_service=object(), engine_client=AudioEngine())
|
||||
config = StreamingVideoSessionConfig(model="test", modalities=["text", "audio"])
|
||||
|
||||
await handler._process_query_engine(
|
||||
ws,
|
||||
config,
|
||||
[_b64(_make_jpeg())],
|
||||
bytearray(),
|
||||
[],
|
||||
"describe",
|
||||
"req-audio-events",
|
||||
asyncio.Event(),
|
||||
{},
|
||||
)
|
||||
|
||||
audio_events = [message for message in ws.sent if "audio" in message.get("type", "")]
|
||||
assert audio_events == [
|
||||
{
|
||||
"type": "response.output_audio.delta",
|
||||
"data": "audio-b64",
|
||||
"format": "wav",
|
||||
},
|
||||
{"type": "response.output_audio.done"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_without_engine_client_sends_error():
|
||||
ws = MockWebSocket()
|
||||
@@ -513,6 +886,88 @@ async def test_new_query_cancels_in_flight_query():
|
||||
assert "session.done" in ws.sent_types()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("delay_abort", [False, True], ids=["immediate-ack", "delayed-ack"])
|
||||
async def test_interrupted_queries_wait_for_abort_without_fixed_delay(monkeypatch, delay_abort):
|
||||
started: asyncio.Queue[str] = asyncio.Queue()
|
||||
abort_started: asyncio.Queue[str] = asyncio.Queue()
|
||||
allow_abort = asyncio.Event()
|
||||
if not delay_abort:
|
||||
allow_abort.set()
|
||||
request_ids: list[str] = []
|
||||
closed: list[str] = []
|
||||
aborted: list[str] = []
|
||||
delays: list[float] = []
|
||||
original_sleep = asyncio.sleep
|
||||
|
||||
async def record_sleep(delay, result=None):
|
||||
# Observe requested delays without a machine-dependent latency limit.
|
||||
delays.append(delay)
|
||||
return await original_sleep(0, result)
|
||||
|
||||
monkeypatch.setattr(video_stream_base.asyncio, "sleep", record_sleep)
|
||||
|
||||
class BlockingEngine:
|
||||
async def generate(self, *, request_id, **kwargs):
|
||||
if request_ids:
|
||||
assert aborted[-1] == request_ids[-1]
|
||||
request_ids.append(request_id)
|
||||
started.put_nowait(request_id)
|
||||
try:
|
||||
if len(request_ids) <= 3:
|
||||
yield _text_result("partial")
|
||||
await asyncio.Event().wait()
|
||||
else:
|
||||
yield _text_result("final")
|
||||
finally:
|
||||
closed.append(request_id)
|
||||
|
||||
async def abort(self, request_id):
|
||||
assert request_id in closed
|
||||
abort_started.put_nowait(request_id)
|
||||
await allow_abort.wait()
|
||||
aborted.append(request_id)
|
||||
|
||||
class PreprocessedHandler(QwenOmniStreamingVideoHandler):
|
||||
async def _preprocess_to_engine_prompt(self, request):
|
||||
return {"prompt_token_ids": [1]}
|
||||
|
||||
ws = TimedWebSocket()
|
||||
handler = PreprocessedHandler(chat_service=object(), engine_client=BlockingEngine(), idle_timeout=5.0)
|
||||
task = asyncio.create_task(handler.handle_session(ws))
|
||||
try:
|
||||
ws.put({"type": "session.config", "modalities": ["text"], "enable_frame_filter": False})
|
||||
ws.put({"type": "video.frame", "data": _b64(_make_jpeg())})
|
||||
ws.put({"type": "video.query", "text": "first"})
|
||||
previous_id = await asyncio.wait_for(started.get(), timeout=2.0)
|
||||
|
||||
for _ in range(3):
|
||||
ws.put({"type": "video.query", "text": "interrupt"})
|
||||
assert await asyncio.wait_for(abort_started.get(), timeout=2.0) == previous_id
|
||||
if delay_abort:
|
||||
assert previous_id not in aborted
|
||||
assert started.empty()
|
||||
allow_abort.set()
|
||||
previous_id = await asyncio.wait_for(started.get(), timeout=2.0)
|
||||
if delay_abort:
|
||||
allow_abort.clear()
|
||||
|
||||
ws.put({"type": "video.done"})
|
||||
await asyncio.wait_for(task, timeout=2.0)
|
||||
|
||||
assert aborted == request_ids[:-1]
|
||||
assert len(set(request_ids)) == 4
|
||||
assert [msg["text"] for msg in ws.sent if msg["type"] == "response.text.done"] == ["final"]
|
||||
assert "error" not in ws.sent_types()
|
||||
assert "session.done" in ws.sent_types()
|
||||
assert not [delay for delay in delays if delay > 0], "Restart must not add a timed grace period after abort"
|
||||
finally:
|
||||
allow_abort.set()
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_video_done_waits_for_in_flight_query():
|
||||
query_started = asyncio.Event()
|
||||
@@ -588,6 +1043,68 @@ async def test_frame_prewarm_does_not_block_following_query(monkeypatch):
|
||||
assert "session.done" in ws.sent_types()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("max_frames", [1, 2], ids=["evicted", "retained"])
|
||||
async def test_frame_prewarm_only_keeps_retained_images(monkeypatch, max_frames):
|
||||
frame_a = _make_jpeg(255, 0, 0)
|
||||
frame_b = _make_jpeg(0, 255, 0)
|
||||
decode_started = asyncio.Event()
|
||||
release_decode = asyncio.Event()
|
||||
frame_b_accepted = asyncio.Event()
|
||||
decoded_images: dict[bytes, weakref.ReferenceType[Image.Image]] = {}
|
||||
prewarm_task: asyncio.Task | None = None
|
||||
original_to_thread = asyncio.to_thread
|
||||
|
||||
async def controlled_to_thread(function, *args, **kwargs):
|
||||
nonlocal prewarm_task
|
||||
if function is video_stream_base._decode_frame_bytes:
|
||||
if args[0] == frame_a:
|
||||
prewarm_task = asyncio.current_task()
|
||||
decode_started.set()
|
||||
await release_decode.wait()
|
||||
image = await original_to_thread(function, *args, **kwargs)
|
||||
decoded_images[args[0]] = weakref.ref(image)
|
||||
return image
|
||||
return await original_to_thread(function, *args, **kwargs)
|
||||
|
||||
class AckWebSocket(TimedWebSocket):
|
||||
async def send_json(self, data):
|
||||
await super().send_json(data)
|
||||
if data.get("type") == "video.frame.ack" and data.get("frame_id") == "B":
|
||||
frame_b_accepted.set()
|
||||
|
||||
monkeypatch.setattr(video_stream_base.asyncio, "to_thread", controlled_to_thread)
|
||||
ws = AckWebSocket()
|
||||
handler = QwenOmniStreamingVideoHandler(chat_service=object(), idle_timeout=5.0)
|
||||
session_task = asyncio.create_task(handler.handle_session(ws))
|
||||
ws.put({"type": "session.config", "max_frames": max_frames, "enable_frame_filter": False})
|
||||
try:
|
||||
ws.put({"type": "video.frame", "frame_id": "A", "data": _b64(frame_a)})
|
||||
await asyncio.wait_for(decode_started.wait(), timeout=5.0)
|
||||
ws.put({"type": "video.frame", "frame_id": "B", "data": _b64(frame_b)})
|
||||
await asyncio.wait_for(frame_b_accepted.wait(), timeout=5.0)
|
||||
ack = next(message for message in ws.sent if message.get("frame_id") == "B")
|
||||
assert ack["accepted"] is True
|
||||
assert ack.get("dropped_frame_id") == ("A" if max_frames == 1 else None)
|
||||
|
||||
# Finish A's real decode only after B has either evicted A or joined it.
|
||||
release_decode.set()
|
||||
assert prewarm_task is not None
|
||||
await asyncio.wait_for(asyncio.shield(prewarm_task), timeout=5.0)
|
||||
gc.collect()
|
||||
assert not session_task.done()
|
||||
# A finished task must not keep an evicted PIL image alive for the session.
|
||||
assert (decoded_images[frame_a]() is not None) == (max_frames == 2)
|
||||
finally:
|
||||
release_decode.set()
|
||||
ws.put({"type": "video.done"})
|
||||
await asyncio.wait_for(session_task, timeout=5.0)
|
||||
|
||||
gc.collect()
|
||||
assert decoded_images[frame_a]() is None
|
||||
assert not any(message.get("type") == "error" for message in ws.sent)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_cannot_send_internal_frame_decode_failed_message():
|
||||
captured_frames: list[list[str]] = []
|
||||
|
||||
@@ -22,6 +22,7 @@ from vllm_omni.entrypoints.openai.tts_adapters import (
|
||||
detect_tts_model_type,
|
||||
resolve_adapter,
|
||||
)
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import resolve_stage_model_path
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.covo_audio import CovoAudioAdapter
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.higgs_audio_v2 import HiggsAudioV2Adapter
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.indextts2 import (
|
||||
@@ -65,10 +66,11 @@ EXPECTED_MODEL_TYPES = {
|
||||
"higgs_audio_v2",
|
||||
"higgs_audio_v3",
|
||||
"glm_tts",
|
||||
"breeze_tts_2",
|
||||
"step_audio2",
|
||||
"indextts2",
|
||||
"indextts2_5",
|
||||
"dots_tts",
|
||||
"gepard",
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +99,31 @@ def test_resolve_unknown_returns_none():
|
||||
assert resolve_adapter(None) is None
|
||||
|
||||
|
||||
def test_stage_model_path_prefers_typed_override():
|
||||
engine_client = SimpleNamespace(
|
||||
stage_configs=[
|
||||
SimpleNamespace(engine_args=SimpleNamespace(model="legacy-stage-model")),
|
||||
SimpleNamespace(
|
||||
model_config=SimpleNamespace(model="typed-stage-model"),
|
||||
),
|
||||
],
|
||||
model="served-model",
|
||||
)
|
||||
|
||||
assert resolve_stage_model_path(engine_client) == "typed-stage-model"
|
||||
|
||||
|
||||
def test_stage_model_path_falls_back_to_legacy_then_served_model():
|
||||
legacy_client = SimpleNamespace(
|
||||
stage_configs=[SimpleNamespace(engine_args=SimpleNamespace(model="legacy-stage-model"))],
|
||||
model="served-model",
|
||||
)
|
||||
served_client = SimpleNamespace(stage_configs=[SimpleNamespace()], model="served-model")
|
||||
|
||||
assert resolve_stage_model_path(legacy_client) == "legacy-stage-model"
|
||||
assert resolve_stage_model_path(served_client) == "served-model"
|
||||
|
||||
|
||||
def test_voxcpm2_resolves():
|
||||
"""VoxCPM2 (the served ``latent_generator`` model) resolves cleanly.
|
||||
|
||||
@@ -142,6 +169,17 @@ def _build_moss_tts_request(adapter_cls, mocker, *, request_seed):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("adapter_cls", "expect_accumulate"),
|
||||
[(MossTTSAdapter, False), (MossTTSNanoAdapter, True)],
|
||||
)
|
||||
def test_moss_tts_accumulate_nonstreaming_follows_adapter_flag(adapter_cls, expect_accumulate, mocker):
|
||||
prepared = _build_moss_tts_request(adapter_cls, mocker, request_seed=7)
|
||||
|
||||
assert adapter_cls.accumulate_nonstreaming is expect_accumulate
|
||||
assert prepared.output_policy.accumulate_nonstreaming is expect_accumulate
|
||||
|
||||
|
||||
# Full-family coverage pins the adapter contract; only Nano consumes this seed end to end today.
|
||||
@pytest.mark.parametrize("adapter_cls", [MossTTSAdapter, MossTTSNanoAdapter])
|
||||
@pytest.mark.parametrize("request_seed", [0, 1234])
|
||||
@@ -216,6 +254,115 @@ def test_moss_reference_transcript_mode(variant, ref_text, mode, mocker):
|
||||
assert request.input == "Target."
|
||||
|
||||
|
||||
def _moss_adapter_with_stage_configs(stage_configs, mocker):
|
||||
engine_client = SimpleNamespace(
|
||||
model_config=SimpleNamespace(model="OpenMOSS-Team/MOSS-TTS-Local-Transformer-v1.5"),
|
||||
stage_configs=stage_configs,
|
||||
)
|
||||
return MossTTSAdapter(SpeechServingContext(server=mocker.Mock(), engine_client=engine_client))
|
||||
|
||||
|
||||
def _moss_cuda_available(mocker, device_count=8):
|
||||
mocker.patch("torch.cuda.is_available", return_value=True)
|
||||
mocker.patch("torch.accelerator.device_count", return_value=device_count)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"stage_configs,expected",
|
||||
[
|
||||
# Follows the code2wav stage's first device.
|
||||
(
|
||||
[
|
||||
SimpleNamespace(model_stage="moss_tts_local", runtime_config=SimpleNamespace(devices="0")),
|
||||
SimpleNamespace(model_stage="moss_tts_local_codec", runtime_config=SimpleNamespace(devices="3")),
|
||||
],
|
||||
"cuda:3",
|
||||
),
|
||||
# No codec-named stage: anchors on the last stage of the pipeline.
|
||||
(
|
||||
[
|
||||
SimpleNamespace(model_stage="talker", runtime_config=SimpleNamespace(devices="1")),
|
||||
SimpleNamespace(model_stage="decoder", runtime_config=SimpleNamespace(devices="2")),
|
||||
],
|
||||
"cuda:2",
|
||||
),
|
||||
# Multi-device codec stage pins replica 0 on the first device.
|
||||
(
|
||||
[
|
||||
SimpleNamespace(model_stage="moss_tts_local_codec", runtime_config=SimpleNamespace(devices="5,6")),
|
||||
],
|
||||
"cuda:5",
|
||||
),
|
||||
# No explicit pinning anywhere: stage worker defaults to cuda:0.
|
||||
(
|
||||
[SimpleNamespace(model_stage="moss_tts_local_codec", runtime_config=SimpleNamespace(devices=None))],
|
||||
"cuda:0",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_moss_ref_encoder_device_follows_codec_stage(stage_configs, expected, mocker):
|
||||
import torch
|
||||
|
||||
_moss_cuda_available(mocker)
|
||||
adapter = _moss_adapter_with_stage_configs(stage_configs, mocker)
|
||||
assert adapter._resolve_ref_encoder_device() == torch.device(expected)
|
||||
|
||||
|
||||
def test_moss_ref_encoder_device_accepts_dict_runtime(mocker):
|
||||
import torch
|
||||
|
||||
_moss_cuda_available(mocker)
|
||||
adapter = _moss_adapter_with_stage_configs(
|
||||
[SimpleNamespace(model_stage="moss_tts_local_codec", runtime={"devices": "2"})],
|
||||
mocker,
|
||||
)
|
||||
assert adapter._resolve_ref_encoder_device() == torch.device("cuda:2")
|
||||
|
||||
|
||||
def test_moss_ref_encoder_device_cpu_when_cuda_unavailable(mocker):
|
||||
import torch
|
||||
|
||||
mocker.patch("torch.cuda.is_available", return_value=False)
|
||||
adapter = _moss_adapter_with_stage_configs(
|
||||
[SimpleNamespace(model_stage="moss_tts_local_codec", runtime_config=SimpleNamespace(devices="3"))],
|
||||
mocker,
|
||||
)
|
||||
assert adapter._resolve_ref_encoder_device() == torch.device("cpu")
|
||||
|
||||
|
||||
def test_moss_ref_encoder_device_cpu_when_index_out_of_range(mocker):
|
||||
import torch
|
||||
|
||||
_moss_cuda_available(mocker, device_count=2)
|
||||
adapter = _moss_adapter_with_stage_configs(
|
||||
[SimpleNamespace(model_stage="moss_tts_local_codec", runtime_config=SimpleNamespace(devices="5"))],
|
||||
mocker,
|
||||
)
|
||||
assert adapter._resolve_ref_encoder_device() == torch.device("cpu")
|
||||
|
||||
|
||||
def test_moss_get_processor_places_audio_tokenizer_on_codec_stage_device(mocker):
|
||||
import torch
|
||||
|
||||
_moss_cuda_available(mocker)
|
||||
adapter = _moss_adapter_with_stage_configs(
|
||||
[SimpleNamespace(model_stage="moss_tts_local_codec", runtime_config=SimpleNamespace(devices="3"))],
|
||||
mocker,
|
||||
)
|
||||
audio_tokenizer = mocker.Mock()
|
||||
audio_tokenizer.to.return_value = audio_tokenizer
|
||||
processor = SimpleNamespace(audio_tokenizer=audio_tokenizer)
|
||||
from_pretrained = mocker.patch("transformers.AutoProcessor.from_pretrained", return_value=processor)
|
||||
|
||||
assert adapter._get_moss_processor() is processor
|
||||
audio_tokenizer.to.assert_called_once_with(torch.device("cuda", 3))
|
||||
audio_tokenizer.eval.assert_called_once_with()
|
||||
# Lazy cache: no re-load / re-placement on the second call.
|
||||
assert adapter._get_moss_processor() is processor
|
||||
from_pretrained.assert_called_once()
|
||||
audio_tokenizer.to.assert_called_once()
|
||||
|
||||
|
||||
def test_qwen3_tts_metadata():
|
||||
assert Qwen3TTSAdapter.backend == "ar"
|
||||
assert issubclass(Qwen3TTSAdapter, ARTTSAdapter)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""TTS model detection: registry-driven stage -> model-type resolution.
|
||||
|
||||
``serving_speech.py`` used to carry a hand-written 20-branch ladder mapping
|
||||
@@ -138,6 +139,7 @@ _PIPELINE_STAGES = [
|
||||
"audio_tokenizer",
|
||||
"audio_vae",
|
||||
"aura",
|
||||
"breeze_tts_2",
|
||||
"code2wav",
|
||||
"cosyvoice3_code2wav",
|
||||
"cosyvoice3_talker",
|
||||
@@ -146,6 +148,7 @@ _PIPELINE_STAGES = [
|
||||
"dit",
|
||||
"fish_speech_slow_ar",
|
||||
"fused_thinker_talker",
|
||||
"gepard",
|
||||
"glm_tts",
|
||||
"glm_tts_dit",
|
||||
"higgs_audio_v2",
|
||||
@@ -184,6 +187,7 @@ _STAGES = [*_PIPELINE_STAGES, None, "vae", "not_a_real_stage"]
|
||||
_ARCHS = [
|
||||
None,
|
||||
"VoxCPM2TalkerForConditionalGeneration",
|
||||
"BreezeForConditionalGeneration",
|
||||
"MingTTSForConditionalGeneration",
|
||||
"CovoAudioForConditionalGeneration",
|
||||
"MyCovoAudioThing",
|
||||
@@ -259,7 +263,6 @@ def test_arch_matching_is_a_fallback_not_an_override():
|
||||
|
||||
def test_shared_latent_generator_resolves_by_architecture_priority():
|
||||
assert detect_tts_model_type("latent_generator", "VoxCPM2TalkerForConditionalGeneration") == "voxcpm2"
|
||||
assert detect_tts_model_type("latent_generator", "DotsTTSForConditionalGeneration") == "dots_tts"
|
||||
|
||||
|
||||
def test_stage_keys_cover_legacy_stage_set():
|
||||
|
||||
@@ -11,6 +11,7 @@ import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import CancelledError as FutureCancelledError
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
@@ -25,6 +26,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 +60,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 +69,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:
|
||||
@@ -81,6 +85,7 @@ class FakeAsyncOmni:
|
||||
self.model_class_name = "WanPipeline"
|
||||
self.captured_prompt = None
|
||||
self.captured_reference_video_bytes = None
|
||||
self.captured_latent_edit_source_bytes = {}
|
||||
self.captured_control_reference_bytes = {}
|
||||
self.captured_sampling_params_list = None
|
||||
|
||||
@@ -101,10 +106,22 @@ class FakeAsyncOmni:
|
||||
and all(isinstance(item, str) for item in reference_videos)
|
||||
):
|
||||
self.captured_reference_video_bytes = [Path(item).read_bytes() for item in reference_videos]
|
||||
for field_name in ("source_video", "source_audio"):
|
||||
source_path = prompt.get("multi_modal_data", {}).get(field_name)
|
||||
if isinstance(source_path, str):
|
||||
self.captured_latent_edit_source_bytes[field_name] = Path(source_path).read_bytes()
|
||||
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()
|
||||
@@ -134,6 +151,60 @@ def test_raw_and_base64_encoders_receive_persistent_converter(mocker: MockerFixt
|
||||
handler.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch_frames", [0, -1, True, 1.5, "17", None])
|
||||
def test_preencode_rejects_invalid_batch_frames_before_generation(batch_frames):
|
||||
engine = FakeAsyncOmni()
|
||||
handler = OmniOpenAIServingVideo.for_diffusion(engine, model_name="test-model")
|
||||
request = VideoGenerationRequest(
|
||||
prompt="test", extra_params={"preencode_mp4": True, "preencode_batch_frames": batch_frames}
|
||||
)
|
||||
try:
|
||||
with pytest.raises(HTTPException, match="preencode_batch_frames") as exc:
|
||||
asyncio.run(handler.generate_video_bytes(request, "invalid-batch"))
|
||||
assert exc.value.status_code == 400
|
||||
assert engine.captured_prompt is None
|
||||
finally:
|
||||
handler.shutdown()
|
||||
|
||||
|
||||
def test_preencoded_video_bytes_preserve_metadata(mocker: MockerFixture):
|
||||
from vllm_omni.entrypoints.openai.serving_video import VideoGenerationArtifacts
|
||||
|
||||
handler = OmniOpenAIServingVideo.for_diffusion(FakeAsyncOmni(), model_name="test-model")
|
||||
# Resolved frame count differs from anything the request asked for, so the
|
||||
# metadata has to come from the encoded stream rather than request defaults.
|
||||
preencoded = _make_test_video_bytes((32, 24), num_frames=7)
|
||||
artifacts = VideoGenerationArtifacts(
|
||||
videos=[preencoded],
|
||||
audios=[None],
|
||||
actions=[None],
|
||||
audio_sample_rate=24000,
|
||||
output_fps=24.0,
|
||||
stage_durations={"decode": 0.5},
|
||||
peak_memory_mb=123.0,
|
||||
metrics={"generation_time": 1.25},
|
||||
)
|
||||
mocker.patch.object(handler, "_run_and_extract", return_value=artifacts)
|
||||
encoder = mocker.patch("vllm_omni.entrypoints.openai.serving_video._encode_video_bytes")
|
||||
try:
|
||||
result = asyncio.run(handler.generate_video_bytes(VideoGenerationRequest(prompt="test"), "preencoded"))
|
||||
assert result == (
|
||||
preencoded,
|
||||
{"decode": 0.5},
|
||||
123.0,
|
||||
None,
|
||||
{
|
||||
"fps": 24.0,
|
||||
"num_frames": 7,
|
||||
"duration_s": 7 / 24.0,
|
||||
"metrics": {"generation_time": 1.25},
|
||||
},
|
||||
)
|
||||
encoder.assert_not_called()
|
||||
finally:
|
||||
handler.shutdown()
|
||||
|
||||
|
||||
def test_resolve_diffusion_od_config_falls_back_to_attribute():
|
||||
od_config = SimpleNamespace(model_class_name="WanPipeline")
|
||||
handler = OmniOpenAIServingVideo.for_diffusion(
|
||||
@@ -159,6 +230,10 @@ def test_resolve_diffusion_od_config_prefers_getter_over_attribute():
|
||||
|
||||
|
||||
class BlockingVideoHandler:
|
||||
supports_mixed_reference_inputs = False
|
||||
supports_latent_mask_editing = False
|
||||
supported_control_upload_types = frozenset()
|
||||
|
||||
def __init__(self):
|
||||
self.model_name = "Wan-AI/Wan2.2-T2V-A14B-Diffusers"
|
||||
self.stage_configs = None
|
||||
@@ -170,9 +245,19 @@ 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,
|
||||
latent_edit_input=None,
|
||||
):
|
||||
del request, reference_id, reference_image, reference_video, reference_audio
|
||||
del request, reference_id, reference_image, reference_video, reference_audio, latent_edit_input
|
||||
if on_started is not None:
|
||||
await on_started()
|
||||
self.started.set()
|
||||
try:
|
||||
await asyncio.Future()
|
||||
@@ -180,6 +265,106 @@ 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,
|
||||
latent_edit_input=None,
|
||||
):
|
||||
del request, reference_image, reference_video, reference_audio, latent_edit_input
|
||||
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,
|
||||
latent_edit_input=None,
|
||||
):
|
||||
del request, reference_id, reference_image, reference_video, reference_audio, latent_edit_input
|
||||
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):
|
||||
@@ -291,6 +476,7 @@ async def test_server_worker_keeps_engine_alive_until_http_shutdown(monkeypatch)
|
||||
@pytest.fixture
|
||||
def test_client():
|
||||
app = FastAPI()
|
||||
app.state.api_server_count = 1
|
||||
app.include_router(router)
|
||||
app.state.openai_serving_video = OmniOpenAIServingVideo.for_diffusion(
|
||||
diffusion_engine=FakeAsyncOmni(),
|
||||
@@ -323,6 +509,11 @@ def _make_test_video_bytes(size=(32, 24), num_frames=3) -> bytes:
|
||||
return mux_video_audio_bytes(frames, fps=8, video_codec_options={"preset": "ultrafast", "threads": "0"})
|
||||
|
||||
|
||||
def _mask_file(value) -> tuple[str, bytes, str]:
|
||||
payload = value if isinstance(value, str) else json.dumps(value)
|
||||
return "mask.json", payload.encode(), "application/json"
|
||||
|
||||
|
||||
def test_mux_video_audio_marks_aac_priming_timestamp():
|
||||
frames = np.zeros((2, 16, 16, 3), dtype=np.uint8)
|
||||
audio = np.zeros((2, 2048), dtype=np.float32)
|
||||
@@ -538,12 +729,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(
|
||||
@@ -571,6 +762,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)
|
||||
@@ -581,6 +773,32 @@ def test_i2v_resize_policy_can_defer_to_pipeline(monkeypatch):
|
||||
}
|
||||
|
||||
|
||||
def test_i2v_minimax_h3_preserves_reference_geometry():
|
||||
engine = FakeAsyncOmni()
|
||||
engine.model_class_name = "MiniMaxH3Pipeline"
|
||||
handler = OmniOpenAIServingVideo.for_diffusion(
|
||||
diffusion_engine=engine,
|
||||
model_name="MiniMaxAI/MiniMax-H3",
|
||||
)
|
||||
image = Image.new("RGB", (48, 32))
|
||||
|
||||
asyncio.run(
|
||||
handler._run_and_extract(
|
||||
VideoGenerationRequest(prompt="A bear playing with yarn.", width=96, height=64),
|
||||
"minimax-h3-reference-geometry",
|
||||
reference_image=ReferenceImage(image),
|
||||
)
|
||||
)
|
||||
|
||||
assert engine.captured_prompt is not None
|
||||
assert engine.captured_sampling_params_list is not None
|
||||
input_image = engine.captured_prompt["multi_modal_data"]["image"]
|
||||
assert isinstance(input_image, Image.Image)
|
||||
assert input_image.size == (48, 32)
|
||||
sampling_params = engine.captured_sampling_params_list[0]
|
||||
assert (sampling_params.width, sampling_params.height) == (96, 64)
|
||||
|
||||
|
||||
def test_i2v_extra_params_dimensions_preserve_input_image_geometry(test_client, mocker: MockerFixture):
|
||||
image_bytes = _make_test_image_bytes((48, 48))
|
||||
mocker.patch(
|
||||
@@ -638,6 +856,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
|
||||
@@ -934,6 +1153,251 @@ def test_mixed_reference_capability_uses_model_metadata_when_config_defaults_fal
|
||||
assert handler.supports_mixed_reference_inputs
|
||||
|
||||
|
||||
def test_typed_stage_drives_video_capability_checks():
|
||||
from vllm_omni.config.config_factory import StageConfigFactory
|
||||
from vllm_omni.diffusion.model_metadata import get_diffusion_model_metadata
|
||||
|
||||
minimax_stage = StageConfigFactory.create_typed_default_diffusion(
|
||||
"minimax-h3",
|
||||
{"model_class_name": "MiniMaxH3Pipeline"},
|
||||
).stage_configs[0]
|
||||
minimax_handler = OmniOpenAIServingVideo.for_diffusion(
|
||||
SimpleNamespace(od_config=SimpleNamespace(model_class_name=None)),
|
||||
model_name="minimax-h3",
|
||||
stage_configs=[minimax_stage],
|
||||
)
|
||||
assert minimax_handler.supports_mixed_reference_inputs
|
||||
assert minimax_handler.supports_latent_mask_editing
|
||||
assert get_diffusion_model_metadata("MiniMaxH3ModularPipeline").supports_latent_mask_editing
|
||||
|
||||
cosmos_stage = StageConfigFactory.create_typed_default_diffusion(
|
||||
"cosmos3",
|
||||
{"model_class_name": "Cosmos3OmniDiffusersPipeline"},
|
||||
).stage_configs[0]
|
||||
cosmos_handler = OmniOpenAIServingVideo.for_diffusion(
|
||||
SimpleNamespace(od_config=SimpleNamespace(model_class_name=None)),
|
||||
model_name="cosmos3",
|
||||
stage_configs=[cosmos_stage],
|
||||
)
|
||||
assert cosmos_handler.supported_control_upload_types == frozenset({"edge", "blur", "depth", "seg", "wsm"})
|
||||
assert not cosmos_handler.supports_latent_mask_editing
|
||||
|
||||
|
||||
@pytest.mark.parametrize("endpoint", ["/v1/videos", "/v1/videos/sync"])
|
||||
def test_h3_latent_edit_uploads_reach_generation_and_are_cleaned(endpoint, test_client, mocker: MockerFixture):
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.serving_video._encode_video_bytes",
|
||||
return_value=b"fake-video",
|
||||
)
|
||||
engine = test_client.app.state.openai_serving_video._engine_client
|
||||
engine.model_class_name = "MiniMaxH3Pipeline"
|
||||
video_mask = [[[0.0, 1.0], [0.5, 1.0]]]
|
||||
audio_mask = [0.0, 0.25, 1.0]
|
||||
|
||||
response = test_client.post(
|
||||
endpoint,
|
||||
data={"prompt": "Replace only the masked regions."},
|
||||
files=[
|
||||
("source_video", ("source.mov", b"source-video", "video/quicktime")),
|
||||
("source_audio", ("source.mp3", b"source-audio", "audio/mpeg")),
|
||||
("video_noise_mask", _mask_file(video_mask)),
|
||||
("audio_noise_mask", _mask_file(audio_mask)),
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
if not endpoint.endswith("/sync"):
|
||||
_wait_for_status(test_client, response.json()["id"], VideoGenerationStatus.COMPLETED.value)
|
||||
|
||||
multi_modal_data = engine.captured_prompt["multi_modal_data"]
|
||||
assert engine.captured_latent_edit_source_bytes == {
|
||||
"source_video": b"source-video",
|
||||
"source_audio": b"source-audio",
|
||||
}
|
||||
assert multi_modal_data["video_noise_mask"] == video_mask
|
||||
assert multi_modal_data["audio_noise_mask"] == audio_mask
|
||||
_wait_until(
|
||||
lambda: (
|
||||
not Path(multi_modal_data["source_video"]).exists() and not Path(multi_modal_data["source_audio"]).exists()
|
||||
)
|
||||
)
|
||||
assert not Path(multi_modal_data["source_video"]).exists()
|
||||
assert not Path(multi_modal_data["source_audio"]).exists()
|
||||
|
||||
|
||||
def test_latent_edit_fields_are_rejected_for_models_without_capability(test_client):
|
||||
response = test_client.post(
|
||||
"/v1/videos/sync",
|
||||
data={"prompt": "edit"},
|
||||
files=[
|
||||
("source_video", ("source.mp4", b"video", "video/mp4")),
|
||||
("video_noise_mask", _mask_file("1")),
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "not supported" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("files", "message"),
|
||||
[
|
||||
(
|
||||
[("source_video", ("source.mp4", b"video", "video/mp4"))],
|
||||
"at least one",
|
||||
),
|
||||
(
|
||||
[
|
||||
("source_audio", ("source.wav", b"audio", "audio/wav")),
|
||||
("video_noise_mask", _mask_file("0")),
|
||||
],
|
||||
"source_audio requires an audio_noise_mask",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_h3_latent_edit_cross_field_validation(test_client, files, message):
|
||||
test_client.app.state.openai_serving_video._engine_client.model_class_name = "MiniMaxH3Pipeline"
|
||||
|
||||
response = test_client.post("/v1/videos/sync", data={"prompt": "edit"}, files=files)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert message in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field_name", "filename", "content_type", "message"),
|
||||
[
|
||||
("source_video", "source.webm", "video/webm", "MP4 or MOV"),
|
||||
("source_audio", "source.flac", "audio/flac", "WAV or MP3"),
|
||||
],
|
||||
)
|
||||
def test_h3_latent_edit_rejects_unsupported_source_formats(
|
||||
test_client,
|
||||
field_name,
|
||||
filename,
|
||||
content_type,
|
||||
message,
|
||||
):
|
||||
test_client.app.state.openai_serving_video._engine_client.model_class_name = "MiniMaxH3Pipeline"
|
||||
mask_name = "video_noise_mask" if field_name == "source_video" else "audio_noise_mask"
|
||||
|
||||
response = test_client.post(
|
||||
"/v1/videos/sync",
|
||||
data={"prompt": "edit"},
|
||||
files=[
|
||||
(field_name, (filename, b"source", content_type)),
|
||||
(mask_name, _mask_file("0")),
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert message in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mask", "message"),
|
||||
[
|
||||
("[not-json", "Invalid JSON"),
|
||||
("[" * 2000 + "0" + "]" * 2000, "nested too deeply"),
|
||||
("[" * 65 + "0" + "]" * 65, "nested too deeply"),
|
||||
],
|
||||
)
|
||||
def test_h3_latent_edit_rejects_malformed_mask_json(test_client, mask, message):
|
||||
test_client.app.state.openai_serving_video._engine_client.model_class_name = "MiniMaxH3Pipeline"
|
||||
|
||||
response = test_client.post(
|
||||
"/v1/videos/sync",
|
||||
data={"prompt": "edit"},
|
||||
files=[
|
||||
("source_video", ("source.mp4", b"video", "video/mp4")),
|
||||
("video_noise_mask", _mask_file(mask)),
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert message in response.json()["detail"]
|
||||
|
||||
|
||||
def test_h3_latent_edit_rejects_mask_text_form_fields(test_client):
|
||||
test_client.app.state.openai_serving_video._engine_client.model_class_name = "MiniMaxH3Pipeline"
|
||||
|
||||
response = test_client.post(
|
||||
"/v1/videos/sync",
|
||||
data={"prompt": "edit", "video_noise_mask": "0"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_h3_latent_edit_masks_without_sources_are_forwarded_to_stage0(test_client, mocker: MockerFixture):
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.serving_video._encode_video_bytes",
|
||||
return_value=b"fake-video",
|
||||
)
|
||||
engine = test_client.app.state.openai_serving_video._engine_client
|
||||
engine.model_class_name = "MiniMaxH3Pipeline"
|
||||
|
||||
response = test_client.post(
|
||||
"/v1/videos/sync",
|
||||
data={"prompt": "ordinary generation"},
|
||||
files=[
|
||||
("video_noise_mask", _mask_file("1")),
|
||||
("audio_noise_mask", _mask_file("[1, 1]")),
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert engine.captured_prompt["multi_modal_data"]["video_noise_mask"] == 1
|
||||
assert engine.captured_prompt["multi_modal_data"]["audio_noise_mask"] == [1, 1]
|
||||
|
||||
|
||||
def test_h3_latent_edit_rejects_oversized_mask_json(test_client, monkeypatch):
|
||||
test_client.app.state.openai_serving_video._engine_client.model_class_name = "MiniMaxH3Pipeline"
|
||||
monkeypatch.setattr(video_generation_helpers, "LATENT_EDIT_MASK_FILE_MAX_BYTES", 4)
|
||||
|
||||
response = test_client.post(
|
||||
"/v1/videos/sync",
|
||||
data={"prompt": "edit"},
|
||||
files=[
|
||||
("source_video", ("source.mp4", b"video", "video/mp4")),
|
||||
("video_noise_mask", _mask_file("[0.0]")),
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "JSON exceeds" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_cancelled_latent_edit_persistence_cleans_prior_source(test_client, monkeypatch):
|
||||
test_client.app.state.openai_serving_video._engine_client.model_class_name = "MiniMaxH3Pipeline"
|
||||
persist = video_generation_helpers._persist_latent_edit_source
|
||||
persisted_paths: list[str] = []
|
||||
|
||||
async def persist_then_cancel(upload, *, field_name):
|
||||
if field_name == "source_audio":
|
||||
raise asyncio.CancelledError
|
||||
path = await persist(upload, field_name=field_name)
|
||||
persisted_paths.append(path)
|
||||
return path
|
||||
|
||||
monkeypatch.setattr(video_generation_helpers, "_persist_latent_edit_source", persist_then_cancel)
|
||||
|
||||
with pytest.raises(FutureCancelledError):
|
||||
test_client.post(
|
||||
"/v1/videos/sync",
|
||||
data={"prompt": "edit"},
|
||||
files=[
|
||||
("source_video", ("source.mp4", b"source-video", "video/mp4")),
|
||||
("source_audio", ("source.wav", b"source-audio", "audio/wav")),
|
||||
("video_noise_mask", _mask_file("0")),
|
||||
("audio_noise_mask", _mask_file("0")),
|
||||
],
|
||||
)
|
||||
|
||||
assert len(persisted_paths) == 1
|
||||
assert not Path(persisted_paths[0]).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_class_name", ["Cosmos3OmniDiffusersPipeline", "Cosmos3OmniPipeline"])
|
||||
def test_control_upload_capability_is_declared_only_by_cosmos3(test_client, model_class_name):
|
||||
handler = test_client.app.state.openai_serving_video
|
||||
@@ -991,6 +1455,35 @@ def test_cosmos3_reference_video_limit_uses_v2v_condition_frames():
|
||||
assert spec.keep == "first"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("typed", [False, True], ids=["legacy", "typed"])
|
||||
@pytest.mark.parametrize(
|
||||
("num_frames", "extra_params", "expected"),
|
||||
[
|
||||
(189, {"condition_frame_indexes_vision": [0, 2], "condition_video_keep": "last"}, (9, "last")),
|
||||
(189, {"condition_frame_indexes_vision": [0, 2]}, (9, "first")),
|
||||
(5, {"condition_frame_indexes_vision": [0, 20]}, (5, "first")),
|
||||
(None, {"action_mode": "inverse_dynamics", "action_chunk_size": 16}, (17, "first")),
|
||||
],
|
||||
)
|
||||
def test_cosmos3_reference_video_decode_policy_with_runtime_configs(typed, num_frames, extra_params, expected):
|
||||
from vllm_omni.config.config_factory import StageConfigFactory
|
||||
|
||||
if typed:
|
||||
stages = list(
|
||||
StageConfigFactory.create_typed_default_diffusion(
|
||||
"cosmos3",
|
||||
{"model_class_name": "Cosmos3OmniDiffusersPipeline"},
|
||||
).stage_configs
|
||||
)
|
||||
else:
|
||||
stages = _cosmos3_stage_configs()
|
||||
request = VideoGenerationRequest(prompt="Continue this motion.", num_frames=num_frames, extra_params=extra_params)
|
||||
|
||||
spec = _reference_video_decode_spec(request, stages)
|
||||
|
||||
assert (spec.max_frames, spec.keep) == expected
|
||||
|
||||
|
||||
def test_cosmos3_reference_video_limit_preserves_action_frames():
|
||||
request = VideoGenerationRequest(
|
||||
prompt="Predict the action.",
|
||||
@@ -1423,7 +1916,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",
|
||||
@@ -1549,6 +2042,7 @@ def test_action_extraction_accepts_multimodal_actions_payload():
|
||||
|
||||
def test_missing_handler_returns_503():
|
||||
app = FastAPI()
|
||||
app.state.api_server_count = 1
|
||||
app.include_router(router)
|
||||
app.state.openai_serving_video = None
|
||||
client = TestClient(app)
|
||||
@@ -1773,6 +2267,33 @@ def test_failed_generation_awaits_storage_cleanup(test_client, isolated_video_ba
|
||||
delete_spy.assert_called_once_with(video_id)
|
||||
|
||||
|
||||
def test_failed_async_latent_edit_cleans_source_upload(test_client, mocker: MockerFixture):
|
||||
handler = test_client.app.state.openai_serving_video
|
||||
handler._engine_client.model_class_name = "MiniMaxH3Pipeline"
|
||||
captured_path: list[str] = []
|
||||
|
||||
async def fail_generation(*_args, latent_edit_input=None, **_kwargs):
|
||||
assert latent_edit_input is not None
|
||||
captured_path.append(latent_edit_input.source_video)
|
||||
assert Path(latent_edit_input.source_video).read_bytes() == b"source-video"
|
||||
raise RuntimeError("edit failed")
|
||||
|
||||
mocker.patch.object(handler, "generate_video_bytes", side_effect=fail_generation)
|
||||
response = test_client.post(
|
||||
"/v1/videos",
|
||||
data={"prompt": "will fail"},
|
||||
files=[
|
||||
("source_video", ("source.mp4", b"source-video", "video/mp4")),
|
||||
("video_noise_mask", _mask_file("0")),
|
||||
],
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
_wait_for_status(test_client, response.json()["id"], VideoGenerationStatus.FAILED.value)
|
||||
assert len(captured_path) == 1
|
||||
_wait_until(lambda: not Path(captured_path[0]).exists())
|
||||
|
||||
|
||||
def test_async_guardrail_error_returns_400_on_retrieve(test_client, mocker: MockerFixture):
|
||||
mocker.patch.object(
|
||||
OmniOpenAIServingVideo,
|
||||
@@ -2016,6 +2537,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"
|
||||
@@ -2461,6 +3056,7 @@ def test_cosmos3_control_upload_rejects_invalid_size(control_bytes, message, tes
|
||||
|
||||
def test_sync_missing_handler_returns_503():
|
||||
app = FastAPI()
|
||||
app.state.api_server_count = 1
|
||||
app.include_router(router)
|
||||
app.state.openai_serving_video = None
|
||||
client = TestClient(app)
|
||||
|
||||
Reference in new issue
Block a user