Merge main and preserve multi-API engine and duplex guards

Signed-off-by: Sy03 <1370724210@qq.com>
This commit is contained in:
Sy03 committed 2026-09-16 16:40:16 +08:00
commit daf91b4d7a
329 files changed
+36428 -35780

No files matched your search

@@ -682,6 +682,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.
@@ -1015,7 +1057,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())
@@ -0,0 +1,414 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Guards for the duplex-only API server (unified full-duplex framework).
A model whose pipeline declares ``duplex_plugin`` is served through
``DuplexOmni`` and nothing else: sessions run over ``/v1/realtime?duplex=1``
(alias ``/v1/duplex``) and every turn-based route reports "not available".
These tests lock that shape, which ``test_api_server_guards.py`` covers for the
diffusion and multi-stage servers:
* the pipeline probe that decides duplex vs turn-based at startup,
* the ``app.state`` snapshot of a duplex server (what is wired, what stays
``None``),
* both duplex websocket routes reaching the session handler, and
``/v1/realtime`` without the flag still reporting the turn-based Realtime API
as unavailable,
* the startup-warmup gate that holds real clients while the warmup session runs.
CPU-only: no engine is started; ``DuplexOmni`` is subclassed with just the
accessors the app-state wiring reads.
"""
from __future__ import annotations
import asyncio
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.config_factory import StageConfigFactory
from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig
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
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_model_probe_follows_the_pipeline_plugin(monkeypatch, pipeline_config, expected: bool) -> None:
"""``duplex_plugin`` alone decides whether ``serve`` builds ``DuplexOmni``."""
seen: dict[str, object] = {}
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": "deploy.yaml"}
assert api_server._is_duplex_model("demo-duplex-model", kwargs) is expected
assert seen == {
"model": "demo-duplex-model",
"trust_remote_code": True,
"deploy_config_path": "deploy.yaml",
}
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._is_duplex_model("demo-duplex-model", {})
# --------------------------------------------------------------------------- #
# 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)
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")
@@ -32,7 +32,7 @@ from tests.helpers.media import (
from tests.helpers.runtime import OmniServerParams
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.vad import (
SILERO_VAD_FILENAME,
SILERO_VAD_REPO_ID,
SILERO_VAD_REVISION,
@@ -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()
@@ -5134,6 +5134,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],
@@ -5289,6 +5290,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],
@@ -46,6 +46,7 @@ def _serving(metrics: _MetricsStub, *, adapter=None) -> 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",
@@ -375,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 = []
@@ -69,6 +69,7 @@ EXPECTED_MODEL_TYPES = {
"step_audio2",
"indextts2",
"indextts2_5",
"gepard",
"dots_tts",
}
@@ -143,6 +144,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])
@@ -148,6 +148,7 @@ _PIPELINE_STAGES = [
"dit",
"fish_speech_slow_ar",
"fused_thinker_talker",
"gepard",
"glm_tts",
"glm_tts_dit",
"higgs_audio_v2",
@@ -25,6 +25,7 @@ from PIL import Image
from pytest_mock import MockerFixture
from vllm import envs
from vllm_omni.diffusion.data import DIFFUSION_REQUEST_LIFECYCLE_KEY, DIFFUSION_REQUEST_STARTED
from vllm_omni.diffusion.utils.media_utils import mux_video_audio_bytes
from vllm_omni.entrypoints.openai import api_server, video_api_utils
from vllm_omni.entrypoints.openai.api_server import router
@@ -58,6 +59,7 @@ class MockVideoResult:
multimodal_output=None,
stage_durations=None,
peak_memory_mb=0.0,
custom_output=None,
):
self.multimodal_output = dict(multimodal_output or {"video": videos})
if audios is not None:
@@ -66,6 +68,7 @@ class MockVideoResult:
self.multimodal_output["audio_sample_rate"] = sample_rate
self.stage_durations = stage_durations or {}
self.peak_memory_mb = peak_memory_mb
self.custom_output = custom_output or {}
class FakeAsyncOmni:
@@ -102,9 +105,17 @@ class FakeAsyncOmni:
):
self.captured_reference_video_bytes = [Path(item).read_bytes() for item in reference_videos]
num_outputs = sampling_params_list[0].num_outputs_per_prompt
if sampling_params_list[0].emit_request_lifecycle:
yield MockVideoResult(
[],
custom_output={DIFFUSION_REQUEST_LIFECYCLE_KEY: DIFFUSION_REQUEST_STARTED},
)
videos = [object() for _ in range(num_outputs)]
yield MockVideoResult(videos)
async def abort(self, request_id, *, timeout=None):
del request_id, timeout
def test_raw_and_base64_encoders_receive_persistent_converter(mocker: MockerFixture):
engine = FakeAsyncOmni()
@@ -224,9 +235,18 @@ class BlockingVideoHandler:
self.stage_configs = stage_configs
async def generate_video_bytes(
self, request, reference_id, *, reference_image=None, reference_video=None, reference_audio=None
self,
request,
reference_id,
*,
reference_image=None,
reference_video=None,
reference_audio=None,
on_started=None,
):
del request, reference_id, reference_image, reference_video, reference_audio
if on_started is not None:
await on_started()
self.started.set()
try:
await asyncio.Future()
@@ -234,6 +254,104 @@ class BlockingVideoHandler:
self.cancelled.set()
raise
async def abort_request(self, request_id):
del request_id
class HangingAbortHandler(BlockingVideoHandler):
"""Engine abort never returns; DELETE must time out and still cancel."""
async def abort_request(self, request_id):
del request_id
await asyncio.sleep(30)
class CompletingDuringAbortHandler(BlockingVideoHandler):
"""Finishes and persists output while DELETE is still awaiting abort."""
def __init__(self):
super().__init__()
self._finish = asyncio.Event()
async def generate_video_bytes(
self,
request,
reference_id,
*,
reference_image=None,
reference_video=None,
reference_audio=None,
on_started=None,
):
del request, reference_image, reference_video, reference_audio
if on_started is not None:
await on_started()
self.started.set()
await self._finish.wait()
return b"late-complete", {}, 0.0, None
async def abort_request(self, request_id):
self._finish.set()
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
job = await api_server.VIDEO_STORE.get(request_id)
if job is not None and job.status is VideoGenerationStatus.COMPLETED and job.file_name is not None:
return
await asyncio.sleep(0.01)
raise RuntimeError(f"video job {request_id} did not complete during abort")
class SchedulerQueuedVideoHandler(BlockingVideoHandler):
"""Parks after engine submission but before scheduler admission."""
def __init__(self):
super().__init__()
self.admit = threading.Event()
self.in_progress = threading.Event()
async def generate_video_bytes(
self,
request,
reference_id,
*,
reference_image=None,
reference_video=None,
reference_audio=None,
on_started=None,
):
del request, reference_id, reference_image, reference_video, reference_audio
self.started.set()
try:
while not self.admit.is_set():
await asyncio.sleep(0.01)
if on_started is not None:
await on_started()
self.in_progress.set()
await asyncio.Future()
except asyncio.CancelledError:
self.cancelled.set()
raise
class AbortTrackingOmni(FakeAsyncOmni):
def __init__(self):
super().__init__()
self.aborted: list[str] = []
self.entered = threading.Event()
async def generate(self, prompt, request_id, sampling_params_list):
del prompt, request_id, sampling_params_list
self.entered.set()
yield MockVideoResult(
[],
custom_output={DIFFUSION_REQUEST_LIFECYCLE_KEY: DIFFUSION_REQUEST_STARTED},
)
await asyncio.Future()
async def abort(self, request_id, *, timeout=None):
assert timeout is not None
self.aborted.append(request_id)
class FakeServerSocket:
def __init__(self):
@@ -593,12 +711,12 @@ def test_i2v_video_generation_resizes_input_to_requested_dimensions(test_client,
def test_i2v_resize_policy_can_defer_to_pipeline(monkeypatch):
engine = FakeAsyncOmni()
engine.get_diffusion_od_config = lambda: SimpleNamespace(
engine.get_diffusion_od_config = lambda: SimpleNamespace( # type: ignore[method-assign]
model="org/model",
model_class_name="ExamplePipeline",
revision="pinned-revision",
)
captured = {}
captured: dict[str, str | None] = {}
def fake_policy(model_class_name, *, model, revision=None):
captured.update(
@@ -626,6 +744,7 @@ def test_i2v_resize_policy_can_defer_to_pipeline(monkeypatch):
)
)
assert engine.captured_prompt is not None
input_image = engine.captured_prompt["multi_modal_data"]["image"]
assert isinstance(input_image, Image.Image)
assert input_image.size == (48, 32)
@@ -719,6 +838,7 @@ def test_video_generation_bridges_request_fields(generation_request, expected_nu
asyncio.run(handler._run_and_extract(generation_request, "field-bridge"))
assert engine.captured_sampling_params_list is not None
sampling = engine.captured_sampling_params_list[0]
# Top-level ``seconds`` bridges into extra_args["duration"]; num_frames is
# passed through (or derived as seconds x fps when omitted). No private
@@ -1504,7 +1624,7 @@ def test_video_generation_response_exposes_action_payload(mocker: MockerFixture)
},
)
engine.generate = _generate
engine.generate = _generate # type: ignore[method-assign]
mocker.patch(
"vllm_omni.entrypoints.openai.serving_video.encode_video_base64",
return_value="encoded-video",
@@ -2098,6 +2218,80 @@ def test_delete_in_progress_job_cancels_task_and_removes_metadata(test_client):
assert retrieve_resp.status_code == 404
def test_async_video_stays_queued_until_scheduler_admission(test_client):
handler = SchedulerQueuedVideoHandler()
test_client.app.state.openai_serving_video = handler
create_resp = test_client.post("/v1/videos", data={"prompt": "Queue this video"})
assert create_resp.status_code == 200
video_id = create_resp.json()["id"]
assert handler.started.wait(timeout=2.0)
queued = test_client.get(f"/v1/videos/{video_id}")
assert queued.status_code == 200
assert queued.json()["status"] == VideoGenerationStatus.QUEUED.value
handler.admit.set()
assert handler.in_progress.wait(timeout=2.0)
in_progress = test_client.get(f"/v1/videos/{video_id}")
assert in_progress.status_code == 200
assert in_progress.json()["status"] == VideoGenerationStatus.IN_PROGRESS.value
assert test_client.delete(f"/v1/videos/{video_id}").status_code == 200
def test_delete_times_out_engine_abort_and_still_cancels(test_client, monkeypatch):
monkeypatch.setattr(api_server, "VIDEO_ABORT_TIMEOUT_S", 0.05)
handler = HangingAbortHandler()
test_client.app.state.openai_serving_video = handler
create_resp = test_client.post("/v1/videos", data={"prompt": "Hang abort"})
assert create_resp.status_code == 200
video_id = create_resp.json()["id"]
assert handler.started.wait(timeout=2.0)
started = time.monotonic()
delete_resp = test_client.delete(f"/v1/videos/{video_id}")
assert time.monotonic() - started < 2.0
assert delete_resp.status_code == 200
assert handler.cancelled.wait(timeout=2.0)
def test_delete_removes_artifact_if_job_completes_during_abort(test_client):
handler = CompletingDuringAbortHandler()
test_client.app.state.openai_serving_video = handler
create_resp = test_client.post("/v1/videos", data={"prompt": "Complete during delete"})
assert create_resp.status_code == 200
video_id = create_resp.json()["id"]
assert handler.started.wait(timeout=2.0)
assert test_client.delete(f"/v1/videos/{video_id}").status_code == 200
file_path = os.path.join(api_server.STORAGE_MANAGER.storage_path, video_id)
assert not os.path.exists(file_path)
assert asyncio.run(api_server.VIDEO_STORE.get(video_id)) is None
def test_delete_aborts_engine_request_before_cancelling_task(test_client):
engine = AbortTrackingOmni()
test_client.app.state.openai_serving_video = OmniOpenAIServingVideo.for_diffusion(
engine,
model_name="Wan-AI/Wan2.2-T2V-A14B-Diffusers",
)
create_resp = test_client.post("/v1/videos", data={"prompt": "Abort this video"})
assert create_resp.status_code == 200
video_id = create_resp.json()["id"]
assert engine.entered.wait(timeout=2.0)
_wait_for_status(test_client, video_id, VideoGenerationStatus.IN_PROGRESS.value)
delete_resp = test_client.delete(f"/v1/videos/{video_id}")
assert delete_resp.status_code == 200
assert delete_resp.json()["deleted"] is True
_wait_until(lambda: engine.aborted == [video_id])
assert asyncio.run(api_server.VIDEO_STORE.get(video_id)) is None
def test_video_response_file_extension_is_robust():
response = VideoResponse(model="test-model", prompt="Make something beautiful")
assert response.file_extension == "mp4"