[Core][Frontend] Unified Full-duplex Framework (#7413)

Signed-off-by: chickeyton <ngton2014@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
chickeytonandClaude Opus 5 authored and GitHub committed 2026-09-16 15:35:36 +08:00
1 parent 9005d78903
commit 99ff4f3070
182 files changed
+26951 -34743

No files matched your search

@@ -1,54 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import base64
import numpy as np
import pytest
from vllm_omni.entrypoints.duplex.audio import convert_input_audio_with_rate
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_pcm16_input_conversion_is_pure_and_reports_target_rate():
pcm16 = np.array([-32768, 0, 32767], dtype="<i2").tobytes()
encoded = base64.b64encode(pcm16).decode("ascii")
converted, fmt, sample_rate = convert_input_audio_with_rate(
encoded,
"pcm16",
sample_rate_hz=16_000,
)
assert encoded == base64.b64encode(pcm16).decode("ascii")
assert fmt == "pcm_f32le"
assert sample_rate == 16_000
samples = np.frombuffer(base64.b64decode(converted), dtype="<f4")
assert np.allclose(samples, [-1.0, 0.0, 32767 / 32768])
@pytest.mark.parametrize("sample_rate_hz", [8_000, 192_000])
def test_pcm16_input_conversion_accepts_supported_sample_rate_boundaries(sample_rate_hz: int):
encoded = base64.b64encode(np.zeros(8, dtype="<i2").tobytes()).decode("ascii")
_, fmt, converted_rate = convert_input_audio_with_rate(
encoded,
"pcm16",
sample_rate_hz=sample_rate_hz,
)
assert fmt == "pcm_f32le"
assert converted_rate == 16_000
@pytest.mark.parametrize("sample_rate_hz", [7_999, 192_001, 10**1000])
def test_pcm16_input_conversion_rejects_sample_rates_outside_supported_range(sample_rate_hz: int):
encoded = base64.b64encode(np.zeros(8, dtype="<i2").tobytes()).decode("ascii")
with pytest.raises(ValueError, match="sample_rate_hz"):
convert_input_audio_with_rate(
encoded,
"pcm16",
sample_rate_hz=sample_rate_hz,
)
@@ -0,0 +1,609 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""The thin websocket handler: handshake, command translation, event pump, resume/takeover."""
from __future__ import annotations
import asyncio
import base64
import json
from contextlib import suppress
from typing import Any
import pytest
from fastapi import WebSocketDisconnect
from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig
from vllm_omni.engine.duplex import commands
from vllm_omni.engine.duplex.config import DuplexCapabilities
from vllm_omni.engine.duplex.events import AudioDelta, DuplexEvent, SessionClosed, SessionCreated
from vllm_omni.engine.duplex.messages import DuplexSessionError
from vllm_omni.entrypoints.duplex.realtime_input import RealtimeEnvelope, parse_resume_request
from vllm_omni.entrypoints.duplex.serving import OmniDuplexSessionHandler
from vllm_omni.entrypoints.duplex.websocket import MAX_EVENT_BYTES
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
_DISCONNECT = object()
class FakeWebSocket:
def __init__(self, query: dict[str, str] | None = None) -> None:
self.query_params = dict(query or {})
self.sent: list[dict[str, Any]] = []
self.accepted = False
self.closed: list[tuple[int, str]] = []
self._send_failure: str | None = None
self._inbound: asyncio.Queue[Any] = asyncio.Queue()
async def accept(self) -> None:
self.accepted = True
async def send_json(self, payload: dict[str, Any]) -> None:
if self.closed or self._send_failure is not None:
raise RuntimeError(
self._send_failure or "Unexpected ASGI message 'websocket.send', after sending 'websocket.close'."
)
self.sent.append(json.loads(json.dumps(payload)))
async def receive_text(self) -> str:
item = await self._inbound.get()
if item is _DISCONNECT:
raise WebSocketDisconnect(code=1000)
return item
async def close(self, code: int = 1000, reason: str = "") -> None:
self.closed.append((code, reason))
# A closed socket ends the reader exactly like a client disconnect.
self._inbound.put_nowait(_DISCONNECT)
# ---- test helpers ----
def feed(self, payload: dict[str, Any] | str) -> None:
self._inbound.put_nowait(payload if isinstance(payload, str) else json.dumps(payload))
def disconnect(self) -> None:
self._inbound.put_nowait(_DISCONNECT)
def break_sends(self) -> None:
"""Kill the write half only: the reader stays parked, as it does in practice."""
self._send_failure = "Unexpected ASGI message 'websocket.send', after sending 'websocket.close'."
def types(self) -> list[str]:
return [payload["type"] for payload in self.sent]
async def wait_for(self, wire_type: str, *, timeout_s: float = 2.0) -> dict[str, Any]:
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout_s
while loop.time() < deadline:
for payload in self.sent:
if payload["type"] == wire_type:
return payload
await asyncio.sleep(0.005)
raise AssertionError(f"no {wire_type!r} in {self.types()}")
class FakeHandle:
def __init__(self, session_id: str, capabilities: DuplexCapabilities, *, idle_timeout_s: float = 300) -> None:
self.session_id = session_id
self.capabilities = capabilities
self.public_session: dict[str, Any] = {"id": session_id, "idle_timeout_s": idle_timeout_s}
self.lease_generation = 0
self.closed = False
self.close_reasons: list[str] = []
self.commands: list[commands.DuplexCommand] = []
self._outbox: asyncio.Queue[DuplexEvent] = asyncio.Queue()
def deliver(self, event: DuplexEvent) -> None:
self._outbox.put_nowait(event)
async def submit(self, command: commands.DuplexCommand) -> None:
if self.closed:
raise DuplexSessionError("closed", code="session_closed", session_id=self.session_id)
self.commands.append(command)
async def events(self):
while True:
event = await self._outbox.get()
yield event
if event.is_terminal:
return
async def close(self, *, reason: str = "client_close", timeout: float | None = None) -> None:
self.close_reasons.append(reason)
if self.closed:
return
self.closed = True
self.deliver(SessionClosed(session_id=self.session_id, reason=reason))
class FakeOmni:
def __init__(
self, *, resumable: bool = True, replay_max_bytes: int = 64 * 1024, idle_timeout_s: float = 300
) -> None:
self.duplex_session_config = DuplexSessionRuntimeConfig(
resume_replay_ttl_s=60.0, resume_replay_max_bytes_per_session=replay_max_bytes
)
self.capabilities = DuplexCapabilities(supports_session_resume=resumable)
self.idle_timeout_s = idle_timeout_s
self.opened: list[dict[str, Any]] = []
self.handles: dict[str, FakeHandle] = {}
self.resumed: list[tuple[str, int]] = []
self.detached: list[str] = []
self.open_error: DuplexSessionError | None = None
async def open_session(self, config: Any) -> FakeHandle:
self.opened.append(dict(config))
if self.open_error is not None:
raise self.open_error
session_id = f"duplex-{len(self.handles) + 1:032x}"
handle = FakeHandle(session_id, self.capabilities, idle_timeout_s=self.idle_timeout_s)
self.handles[session_id] = handle
handle.deliver(SessionCreated(session_id=session_id, session={"id": session_id, "model": config.get("model")}))
return handle
def get_session(self, session_id: str) -> FakeHandle | None:
return self.handles.get(session_id)
async def resume_session(self, session_id: str, *, expected_lease_generation: int) -> FakeHandle:
self.resumed.append((session_id, expected_lease_generation))
handle = self.handles[session_id]
handle.lease_generation = expected_lease_generation + 1
return handle
async def detach_session(self, session_id: str) -> None:
self.detached.append(session_id)
def _handler(omni: FakeOmni, **kwargs: Any) -> OmniDuplexSessionHandler:
kwargs.setdefault("config_timeout_s", 1.0)
kwargs.setdefault("idle_timeout_s", 5.0)
return OmniDuplexSessionHandler(duplex_omni=omni, **kwargs)
def _session_update(**session: Any) -> dict[str, Any]:
return {"type": "session.update", "session": {"model": "test-model", "modalities": ["audio", "text"], **session}}
async def _open(
handler: OmniDuplexSessionHandler, omni: FakeOmni, **session: Any
) -> tuple[FakeWebSocket, FakeHandle, asyncio.Task]:
ws = FakeWebSocket({"duplex": "1", "autostart": "0"})
task = asyncio.create_task(handler.handle_realtime_session(ws))
ws.feed(_session_update(**session))
created = await ws.wait_for("session.created")
handle = omni.handles[created["session"]["id"]]
return ws, handle, task
# --------------------------------------------------------------------------- #
# Handshake and commands #
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_session_update_opens_a_session_and_announces_the_server_id() -> None:
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni, session_id="mine", instructions="hi")
assert ws.accepted
created = ws.sent[0]
assert created["type"] == "session.created"
assert created["session"]["id"] == handle.session_id != "mine"
assert created["attachment_generation"] == 1
assert isinstance(created["resume_token"], str) and created["resume_token"]
assert "incarnation" not in created
assert "server_event_seq" not in created
# The whole session object went to open_session; the id inside it is ignored there.
assert omni.opened == [
{"model": "test-model", "modalities": ["audio", "text"], "session_id": "mine", "instructions": "hi"}
]
ws.feed({"type": "input_audio_buffer.append", "audio": base64.b64encode(b"\x00\x01" * 8).decode("ascii")})
ws.feed({"type": "input_audio_buffer.commit", "event_id": "evt-c"})
await asyncio.sleep(0.05)
assert [type(command) for command in handle.commands] == [commands.AppendAudio, commands.Commit]
assert handle.commands[1].event_id == "evt-c"
ws.disconnect()
await asyncio.wait_for(task, timeout=2.0)
# A resumable session is detached (engine-owned grace), not closed.
assert omni.detached == [handle.session_id]
assert handle.close_reasons == []
@pytest.mark.asyncio
async def test_session_events_are_journaled_and_sent_in_order() -> None:
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
handle.deliver(AudioDelta(session_id=handle.session_id, response_id="r1", delta="aGk="))
delta = await ws.wait_for("response.output_audio.delta")
assert delta["delta"] == "aGk=" and delta["server_event_seq"] == 1
ws.feed({"type": "session.event_ack", "server_event_seq": 1})
ws.feed({"type": "session.event_ack", "server_event_seq": 9, "event_id": "evt-ack"})
error = await ws.wait_for("error")
assert error["error"]["code"] == "invalid_event_ack" and error["error"]["event_id"] == "evt-ack"
await handle.close(reason="client_close")
closed = await ws.wait_for("session.closed")
assert closed["reason"] == "client_close"
await asyncio.wait_for(task, timeout=2.0)
assert ws.closed[0][1] == "client_close"
@pytest.mark.asyncio
async def test_envelope_errors_are_answered_locally_without_touching_the_session() -> None:
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
ws.feed("not json")
ws.feed("x" * (MAX_EVENT_BYTES + 1))
ws.feed({"type": "totally.unknown", "event_id": "evt-u"})
ws.feed({"type": "session.resume", "event_id": "evt-r"})
ws.feed({"type": "playback.ack", "event_id": "evt-p"}) # played_ms missing
await asyncio.sleep(0.1)
codes = [payload["error"]["code"] for payload in ws.sent if payload["type"] == "error"]
assert codes == ["invalid_json", "event_too_large", "unknown_event", "unsupported_session_resume", "bad_event"]
errors = [payload for payload in ws.sent if payload["type"] == "error"]
assert errors[2]["error"]["event_id"] == "evt-u"
assert errors[4]["error"]["event_id"] == "evt-p"
assert handle.commands == []
ws.disconnect()
await asyncio.wait_for(task, timeout=2.0)
@pytest.mark.asyncio
async def test_open_rejection_is_reported_and_the_socket_ends() -> None:
omni = FakeOmni()
omni.open_error = DuplexSessionError("no room", code="resource_exhausted", retryable=True)
handler = _handler(omni)
ws = FakeWebSocket({"duplex": "1"})
task = asyncio.create_task(handler.handle_realtime_session(ws))
ws.feed(_session_update())
await asyncio.wait_for(task, timeout=2.0)
assert ws.types() == ["error"]
assert ws.sent[0]["error"]["code"] == "resource_exhausted"
@pytest.mark.asyncio
async def test_config_timeout_and_first_message_validation() -> None:
omni = FakeOmni()
handler = _handler(omni, config_timeout_s=0.05)
ws = FakeWebSocket({"duplex": "1"})
await asyncio.wait_for(handler.handle_realtime_session(ws), timeout=2.0)
assert ws.sent[0]["error"]["code"] == "config_timeout"
ws = FakeWebSocket({"duplex": "1"})
task = asyncio.create_task(handler.handle_realtime_session(ws))
ws.feed("{not json")
await asyncio.wait_for(task, timeout=2.0)
assert ws.sent[0]["error"]["code"] == "invalid_json"
assert omni.opened == []
@pytest.mark.asyncio
async def test_idle_timeout_detaches_a_resumable_session_like_a_disconnect() -> None:
omni = FakeOmni(idle_timeout_s=0.05)
handler = _handler(omni, idle_timeout_s=0.05)
ws, handle, task = await _open(handler, omni)
await asyncio.wait_for(task, timeout=2.0)
# Serving makes no session-lifetime decision: the engine lease decides
# whether a silent, detached session expires.
assert handle.close_reasons == []
assert omni.detached == [handle.session_id]
@pytest.mark.asyncio
async def test_idle_timeout_closes_a_non_resumable_session() -> None:
omni = FakeOmni(idle_timeout_s=0.05, resumable=False)
handler = _handler(omni, idle_timeout_s=0.05)
ws, handle, task = await _open(handler, omni)
await asyncio.wait_for(task, timeout=2.0)
assert handle.close_reasons == ["disconnect"]
assert omni.detached == []
@pytest.mark.asyncio
async def test_transport_send_failure_detaches_the_session_instead_of_closing_it() -> None:
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
# Only the write half dies, so the pump's send is the one and only report
# of the broken socket (the reader is still parked on receive).
ws.break_sends()
handle.deliver(AudioDelta(session_id=handle.session_id, response_id="r1", delta="aGk="))
await asyncio.sleep(0.05)
# The engine session is alive and resumable, so this is a disconnect of
# the current attachment, not a session close; the pump keeps journaling.
assert handle.close_reasons == []
assert omni.detached == [handle.session_id]
assert not handler._pumps[handle.session_id].done()
# The reader sees the same broken socket a moment later. The attachment is
# already gone, so this must not detach a second time: another detach would
# restart the engine's disconnect grace window.
ws.disconnect()
await asyncio.wait_for(task, timeout=2.0)
assert omni.detached == [handle.session_id]
assert handle.close_reasons == []
handle.deliver(SessionClosed(session_id=handle.session_id, reason="client_close"))
await asyncio.sleep(0.05)
assert handle.session_id not in handler._pumps
@pytest.mark.asyncio
async def test_non_resumable_session_is_closed_on_disconnect() -> None:
omni = FakeOmni(resumable=False)
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
assert "resume_token" not in ws.sent[0]
ws.disconnect()
await asyncio.wait_for(task, timeout=2.0)
assert handle.close_reasons == ["disconnect"]
assert omni.detached == []
@pytest.mark.asyncio
async def test_a_client_close_still_delivers_session_closed_before_the_socket_goes() -> None:
"""The read loop must not outrun the pump's terminal event.
``DuplexSessionHandle._deliver`` queues ``SessionClosed`` and marks the
handle closed in one synchronous step, so ``handle.closed`` is already true
while the event is still sitting in the outbox. ``_read_loop`` loops on
exactly that flag: it returned, the endpoint returned, and the ASGI server
tore the socket down with ``session.closed`` unsent -- the client saw an
abrupt close (no close frame) instead of the terminal event it was waiting
for.
The teardown is what makes this observable, so it is modelled here: a real
connection stops accepting writes the moment the endpoint returns, which is
why a pump that is merely *still scheduled* is already too late.
"""
omni = FakeOmni()
handler = _handler(omni)
ws = FakeWebSocket({"duplex": "1", "autostart": "0"})
async def serve() -> None:
try:
await handler.handle_realtime_session(ws)
finally:
# The ASGI server drops the connection when the endpoint returns;
# anything the pump writes after this never reaches the client.
ws.break_sends()
task = asyncio.create_task(serve())
ws.feed(_session_update())
created = await ws.wait_for("session.created")
handle = omni.handles[created["session"]["id"]]
submit = handle.submit
async def closing_submit(command: commands.DuplexCommand) -> None:
await submit(command)
if isinstance(command, commands.CloseSession):
# Same order as the real handle: queue the event, then flip the flag.
handle.deliver(SessionClosed(session_id=handle.session_id, reason="client_close"))
handle.closed = True
handle.submit = closing_submit # type: ignore[method-assign]
ws.feed({"type": "session.close"})
await asyncio.wait_for(task, timeout=2.0)
assert ws.types()[-1] == "session.closed", f"terminal event never reached the wire: {ws.types()}"
assert ws.closed and ws.closed[0][0] == 1000, "the socket must close with a normal close frame"
# --------------------------------------------------------------------------- #
# Resume and takeover #
# --------------------------------------------------------------------------- #
async def _resume(
handler: OmniDuplexSessionHandler,
session_id: str,
token: str,
*,
last_seq: int = 0,
) -> tuple[FakeWebSocket, asyncio.Task]:
ws = FakeWebSocket({"duplex": "1", "resume": "1"})
task = asyncio.create_task(handler.handle_realtime_session(ws))
ws.feed(
{
"type": "session.resume",
"session_id": session_id,
"resume_token": token,
"last_received_server_event_seq": last_seq,
}
)
return ws, task
@pytest.mark.asyncio
async def test_resume_after_disconnect_replays_missed_events_and_rotates_the_token() -> None:
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
token = ws.sent[0]["resume_token"]
handle.deliver(AudioDelta(session_id=handle.session_id, response_id="r1", delta="one"))
await ws.wait_for("response.output_audio.delta")
ws.disconnect()
await asyncio.wait_for(task, timeout=2.0)
assert omni.detached == [handle.session_id]
# Emitted while detached: journaled for replay only.
handle.deliver(AudioDelta(session_id=handle.session_id, response_id="r1", delta="two"))
await asyncio.sleep(0.05)
ws2, task2 = await _resume(handler, handle.session_id, token, last_seq=1)
resumed = await ws2.wait_for("session.resumed")
assert resumed["session_id"] == handle.session_id
assert resumed["attachment_generation"] == 2
assert resumed["resume_token"] != token
assert "incarnation" not in resumed
replayed = await ws2.wait_for("response.output_audio.delta")
assert replayed["delta"] == "two" and replayed["server_event_seq"] == 2
assert omni.resumed == [(handle.session_id, 0)]
# The old token is revoked by the rotation.
ws3, task3 = await _resume(handler, handle.session_id, token)
await asyncio.wait_for(task3, timeout=2.0)
assert ws3.sent[0]["error"]["code"] == "invalid_resume_token"
ws2.feed({"type": "session.close"})
await asyncio.sleep(0.05)
ws2.disconnect()
await asyncio.wait_for(task2, timeout=2.0)
@pytest.mark.asyncio
async def test_resume_takes_over_a_live_attachment() -> None:
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
token = ws.sent[0]["resume_token"]
ws2, task2 = await _resume(handler, handle.session_id, token)
await ws2.wait_for("session.resumed")
replaced = await ws.wait_for("session.replaced")
assert replaced["attachment_generation"] == 1
assert ws.closed and ws.closed[0][1] == "session_replaced"
# The replaced socket's later input is ignored; the new one drives the session.
ws.feed({"type": "input_audio_buffer.clear"})
ws2.feed({"type": "input_audio_buffer.clear", "event_id": "evt-new"})
await asyncio.sleep(0.05)
assert [command.event_id for command in handle.commands] == ["evt-new"]
await asyncio.wait_for(task, timeout=2.0)
ws2.disconnect()
await asyncio.wait_for(task2, timeout=2.0)
assert omni.detached == [handle.session_id]
@pytest.mark.asyncio
async def test_resume_validation_errors() -> None:
omni = FakeOmni()
handler = _handler(omni)
ws = FakeWebSocket({"duplex": "1", "resume": "1"})
task = asyncio.create_task(handler.handle_realtime_session(ws))
ws.feed({"type": "session.resume", "session_id": "duplex-x"})
await asyncio.wait_for(task, timeout=2.0)
assert ws.sent[0]["error"]["code"] == "invalid_session_resume"
ws, task = await _resume(handler, "duplex-unknown", "token")
await asyncio.wait_for(task, timeout=2.0)
assert ws.sent[0]["error"]["code"] == "session_resume_expired"
@pytest.mark.asyncio
async def test_journal_overflow_degrades_to_live_delivery_with_resync_required() -> None:
omni = FakeOmni(replay_max_bytes=256)
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
handle.deliver(AudioDelta(session_id=handle.session_id, response_id="r1", delta="x" * 400))
resync = await ws.wait_for("session.resync_required")
assert resync["reason"] == "journal_overflow"
delta = await ws.wait_for("response.output_audio.delta")
assert "server_event_seq" not in delta
assert ws.types().index("session.resync_required") < ws.types().index("response.output_audio.delta")
ws.disconnect()
await asyncio.wait_for(task, timeout=2.0)
# --------------------------------------------------------------------------- #
# Envelope helpers #
# --------------------------------------------------------------------------- #
def test_realtime_envelope_query_rules() -> None:
envelope = RealtimeEnvelope.from_query_params({"model": "m"})
assert envelope.initial_open_payload() == {"model": "m"}
assert envelope.initial_open_payload() is None # autostart happens once
envelope = RealtimeEnvelope.from_query_params({"model": "m", "autostart": "0"})
assert envelope.resume_only is True
assert envelope.initial_open_payload() is None
assert RealtimeEnvelope.from_query_params({"resume": "1"}).resume_only is True
assert RealtimeEnvelope.from_query_params({"session_id": "mine"}).default_session_payload() == {"model": None}
def test_realtime_envelope_first_message_classification() -> None:
envelope = RealtimeEnvelope.from_query_params({"model": "m", "autostart": "0"})
resume = envelope.first_message({"type": "session.resume", "session_id": "s"})
assert resume.kind == "resume" and resume.resume_payload["session_id"] == "s"
envelope = RealtimeEnvelope.from_query_params({"model": "m", "autostart": "0"})
opened = envelope.first_message({"type": "session.update", "session": {"model": "x", "instructions": "hi"}})
assert opened.kind == "open" and opened.session_payload == {"model": "x", "instructions": "hi"}
assert opened.pending_command_payload is None
envelope = RealtimeEnvelope.from_query_params({"model": "m", "autostart": "0"})
autostarted = envelope.first_message({"type": "input_audio_buffer.commit"})
assert autostarted.kind == "open" and autostarted.session_payload == {"model": "m"}
assert autostarted.pending_command_payload == {"type": "input_audio_buffer.commit"}
def test_parse_resume_request_requires_the_three_fields_only() -> None:
request = parse_resume_request({"session_id": "s", "resume_token": "t", "last_received_server_event_seq": 3})
assert request is not None
assert (request.session_id, request.resume_token, request.last_received_server_event_seq) == ("s", "t", 3)
assert parse_resume_request({"session_id": "s", "resume_token": "t"}).last_received_server_event_seq == 0
assert parse_resume_request({"session_id": "s", "resume_token": "t", "incarnation": 1}) is not None
assert parse_resume_request({"session_id": "s"}) is None
assert parse_resume_request({"session_id": "s", "resume_token": "t", "last_received_server_event_seq": -1}) is None
@pytest.mark.asyncio
async def test_a_resume_that_fails_to_activate_does_not_detach_the_live_attachment() -> None:
"""A rejected resume must roll back only what it owns.
Two reconnects can both authenticate and both complete the engine resume;
only one activates. The loser's activation raises, and the rollback used to
detach the *session*, starting the engine's disconnect grace for the winner
-- a grace ordinary heartbeats do not clear.
The activation failure is injected rather than raced for: what is under test
is the rollback's precondition, not the window that produces it.
"""
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
token = ws.sent[0]["resume_token"]
registry = handler._attachment_registry
try:
assert await registry.has_attachment(handle.session_id), "the opener is attached"
detached_before = list(omni.detached)
async def failing_resume(*args, **kwargs):
raise RuntimeError("transport activation lost the race")
original_resume = registry.resume
registry.resume = failing_resume # type: ignore[method-assign]
try:
ws_lose, task_lose = await _resume(handler, handle.session_id, token)
error = await ws_lose.wait_for("error")
finally:
registry.resume = original_resume # type: ignore[method-assign]
assert error["error"]["code"] == "session_resume_conflict"
assert omni.detached == detached_before, "the loser must not detach the winner"
assert await registry.has_attachment(handle.session_id), "the winner is still attached"
finally:
for pending in (task, locals().get("task_lose")):
if pending is not None:
pending.cancel()
with suppress(asyncio.CancelledError, Exception):
await pending
@@ -1,145 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
from vllm_omni.entrypoints.duplex.runtime_adapter import (
validate_serving_runtime_adapter,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@pytest.mark.parametrize(
"module_name",
[
"vllm_omni.entrypoints.duplex.session_runner",
"vllm_omni.entrypoints.duplex.runtime_bridge",
"vllm_omni.entrypoints.duplex.serving",
],
)
def test_generic_openai_runtime_import_does_not_load_model_adapters(module_name: str) -> None:
repo_root = Path(__file__).resolve().parents[3]
script = f"""
import sys
import {module_name}
model_duplex_prefixes = (
"vllm_omni.model_executor.models.minicpmo_4_5.duplex",
"vllm_omni.model_executor.models.nemotron_voicechat.duplex",
"vllm_omni.model_executor.models.personaplex.duplex",
)
loaded = sorted(
name
for name in sys.modules
if any(
name == prefix or name.startswith(prefix + ".")
for prefix in model_duplex_prefixes
)
)
if loaded:
raise SystemExit("generic OpenAI runtime loaded model duplex modules: " + ", ".join(loaded))
"""
result = subprocess.run(
[sys.executable, "-c", script],
cwd=repo_root,
capture_output=True,
text=True,
timeout=60,
check=False,
)
assert result.returncode == 0, result.stderr
def test_generic_handler_supports_adapterless_chat_fallback() -> None:
repo_root = Path(__file__).resolve().parents[3]
script = """
from types import SimpleNamespace
from vllm_omni.entrypoints.duplex.serving import OmniDuplexSessionHandler
chat_service = SimpleNamespace(
engine_client=SimpleNamespace(),
model_config=SimpleNamespace(model="test-model"),
)
handler = OmniDuplexSessionHandler(chat_service=chat_service)
if handler._serving_runtime_adapter is not None:
raise SystemExit("generic handler silently selected a model serving adapter")
"""
result = subprocess.run(
[sys.executable, "-c", script],
cwd=repo_root,
capture_output=True,
text=True,
timeout=60,
check=False,
)
assert result.returncode == 0, result.stderr
def _valid_runtime_adapter() -> SimpleNamespace:
data_plane = SimpleNamespace(
begin_request=lambda request_id: None,
is_terminal=lambda request_id: False,
mark_terminal=lambda request_id: None,
close_stream=lambda request_id: None,
close_session=lambda session_id, **kwargs: None,
project=lambda result, **kwargs: (),
)
return SimpleNamespace(
adapter_id="test-adapter",
session_states={},
data_plane=data_plane,
clean_response_done_prefix="clean",
interrupted_tts_prefix="interrupted",
private_runtime_config_keys=frozenset({"private_key"}),
create_session_state=lambda: object(),
session_state=lambda session_id: object(),
remove_session_state=lambda session_id: None,
is_enabled=lambda config: True,
capabilities=lambda **kwargs: object(),
validate_client_extra_body=lambda extra_body: None,
prepare_runtime_config=lambda config, **kwargs: {},
runtime_config_for_update=lambda config, current: {},
data_plane_context=lambda **kwargs: object(),
)
@pytest.mark.parametrize(
("attribute", "invalid_value"),
[
("session_states", None),
("clean_response_done_prefix", None),
("interrupted_tts_prefix", None),
("private_runtime_config_keys", {"private_key"}),
],
)
def test_runtime_adapter_validator_rejects_invalid_protocol_attributes(
attribute: str,
invalid_value: object,
) -> None:
adapter = _valid_runtime_adapter()
setattr(adapter, attribute, invalid_value)
with pytest.raises(TypeError, match=attribute):
validate_serving_runtime_adapter(adapter)
def test_runtime_adapter_validator_rejects_incomplete_data_plane() -> None:
adapter = _valid_runtime_adapter()
adapter.data_plane.close_session = None
with pytest.raises(TypeError, match="data_plane.*close_session"):
validate_serving_runtime_adapter(adapter)
@@ -1,115 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Regression tests for duplex ``runtime_control`` payload redaction.
The redacted result is emitted on the WebSocket (for example
``session.created.runtime_control``), so it must always survive
``json.dumps``. Control results carry typed values straight from the engine --
``fence`` and ``accepted_fence`` are ``DuplexFence`` instances added by
``DuplexControlClient.execute`` -- which previously reached
``WebSocket.send_json`` unconverted and killed the session at handshake.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from enum import Enum
import pytest
from vllm_omni.engine.duplex.messages import DuplexFence
from vllm_omni.entrypoints.duplex.runtime_bridge import NativeRuntimeBridgeMixin
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
_redact = NativeRuntimeBridgeMixin._redact_runtime_control_result
def _control_result() -> dict[str, object]:
"""The shape ``DuplexControlClient.execute`` returns for an open."""
return {
"ok": True,
"operation": "open",
"session_id": "sess-1",
"fence": DuplexFence("sess-1", epoch=0, turn_id=0, response_seq=0, incarnation=0),
"accepted_fence": DuplexFence("sess-1", epoch=1, turn_id=2, response_seq=3, incarnation=4),
"stage_results": [{"stage_id": -1, "replica_id": -1, "result": {"supported": True}}],
}
def test_redacted_control_result_is_json_serializable() -> None:
"""The whole payload must serialize the way send_json does."""
redacted = _redact(_control_result())
# Must not raise.
json.dumps({"type": "session.created", "runtime_control": redacted})
def test_fences_are_converted_to_plain_dicts() -> None:
redacted = _redact(_control_result())
assert redacted["fence"] == {
"session_id": "sess-1",
"epoch": 0,
"turn_id": 0,
"response_seq": 0,
"incarnation": 0,
}
# Field values are preserved, not just the type.
assert redacted["accepted_fence"]["epoch"] == 1
assert redacted["accepted_fence"]["turn_id"] == 2
assert redacted["accepted_fence"]["response_seq"] == 3
assert redacted["accepted_fence"]["incarnation"] == 4
def test_nested_dataclasses_are_converted() -> None:
"""Fences can appear inside stage results, not only at the top level."""
redacted = _redact(
{
"stage_results": [
{"stage_id": 0, "result": {"fence": DuplexFence("sess-2", epoch=7)}},
]
}
)
json.dumps(redacted)
assert redacted["stage_results"][0]["result"]["fence"]["epoch"] == 7
def test_enums_are_reduced_to_values() -> None:
class _Mode(str, Enum):
APPEND = "append_audio_chunk"
redacted = _redact({"mode": _Mode.APPEND})
json.dumps(redacted)
assert redacted["mode"] == "append_audio_chunk"
def test_redaction_still_drops_heavy_payload_keys() -> None:
"""The pre-existing contract must be preserved."""
redacted = _redact(
{
"ok": True,
"stage_handoff": object(),
"tts_handoff": object(),
"omni_payload": object(),
"tts_hidden_states": object(),
"tts_token_ids": object(),
"traceback": "long trace",
}
)
assert redacted == {"ok": True}
def test_plain_values_pass_through_unchanged() -> None:
payload = {"ok": True, "count": 3, "name": "x", "missing": None, "items": [1, "two"]}
assert _redact(payload) == payload
def test_dataclass_types_are_not_treated_as_instances() -> None:
"""A class object is not a dataclass instance and must not be asdict'd."""
@dataclass
class _Shape:
x: int = 1
assert _redact(_Shape) is _Shape
@@ -1,145 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import asyncio
import pytest
from vllm_omni.entrypoints.duplex.websocket import (
DuplexWebSocketActor,
normalize_duplex_input_event,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class FakeWebSocket:
def __init__(self) -> None:
self.sent: list[dict[str, object]] = []
async def send_json(self, payload: dict[str, object]) -> None:
self.sent.append(dict(payload))
@pytest.mark.parametrize(
("event", "expected"),
[
({"type": "signal_turn", "event": "barge_in"}, {"type": "turn.signal", "event": "barge_in"}),
({"type": "close_session"}, {"type": "session.close"}),
({"type": "audio.playback_ack", "played_ms": 1}, {"type": "playback.ack", "played_ms": 1}),
({"type": "input_text.append", "text": "a"}, {"type": "input.text.append", "text": "a"}),
({"type": "push_text", "text": "b"}, {"type": "input.text.append", "text": "b"}),
(
{"type": "input.audio.append", "audio": "wav"},
{"type": "input_audio_buffer.append", "audio": "wav", "format": "wav"},
),
(
{"type": "push_chunk", "audio": "wav", "format": "pcm_f32le"},
{"type": "input_audio_buffer.append", "audio": "wav", "format": "pcm_f32le"},
),
],
)
def test_input_aliases_normalize_once_at_mailbox_boundary(event, expected):
assert normalize_duplex_input_event(event) == expected
def test_actor_uses_one_fifo_mailbox_for_inbound_events():
actor = DuplexWebSocketActor(FakeWebSocket())
assert isinstance(actor.mailbox, asyncio.Queue)
assert not isinstance(actor.mailbox, asyncio.PriorityQueue)
assert not hasattr(actor, "control_queue")
assert not hasattr(actor, "input_queue")
assert not hasattr(actor, "event_queue")
assert not hasattr(actor, "lifecycle_state")
assert not hasattr(actor, "last_response_id")
assert not hasattr(actor, "overlap_speech_ms")
assert not hasattr(actor, "transition")
assert not hasattr(actor, "output_generation_in_flight")
assert not hasattr(actor, "session")
assert not hasattr(actor, "runtime_opened")
assert not hasattr(actor, "runtime_closed")
assert not hasattr(actor, "drain_input_queue")
assert not hasattr(actor, "control_events_seen")
assert not hasattr(actor, "input_events_seen")
assert not hasattr(actor, "cancel_count")
@pytest.mark.asyncio
async def test_control_event_does_not_overtake_earlier_audio_input():
actor = DuplexWebSocketActor(FakeWebSocket())
await actor.enqueue_event({"type": "input_audio_buffer.append", "audio": "pcm"})
await actor.enqueue_event({"type": "response.cancel"})
assert await actor.next_event() == {"type": "input_audio_buffer.append", "audio": "pcm"}
assert await actor.next_event() == {"type": "response.cancel"}
@pytest.mark.asyncio
@pytest.mark.parametrize("event_type", ["input.cancel", "session.close", "close_session"])
async def test_terminal_control_preserves_wire_order(event_type: str):
actor = DuplexWebSocketActor(FakeWebSocket())
await actor.enqueue_event({"type": "input_audio_buffer.append", "audio": "pcm"})
await actor.enqueue_event({"type": event_type})
assert await actor.next_event() == {"type": "input_audio_buffer.append", "audio": "pcm"}
assert await actor.next_event() == {"type": event_type}
@pytest.mark.asyncio
async def test_mailbox_delivers_every_enqueued_event_exactly_once():
actor = DuplexWebSocketActor(FakeWebSocket())
events = [
{"type": "input_audio_buffer.append", "audio": str(index)}
if index % 2
else {"type": "response.created", "response_id": f"resp-{index}"}
for index in range(20)
]
await asyncio.gather(*(actor.enqueue_event(dict(event)) for event in events))
received = [await actor.next_event() for _ in events]
assert sorted(received, key=repr) == sorted(events, key=repr)
@pytest.mark.asyncio
async def test_writer_is_single_owner_of_websocket_send():
websocket = FakeWebSocket()
actor = DuplexWebSocketActor(websocket)
writer = asyncio.create_task(actor.writer_loop())
await actor.send_json({"type": "one"})
await actor.send_json({"type": "two"})
await actor.close_writer()
await writer
assert websocket.sent == [{"type": "one"}, {"type": "two"}]
@pytest.mark.asyncio
async def test_stale_fence_payload_is_dropped_before_websocket_send():
websocket = FakeWebSocket()
actor = DuplexWebSocketActor(websocket, current_epoch=lambda: 2)
writer = asyncio.create_task(actor.writer_loop())
await actor.send_json({"type": "response.output_audio.delta", "epoch": 1})
await actor.send_json({"type": "response.output_audio.delta", "epoch": 2})
await actor.close_writer()
await writer
assert websocket.sent == [{"type": "response.output_audio.delta", "epoch": 2}]
assert actor.stale_output_dropped == 1
@pytest.mark.asyncio
async def test_writer_does_not_revoke_accepted_terminal_after_close_starts():
websocket = FakeWebSocket()
actor = DuplexWebSocketActor(websocket, current_epoch=lambda: 1)
await actor.send_json({"type": "response.done", "epoch": 1, "response_id": "resp-1"})
actor.closing = True
await actor.close_writer()
await actor.writer_loop()
assert websocket.sent == [{"type": "response.done", "epoch": 1, "response_id": "resp-1"}]
assert actor.stale_output_dropped == 0
@@ -1,601 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import asyncio
import json
import pytest
from vllm_omni.entrypoints.duplex.protocol import (
DuplexOverlapPolicy,
DuplexSession,
DuplexSessionConfig,
DuplexSessionRegistry,
DuplexTurnController,
DuplexTurnEventType,
DuplexTurnState,
ResponseCreateOptions,
)
from vllm_omni.entrypoints.duplex.realtime_session import (
NativeRealtimeSessionProtocol,
)
from vllm_omni.entrypoints.duplex.realtime_state import RealtimeStateOwner
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.capabilities import (
minicpmo45_native_capabilities,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_realtime_state_owner_uses_explicit_bindings_not_dynamic_attribute_proxy():
assert "__getattr__" not in RealtimeStateOwner.__dict__
assert "__setattr__" not in RealtimeStateOwner.__dict__
class _ProtocolWebSocket:
def __init__(self, *events: dict[str, object]) -> None:
self._events = asyncio.Queue()
for event in events:
self._events.put_nowait(json.dumps(event))
async def receive_text(self) -> str:
return await self._events.get()
def test_duplex_session_commits_text_and_audio_as_one_turn():
registry = DuplexSessionRegistry()
session = registry.create(DuplexSessionConfig(model="test-model"))
session.append_text("hello")
session.append_audio("YWJj", fmt="wav", sample_rate_hz=16000)
committed = session.commit_user_input()
assert committed is not None
assert committed.turn_id == 0
assert committed.input_commit_seq == 1
assert committed.epoch == 0
assert len(session.history) == 1
content = session.history[0]["content"]
assert isinstance(content, list)
assert content[0] == {"type": "text", "text": "hello"}
assert content[1]["type"] == "audio_url"
assert content[1]["audio_url"]["url"] == "data:audio/wav;base64,YWJj"
assert session.turn_state == DuplexTurnState.USER_COMMITTED
def test_response_options_apply_to_one_response_without_mutating_session_defaults():
session = DuplexSession(
session_id="sid-response-options",
config=DuplexSessionConfig(instructions="base", voice="base-voice", max_tokens=64),
)
session.reserve_response_options(
ResponseCreateOptions(
instructions="one response",
voice="override-voice",
max_tokens=8,
)
)
assert session.config.instructions == "base"
assert session.config.voice == "base-voice"
session.begin_response()
assert session.config.instructions == "base"
assert session.config.voice == "base-voice"
assert session.response_config.instructions == "one response"
assert session.response_config.voice == "override-voice"
assert session.response_config.max_tokens == 8
session.end_response()
assert session.config.instructions == "base"
assert session.config.voice == "base-voice"
assert session.config.max_tokens == 64
def test_response_options_cannot_overwrite_an_unconsumed_reservation():
session = DuplexSession(
session_id="sid-response-options-pending",
config=DuplexSessionConfig(instructions="base"),
)
session.reserve_response_options(ResponseCreateOptions(instructions="first"))
with pytest.raises(RuntimeError, match="already reserved"):
session.reserve_response_options(ResponseCreateOptions(instructions="second"))
session.begin_response()
assert session.response_config.instructions == "first"
def test_response_options_cannot_be_reserved_while_response_is_active():
session = DuplexSession(
session_id="sid-response-options-active",
config=DuplexSessionConfig(instructions="base"),
)
session.begin_response()
with pytest.raises(RuntimeError, match="active"):
session.reserve_response_options(ResponseCreateOptions(instructions="too late"))
def test_realtime_model_name_does_not_implicitly_enable_native_duplex():
protocol = NativeRealtimeSessionProtocol({})
event = protocol._session_create_from_realtime({"model": "openbmb/MiniCPM-o-4_5"})
assert "native_duplex" not in event["session"]["extra_body"]
assert "minicpmo45_native_duplex" not in event["session"]["extra_body"]
def test_realtime_explicit_native_duplex_flag_is_preserved():
protocol = NativeRealtimeSessionProtocol({})
event = protocol._session_create_from_realtime(
{
"model": "openbmb/MiniCPM-o-4_5",
"extra_body": {"native_duplex": True},
}
)
assert event["session"]["extra_body"]["native_duplex"] is True
def test_realtime_legacy_native_duplex_alias_is_normalized():
# The deprecated model-prefixed spelling still opts in, but everything
# downstream (and every session echo) sees only the canonical key.
protocol = NativeRealtimeSessionProtocol({})
event = protocol._session_create_from_realtime(
{
"model": "openbmb/MiniCPM-o-4_5",
"extra_body": {"minicpmo45_native_duplex": True},
}
)
assert event["session"]["extra_body"]["native_duplex"] is True
assert "minicpmo45_native_duplex" not in event["session"]["extra_body"]
def test_realtime_explicit_query_native_duplex_flag_is_available_before_autostart():
protocol = NativeRealtimeSessionProtocol(
{
"model": "openbmb/MiniCPM-o-4_5",
"native_duplex": "1",
}
)
event = json.loads(asyncio.run(protocol.receive_internal_event_text(None)))
assert event["type"] == "session.create"
assert event["session"]["extra_body"]["native_duplex"] is True
def test_realtime_legacy_query_native_duplex_alias_seeds_canonical_key():
protocol = NativeRealtimeSessionProtocol(
{
"model": "openbmb/MiniCPM-o-4_5",
"minicpmo45_native_duplex": "1",
}
)
event = json.loads(asyncio.run(protocol.receive_internal_event_text(None)))
assert event["type"] == "session.create"
assert event["session"]["extra_body"]["native_duplex"] is True
assert "minicpmo45_native_duplex" not in event["session"]["extra_body"]
@pytest.mark.asyncio
async def test_realtime_resume_heartbeat_and_event_ack_translate_without_session_autostart():
protocol = NativeRealtimeSessionProtocol(
{
"model": "openbmb/MiniCPM-o-4_5",
"native_duplex": "1",
}
)
resume = await protocol._to_duplex_event(
{
"type": "session.resume",
"session_id": "sid-resume",
"incarnation": 3,
"resume_token": "opaque-token",
"last_received_server_event_seq": 17,
}
)
heartbeat = await protocol._to_duplex_event(
{
"type": "session.heartbeat",
"event_id": "event-heartbeat",
}
)
event_ack = await protocol._to_duplex_event(
{
"type": "session.event_ack",
"server_event_seq": 19,
}
)
assert resume == {
"type": "session.resume",
"session_id": "sid-resume",
"incarnation": 3,
"resume_token": "opaque-token",
"last_received_server_event_seq": 17,
}
assert heartbeat == {
"type": "session.heartbeat",
"event_id": "event-heartbeat",
}
assert event_ack == {
"type": "session.event_ack",
"server_event_seq": 19,
}
assert protocol._opened is False
wire_protocol = NativeRealtimeSessionProtocol({})
first_wire_event = json.loads(
await wire_protocol.receive_internal_event_text(
_ProtocolWebSocket(
{
"type": "session.resume",
"session_id": "sid-resume",
"incarnation": 3,
"resume_token": "opaque-token",
"last_received_server_event_seq": 17,
}
)
)
)
assert first_wire_event["type"] == "session.resume"
@pytest.mark.asyncio
async def test_realtime_resume_query_suppresses_model_autostart_on_same_url():
protocol = NativeRealtimeSessionProtocol(
{
"model": "openbmb/MiniCPM-o-4_5",
"native_duplex": "1",
"resume": "1",
}
)
resume = {
"type": "session.resume",
"session_id": "sid-resume",
"incarnation": 3,
"resume_token": "opaque-token",
"last_received_server_event_seq": 17,
}
first_wire_event = json.loads(await protocol.receive_internal_event_text(_ProtocolWebSocket(resume)))
assert first_wire_event == resume
assert protocol._autostarted_default_session is False
def test_realtime_projects_resume_lifecycle_events_without_duplex_prefix():
protocol = NativeRealtimeSessionProtocol({})
created = protocol.encode_outbound_event(
{
"type": "session.created",
"session": {"id": "sid-resume"},
"incarnation": 3,
"attachment_generation": 1,
"resume_token": "first-token",
}
)[0]
resumed = protocol.encode_outbound_event(
{
"type": "session.resumed",
"session_id": "sid-resume",
"incarnation": 3,
"attachment_generation": 2,
"resume_token": "second-token",
}
)[0]
heartbeat = protocol.encode_outbound_event(
{
"type": "session.heartbeat_ack",
"session_id": "sid-resume",
}
)[0]
replaced = protocol.encode_outbound_event(
{
"type": "session.replaced",
"session_id": "sid-resume",
"attachment_generation": 1,
}
)[0]
resync = protocol.encode_outbound_event(
{
"type": "session.resync_required",
"session_id": "sid-resume",
"reason": "journal_gap",
}
)[0]
assert created["type"] == "session.created"
assert created["incarnation"] == 3
assert created["attachment_generation"] == 1
assert created["resume_token"] == "first-token"
assert resumed["type"] == "session.resumed"
assert resumed["resume_token"] == "second-token"
assert heartbeat["type"] == "session.heartbeat_ack"
assert replaced["type"] == "session.replaced"
assert resync == {
"type": "session.resync_required",
"session_id": "sid-resume",
"reason": "journal_gap",
"event_id": resync["event_id"],
}
def test_realtime_projects_listen_response_identity():
protocol = NativeRealtimeSessionProtocol({})
protocol.encode_outbound_event({"type": "session.created", "session": {"id": "sid-listen"}})
bound = protocol.encode_outbound_event(
{
"type": "response.listen",
"session_id": "sid-listen",
"epoch": 0,
"reason": "model_listen",
"model_listen": True,
"response_id": "resp_1",
}
)[0]
# A listen that terminates a precreated response keeps the response
# identity on the Realtime dialect; clients demultiplex on response_id.
assert bound["type"] == "response.listen"
assert bound["response_id"] == "resp_1"
assert bound["response"]["id"] == "resp_1"
assert bound["response"]["status"] == "listening"
anonymous = protocol.encode_outbound_event(
{
"type": "response.listen",
"session_id": "sid-listen",
"epoch": 0,
"reason": "silence_or_noise",
}
)[0]
assert "response_id" not in anonymous
assert "id" not in anonymous["response"]
def test_duplex_session_registry_advances_incarnation_when_id_is_reused():
registry = DuplexSessionRegistry()
first = registry.create(session_id="reused-session")
registry.close(first.session_id)
second = registry.create(session_id="reused-session")
assert first.incarnation == 0
assert second.incarnation == 1
def test_native_input_commit_does_not_advance_model_turn_identity():
session = DuplexSession(
session_id="sid-model-owned-turn",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
first = session.commit_native_audio_input(transcript="first chunk")
second = session.commit_native_audio_input(transcript="second chunk")
assert session.input_commit_seq == 2
assert first.input_commit_seq == 1
assert second.input_commit_seq == 2
assert first.turn_id == second.turn_id == session.turn_id == 0
def test_duplex_session_owns_response_and_overlap_identity():
session = DuplexSession(session_id="sid-state-owner", config=DuplexSessionConfig())
response_id = session.begin_response()
session.accumulate_overlap_speech(320)
session.accumulate_overlap_speech(180)
session.end_response()
assert session.active_response_id is None
assert session.last_response_id == response_id
assert session.overlap_speech_ms == 500
assert session.reset_overlap_speech() == 500
assert session.overlap_speech_ms == 0
def test_duplex_session_composes_single_owner_ledgers():
session = DuplexSession(session_id="sid-ledgers", config=DuplexSessionConfig())
session.append_text("hello")
session.bind_request("req-1")
response_id = session.begin_response(turn_id=3)
session.mark_audio_sent(duration_ms=240)
assert not hasattr(session, "input")
assert not hasattr(session, "response")
assert not hasattr(session, "playback_ledger")
assert not hasattr(session, "conversation")
assert session.pending_text == ("hello",)
assert session.active_request_id == "req-1"
assert session.active_response_id == response_id
assert session.active_response_turn_id == 3
assert session.playback.sent_ms == 240
try:
session.playback.sent_ms = 480
except (AttributeError, TypeError):
pass
else:
raise AssertionError("playback view must be immutable")
def test_duplex_barge_in_advances_epoch_and_drops_uncommitted_assistant_text():
registry = DuplexSessionRegistry()
session = registry.create()
response_id = session.begin_response()
session.bind_request("chatcmpl-duplex-test")
session.append_assistant_text("unplayed answer")
new_epoch = session.barge_in()
assert response_id is not None
assert new_epoch == 1
assert session.epoch == 1
assert session.active_request_id is None
assert session.active_response_id is None
assert session.assistant_text_buffer == ()
assert session.history == ()
assert session.turn_state == DuplexTurnState.BARGE_IN
def test_duplex_playback_ack_tracks_committed_cursor_separately():
registry = DuplexSessionRegistry()
session = registry.create()
session.mark_audio_sent(duration_ms=10_000)
session.acknowledge_playback(played_ms=2_000)
assert session.playback.generated_ms == 10_000
assert session.playback.sent_ms == 10_000
assert session.playback.played_ms == 2_000
assert session.playback.committed_ms == 2_000
@pytest.mark.parametrize("sample_rate_hz,frame_count", [(22_050, 23), (24_000, 24)])
def test_duplex_generated_audio_rounds_only_cumulative_frames(sample_rate_hz: int, frame_count: int):
session = DuplexSessionRegistry().create()
response_id = session.begin_response()
durations = [
session.record_generated_audio(response_id, frame_count=1, sample_rate_hz=sample_rate_hz)
for _ in range(frame_count)
]
assert durations[:-1] == [0] * (frame_count - 1)
assert durations[-1] == 1
assert session.playback.generated_ms == 1
assert session.playback.sent_ms == 0
assert session.playback.played_ms == 0
def test_duplex_generated_audio_rejects_rate_change_without_advancing():
session = DuplexSessionRegistry().create()
response_id = session.begin_response()
session.record_generated_audio(response_id, frame_count=2205, sample_rate_hz=22_050)
with pytest.raises(ValueError, match="sample rate changed"):
session.record_generated_audio(response_id, frame_count=2400, sample_rate_hz=24_000)
assert session.playback.generated_ms == 100
assert session.playback.sent_ms == 0
def test_duplex_late_audio_accounting_cannot_change_new_response():
session = DuplexSessionRegistry().create()
old_response = session.begin_response()
session.record_generated_audio(old_response, frame_count=2205, sample_rate_hz=22_050)
session.barge_in()
new_response = session.begin_response()
session.record_generated_audio(new_response, frame_count=2400, sample_rate_hz=48_000)
current_state = session.turn_state
assert session.record_generated_audio(old_response, frame_count=2205, sample_rate_hz=22_050) is None
session.mark_audio_sent(100, response_id=old_response, text_chars=99)
assert session.playback_for_response(old_response).sent_ms == 100
assert session.playback.generated_ms == 50
assert session.playback.sent_ms == 0
assert session.assistant_audio_text_marks == ()
assert session.turn_state == current_state
def test_duplex_history_commit_uses_audio_text_alignment_marks():
registry = DuplexSessionRegistry()
session = registry.create()
session.begin_response()
session.append_assistant_text("hello ")
session.mark_audio_sent(duration_ms=1_000, text_chars=6)
session.append_assistant_text("world")
session.mark_audio_sent(duration_ms=2_000, text_chars=11)
session.acknowledge_playback(played_ms=1_200, committed_ms=1_200)
committed = session.end_response(commit_text=True)
assert committed == {"role": "assistant", "content": "hello w"}
def test_turn_controller_accepts_external_signal_source():
registry = DuplexSessionRegistry()
session = registry.create()
controller = DuplexTurnController()
event = controller.signal(session, DuplexTurnEventType.USER_STARTED.value)
assert event["type"] == "turn.event"
assert event["event"] == "user_started"
assert event["turn_state"] == "user_speaking"
assert session.turn_state == DuplexTurnState.USER_SPEAKING
def test_duplex_capabilities_do_not_claim_core_kv_or_input_append():
registry = DuplexSessionRegistry()
session = registry.create()
caps = session.capabilities.as_dict()
assert caps["implementation_level"] == "serving_session_adapter"
assert caps["supports_kv_lease"] is False
assert caps["supports_input_append"] is False
assert caps["supports_reencode_context"] is True
assert caps["adapter_patterns"] == ["chunk_group_append"]
assert "turn_commit_only" in caps["input_modes"]
assert "client_event" in caps["signal_sources"]
def test_minicpmo_native_capabilities_separate_model_state_from_core_kv_lease():
caps = minicpmo45_native_capabilities(max_sessions=2).as_dict()
assert caps["implementation_level"] == "model_native_duplex"
assert caps["supports_input_append"] is True
assert caps["input_modes"] == ["append_audio_chunk"]
assert caps["adapter_patterns"] == ["scheduler_data_plane"]
assert caps["supports_model_internal_state"] is True
assert caps["requires_model_runner_kv"] is True
assert caps["requires_native_stage_role"] is True
assert caps["supports_kv_lease"] is False
assert caps["supports_core_kv_lease"] is False
assert caps["supports_stage_resumption"] is True
assert caps["supports_scheduler_native_append"] is False
assert caps["supports_core_resumable_request"] is True
assert caps["supports_stage_connector_handoff"] is True
assert caps["supports_audio_truncate"] is True
assert caps["supports_barge_in"] is True
assert caps["target_barge_in_latency_ms"] is None
assert caps["supports_multi_session"] is True
assert caps["supports_multi_session_same_replica"] is True
assert caps["supports_session_lease"] is True
assert caps["supports_session_resume"] is True
assert caps["session_admission_mode"] == "engine_managed"
assert caps["stage_handoff_transport"] == "scheduler_data_plane"
def test_duplex_overlap_policy_auto_falls_back_to_listen_only():
from vllm_omni.entrypoints.duplex.protocol import (
DuplexOverlapPolicy,
DuplexSessionConfig,
)
assert DuplexSessionConfig._normalize_overlap_policy("auto") == DuplexOverlapPolicy.LISTEN_ONLY.value
def test_minicpmo_native_capabilities_do_not_overclaim_single_session_deployment():
caps = minicpmo45_native_capabilities(max_sessions=1).as_dict()
assert caps["supports_multi_session"] is False
assert caps["supports_multi_session_same_replica"] is False
def test_duplex_overlap_policy_defaults_and_invalid_values_to_listen_only():
assert DuplexSessionConfig().overlap_policy == DuplexOverlapPolicy.LISTEN_ONLY.value
assert DuplexSessionConfig._normalize_overlap_policy("not-a-policy") == DuplexOverlapPolicy.LISTEN_ONLY.value
@@ -133,7 +133,7 @@ async def test_registry_failed_send_is_accepted_only_when_journaled(mocker, jour
assert accepted.call_args_list == expected_calls
raise RuntimeError("transport lost")
await registry.create("sid-send-failure", incarnation=0, send=failing_send, close=mocker.AsyncMock())
await registry.create("sid-send-failure", send=failing_send, close=mocker.AsyncMock())
with pytest.raises(RuntimeError, match="transport lost"):
await registry.send_event(
@@ -152,7 +152,7 @@ async def test_registry_detached_event_is_accepted_only_when_journaled(mocker, j
registry = DuplexSessionAttachmentRegistry(replay_ttl_s=60.0, replay_max_bytes_per_session=4096)
send = mocker.AsyncMock()
accepted = mocker.Mock()
created = await registry.create("sid-detached-acceptance", incarnation=0, send=send, close=mocker.AsyncMock())
created = await registry.create("sid-detached-acceptance", send=send, close=mocker.AsyncMock())
await registry.detach("sid-detached-acceptance", attachment_generation=created.attachment_generation)
entry = await registry.send_event(
@@ -175,7 +175,7 @@ async def test_registry_unjournaled_event_is_accepted_after_successful_send(mock
async def send(payload):
accepted.assert_not_called()
await registry.create("sid-live-acceptance", incarnation=0, send=send, close=mocker.AsyncMock())
await registry.create("sid-live-acceptance", send=send, close=mocker.AsyncMock())
entry = await registry.send_event(
"sid-live-acceptance",
@@ -193,7 +193,7 @@ async def test_registry_overflow_does_not_accept_or_send_event(mocker) -> None:
registry = DuplexSessionAttachmentRegistry(replay_ttl_s=60.0, replay_max_bytes_per_session=100)
accepted = mocker.Mock()
send = mocker.AsyncMock()
await registry.create("sid-overflow-acceptance", incarnation=0, send=send, close=mocker.AsyncMock())
await registry.create("sid-overflow-acceptance", send=send, close=mocker.AsyncMock())
with pytest.raises(DuplexJournalOverflowError, match="byte limit"):
await registry.send_event(
@@ -231,13 +231,12 @@ async def test_registry_resume_rotates_token_replays_and_atomically_replaces_att
async def close_b(reason):
closes_b.append(reason)
created = await registry.create("sid", incarnation=2, send=send_a, close=close_a)
created = await registry.create("sid", send=send_a, close=close_a)
await registry.send_event("sid", {"type": "event-1"})
await registry.send_event("sid", {"type": "event-2"})
resumed = await registry.resume(
"sid",
incarnation=2,
resume_token=created.resume_token.plaintext,
last_received_server_event_seq=1,
send=send_b,
@@ -260,6 +259,34 @@ async def test_registry_resume_rotates_token_replays_and_atomically_replaces_att
assert closes_a == [] and sends_b == [] and closes_b == []
@pytest.mark.asyncio
async def test_registry_detach_without_a_generation_targets_the_current_attachment_once() -> None:
registry = DuplexSessionAttachmentRegistry(replay_ttl_s=60.0, replay_max_bytes_per_session=4096)
sends: list[dict] = []
async def send(payload):
sends.append(payload)
async def close(reason):
return None
await registry.create("sid-current", send=send, close=close)
# The outbound pump outlives any one connection and only knows the session:
# ``None`` means "whichever socket is attached right now".
assert await registry.detach("sid-current", attachment_generation=None) is True
# A second report of the same dead socket is not a new detach, so it cannot
# restart the engine's disconnect grace window.
assert await registry.detach("sid-current", attachment_generation=None) is False
assert await registry.detach("sid-current", attachment_generation=1) is False
assert await registry.is_current_attachment("sid-current", 1) is False
# A detached session still journals for a later resume.
await registry.send_event("sid-current", {"type": "event-1"})
assert sends == []
assert await registry.detach("missing-session", attachment_generation=None) is False
@pytest.mark.asyncio
async def test_registry_resume_sends_activation_then_replay_before_new_live_events() -> None:
registry = DuplexSessionAttachmentRegistry(
@@ -282,13 +309,12 @@ async def test_registry_resume_sends_activation_then_replay_before_new_live_even
activation_started.set()
await release_activation.wait()
created = await registry.create("sid-order", incarnation=0, send=old_send, close=close)
created = await registry.create("sid-order", send=old_send, close=close)
await registry.detach("sid-order", attachment_generation=1)
await registry.send_event("sid-order", {"type": "replayed"})
resume_task = asyncio.create_task(
registry.resume(
"sid-order",
incarnation=0,
resume_token=created.resume_token.plaintext,
last_received_server_event_seq=0,
send=new_send,
@@ -322,13 +348,12 @@ async def test_registry_resume_delivery_failure_keeps_one_shot_old_token_recover
async def close(reason):
del reason
created = await registry.create("sid-recovery", incarnation=0, send=send, close=close)
created = await registry.create("sid-recovery", send=send, close=close)
await registry.detach("sid-recovery", attachment_generation=1)
with pytest.raises(RuntimeError, match="transport lost"):
await registry.resume(
"sid-recovery",
incarnation=0,
resume_token=created.resume_token.plaintext,
last_received_server_event_seq=0,
send=failing_send,
@@ -342,7 +367,6 @@ async def test_registry_resume_delivery_failure_keeps_one_shot_old_token_recover
recovered = await registry.resume(
"sid-recovery",
incarnation=0,
resume_token=created.resume_token.plaintext,
last_received_server_event_seq=0,
send=send,
@@ -353,7 +377,6 @@ async def test_registry_resume_delivery_failure_keeps_one_shot_old_token_recover
with pytest.raises(InvalidResumeTokenError):
await registry.resume(
"sid-recovery",
incarnation=0,
resume_token=created.resume_token.plaintext,
last_received_server_event_seq=0,
send=send,
@@ -374,13 +397,12 @@ async def test_registry_concurrent_resume_allows_exactly_one_rotated_token_winne
async def close(reason):
del reason
created = await registry.create("sid-race", incarnation=0, send=send, close=close)
created = await registry.create("sid-race", send=send, close=close)
async def attempt():
try:
return await registry.resume(
"sid-race",
incarnation=0,
resume_token=created.resume_token.plaintext,
last_received_server_event_seq=0,
send=send,
@@ -396,7 +418,7 @@ async def test_registry_concurrent_resume_allows_exactly_one_rotated_token_winne
@pytest.mark.asyncio
async def test_registry_keeps_sessions_journals_and_incarnations_isolated() -> None:
async def test_registry_keeps_sessions_journals_and_tokens_isolated() -> None:
registry = DuplexSessionAttachmentRegistry(
replay_ttl_s=60.0,
replay_max_bytes_per_session=4096,
@@ -408,8 +430,8 @@ async def test_registry_keeps_sessions_journals_and_incarnations_isolated() -> N
async def close(reason):
del reason
created_a = await registry.create("sid-a", incarnation=1, send=send, close=close)
created_b = await registry.create("sid-b", incarnation=4, send=send, close=close)
created_a = await registry.create("sid-a", send=send, close=close)
created_b = await registry.create("sid-b", send=send, close=close)
await registry.detach("sid-a", attachment_generation=1)
await registry.detach("sid-b", attachment_generation=1)
await registry.send_event("sid-a", {"type": "a-only"})
@@ -417,7 +439,6 @@ async def test_registry_keeps_sessions_journals_and_incarnations_isolated() -> N
resumed_a = await registry.resume(
"sid-a",
incarnation=1,
resume_token=created_a.resume_token.plaintext,
last_received_server_event_seq=0,
send=send,
@@ -425,7 +446,6 @@ async def test_registry_keeps_sessions_journals_and_incarnations_isolated() -> N
)
resumed_b = await registry.resume(
"sid-b",
incarnation=4,
resume_token=created_b.resume_token.plaintext,
last_received_server_event_seq=0,
send=send,
@@ -434,11 +454,10 @@ async def test_registry_keeps_sessions_journals_and_incarnations_isolated() -> N
assert [entry.payload["type"] for entry in resumed_a.replay_entries] == ["a-only"]
assert [entry.payload["type"] for entry in resumed_b.replay_entries] == ["b-only"]
with pytest.raises(ValueError, match="incarnation mismatch"):
with pytest.raises(InvalidResumeTokenError):
await registry.resume(
"sid-a",
incarnation=2,
resume_token=resumed_a.resume_token.plaintext,
resume_token=resumed_b.resume_token.plaintext,
last_received_server_event_seq=1,
send=send,
close=close,
@@ -458,7 +477,7 @@ async def test_registry_repr_never_contains_plaintext_tokens() -> None:
async def close(reason):
del reason
created = await registry.create("sid-repr", incarnation=0, send=send, close=close)
created = await registry.create("sid-repr", send=send, close=close)
await registry.detach("sid-repr", attachment_generation=1)
entry = await registry.send_event(
"sid-repr",
@@ -469,3 +488,67 @@ async def test_registry_repr_never_contains_plaintext_tokens() -> None:
assert created.resume_token.plaintext not in repr(created)
assert created.resume_token.plaintext not in repr(entry)
assert created.resume_token.plaintext not in repr(registry)
@pytest.mark.asyncio
@pytest.mark.parametrize("cancel_on", ["activation", "replay"])
async def test_registry_resume_cancelled_mid_delivery_rolls_back_like_a_failure(cancel_on: str) -> None:
"""Cancellation must take the same rollback as an ordinary delivery failure.
``CancelledError`` derives from ``BaseException``, so an ``except
Exception`` around the activation and replay sends let a cancelled resume
keep its attachment as the current generation while the token it rotated
past was already gone — the session left attached to an unusable transport
with no way back. Both send points are covered because either can be the
one that gets cancelled.
"""
registry = DuplexSessionAttachmentRegistry(replay_ttl_s=60.0, replay_max_bytes_per_session=4096)
async def send(payload):
del payload
async def close(reason):
del reason
created = await registry.create("sid-cancel", send=send, close=close)
# One journaled event, so the replay loop has something to send.
await registry.send_event("sid-cancel", {"type": "response.output_text.delta", "delta": "a"})
await registry.detach("sid-cancel", attachment_generation=1)
sends = 0
cancel_at = 1 if cancel_on == "activation" else 2
async def cancelling_send(payload):
del payload
nonlocal sends
sends += 1
if sends == cancel_at:
raise asyncio.CancelledError
with pytest.raises(asyncio.CancelledError):
await registry.resume(
"sid-cancel",
resume_token=created.resume_token.plaintext,
last_received_server_event_seq=0,
send=cancelling_send,
close=close,
activation_payload_factory=lambda token, generation: {
"type": "session.resumed",
"resume_token": token.plaintext,
"attachment_generation": generation,
},
)
assert sends == cancel_at, "the send that was cancelled is the one under test"
# The cancelled attachment must not remain current...
assert not await registry.has_attachment("sid-cancel")
# ...and the token the caller presented has to work again, or the session
# is unreachable: resume() had already rotated past it.
recovered = await registry.resume(
"sid-cancel",
resume_token=created.resume_token.plaintext,
last_received_server_event_seq=0,
send=send,
close=close,
)
assert recovered.attachment_generation == 3
@@ -920,7 +920,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
@@ -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()
@@ -674,7 +674,7 @@ def test_resolve_stage_configs_delegates_overrides_to_resolver(mocker):
engine_args=SimpleNamespace(additional_config=additional_config),
)
resolve_config = mocker.patch(
"vllm_omni.engine.async_omni_engine.resolve_omni_config",
"vllm_omni.engine.omni_engine_base.resolve_omni_config",
return_value=OmniConfigResolution(
config_path="dummy.yaml",
stage_configs=(fake_diffusion_stage,),
-601
View File
@@ -1,601 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from types import SimpleNamespace
import pytest
from vllm_omni.engine.duplex.lease import DuplexLeaseActivity
from vllm_omni.engine.duplex.messages import DuplexFence
from vllm_omni.engine.duplex.runtime import (
DuplexOutputAction,
DuplexOutputDecision,
duplex_resource_request_id,
)
from vllm_omni.engine.messages import OutputMessage
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.entrypoints.client_request_state import ClientRequestState
from vllm_omni.entrypoints.duplex_request_client import DuplexRequestClient
from vllm_omni.metrics.stats import OrchestratorAggregator, StageRequestStats, StageStats
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.outputs.duplex import attach_duplex_output_decision
def test_async_omni_uses_extracted_duplex_request_client():
app = object.__new__(AsyncOmni)
app.engine = SimpleNamespace(num_stages=2)
app.request_states = {}
app._duplex_request_client = None
client = app._get_duplex_request_client()
assert isinstance(client, DuplexRequestClient)
assert not hasattr(client, "_async_omni")
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@pytest.mark.asyncio
async def test_async_omni_open_duplex_forwards_session_config_and_timeout():
calls = []
output_handler_starts = 0
async def open_duplex_session_async(session_id, **kwargs):
calls.append((session_id, kwargs))
return {"ok": True}
app = object.__new__(AsyncOmni)
app.engine = SimpleNamespace(open_duplex_session_async=open_duplex_session_async)
def start_output_handler():
nonlocal output_handler_starts
output_handler_starts += 1
app._final_output_handler = start_output_handler
result = await app.open_duplex_session_async(
"sid",
fence=DuplexFence("sid"),
capabilities={"implementation_level": "model_native_duplex"},
session_config={"instructions": "Be brief.", "idle_timeout_s": 30},
timeout=7.5,
)
assert result == {"ok": True}
assert output_handler_starts == 1
assert calls == [
(
"sid",
{
"session_mode": "duplex",
"capabilities": {"implementation_level": "model_native_duplex"},
"session_config": {"instructions": "Be brief.", "idle_timeout_s": 30},
"fence": DuplexFence("sid"),
"timeout": 7.5,
},
)
]
@pytest.mark.asyncio
async def test_async_omni_duplex_runtime_controls_forward_timeout():
calls = []
cancelled_fence = DuplexFence("sid", epoch=2, turn_id=3)
next_fence = DuplexFence("sid", epoch=3, turn_id=3)
async def append_duplex_input_async(session_id, **kwargs):
calls.append(("append", session_id, kwargs))
return {"ok": True}
async def signal_duplex_turn_async(session_id, **kwargs):
calls.append(("signal", session_id, kwargs))
return {"ok": True}
async def close_duplex_session_async(session_id, **kwargs):
calls.append(("close", session_id, kwargs))
return {"ok": True}
async def touch_duplex_session_async(session_id, **kwargs):
calls.append(("touch", session_id, kwargs))
return {"ok": True}
async def resume_duplex_session_async(session_id, **kwargs):
calls.append(("resume", session_id, kwargs))
return {"ok": True}
app = object.__new__(AsyncOmni)
app.request_states = {}
app._final_output_handler = lambda: None
app.engine = SimpleNamespace(
append_duplex_input_async=append_duplex_input_async,
signal_duplex_turn_async=signal_duplex_turn_async,
close_duplex_session_async=close_duplex_session_async,
touch_duplex_session_async=touch_duplex_session_async,
resume_duplex_session_async=resume_duplex_session_async,
)
await app.append_duplex_input_async(
"sid",
mode="append_audio_chunk",
payload={},
fence=cancelled_fence,
timeout=12.5,
)
await app.signal_duplex_turn_async(
"sid",
event="barge_in",
fence=cancelled_fence,
next_fence=next_fence,
timeout=13.5,
)
await app.close_duplex_session_async("sid", reason="done", fence=next_fence, timeout=14.5)
await app.touch_duplex_session_async(
"sid",
fence=next_fence,
activity=DuplexLeaseActivity.HEARTBEAT,
timeout=15.5,
)
await app.resume_duplex_session_async(
"sid",
fence=next_fence,
expected_lease_generation=3,
timeout=16.5,
)
assert calls == [
(
"append",
"sid",
{
"mode": "append_audio_chunk",
"payload": {},
"final": False,
"expected_epoch": None,
"fence": cancelled_fence,
"timeout": 12.5,
},
),
(
"signal",
"sid",
{
"event": "barge_in",
"fence": cancelled_fence,
"next_fence": next_fence,
"timeout": 13.5,
},
),
(
"close",
"sid",
{
"reason": "done",
"fence": next_fence,
"timeout": 14.5,
},
),
(
"touch",
"sid",
{
"fence": next_fence,
"activity": DuplexLeaseActivity.HEARTBEAT,
"timeout": 15.5,
},
),
(
"resume",
"sid",
{
"fence": next_fence,
"expected_lease_generation": 3,
"timeout": 16.5,
},
),
]
@pytest.mark.asyncio
async def test_async_omni_duplex_append_can_defer_data_plane_output_collection():
app = object.__new__(AsyncOmni)
request_id = duplex_resource_request_id(DuplexFence("sid"), "stage0")
async def append_duplex_input_async(session_id, **kwargs):
del session_id, kwargs
request_state = app.request_states.get(request_id)
assert request_state is not None
assert request_state.metrics is not None
return {
"stage_results": [
{
"result": {
"data_plane_append": True,
"request_id": request_id,
"response_stage_id": 1,
}
}
]
}
app.engine = SimpleNamespace(append_duplex_input_async=append_duplex_input_async)
app.request_states = {}
app._final_output_handler = lambda: None
result = await app.append_duplex_input_async(
"sid",
mode="append_audio_chunk",
payload={},
fence=DuplexFence("sid"),
collect_outputs=False,
)
assert "data_plane_outputs" not in result
@pytest.mark.asyncio
async def test_async_omni_duplex_append_forwards_fence():
calls = []
async def append_duplex_input_async(session_id, **kwargs):
calls.append((session_id, kwargs))
return {"ok": True}
app = object.__new__(AsyncOmni)
app.request_states = {}
app._final_output_handler = lambda: None
app.engine = SimpleNamespace(append_duplex_input_async=append_duplex_input_async)
fence = DuplexFence("sid", epoch=1, turn_id=2)
await app.append_duplex_input_async(
"sid",
mode="append_audio_chunk",
payload={},
fence=fence,
collect_outputs=False,
)
assert calls[0][1]["fence"] is fence
@pytest.mark.asyncio
async def test_async_omni_duplex_collect_waits_for_response_stage():
app = object.__new__(AsyncOmni)
req_state = ClientRequestState("duplex-sid-e0-stage0")
stage0_output = OmniRequestOutput(
request_id="duplex-sid-e0-stage0",
stage_id=0,
finished=False,
)
stage1_output = OmniRequestOutput(
request_id="duplex-sid-e0-stage0",
stage_id=1,
final_output_type="audio",
finished=False,
)
await req_state.queue.put(
OutputMessage(
request_id="duplex-sid-e0-stage0",
stage_id=0,
engine_outputs=stage0_output,
finished=False,
)
)
await req_state.queue.put(
OutputMessage(
request_id="duplex-sid-e0-stage0",
stage_id=1,
engine_outputs=stage1_output,
finished=False,
)
)
outputs = await app._collect_duplex_data_plane_outputs(
"duplex-sid-e0-stage0",
req_state,
response_stage_id=1,
timeout=1.0,
)
assert outputs == [stage1_output]
@pytest.mark.asyncio
async def test_duplex_request_client_retains_output_route_at_segment_terminal():
request_id = "duplex-sid-e0-stage0"
request_state = ClientRequestState(request_id)
request_states = {request_id: request_state}
output = OmniRequestOutput(
request_id=request_id,
stage_id=1,
final_output_type="audio",
finished=True,
)
await request_state.queue.put(
OutputMessage(
request_id=request_id,
stage_id=1,
engine_outputs=output,
finished=True,
)
)
client = DuplexRequestClient(
SimpleNamespace(),
SimpleNamespace(
request_states=request_states,
num_stages=2,
log_stats=False,
),
)
outputs = await client.collect_outputs(
request_id,
request_state,
response_stage_id=1,
timeout=1.0,
)
assert outputs == [output]
assert request_states[request_id] is request_state
@pytest.mark.asyncio
async def test_async_omni_duplex_direct_output_keeps_stage_metrics():
app = object.__new__(AsyncOmni)
app._duplex_request_client = None
app._final_output_handler = lambda: None
request_id = "duplex-sid-e0-stage0"
req_state = ClientRequestState(request_id)
decision = DuplexOutputDecision(
action=DuplexOutputAction.DIRECT_RESPONSE,
metadata={
"duplex_direct_response": True,
"duplex_native_decision": "speak",
},
)
direct_output = attach_duplex_output_decision(
OmniRequestOutput(
request_id=request_id,
stage_id=0,
final_output_type="text",
finished=True,
),
decision,
)
await req_state.queue.put(
OutputMessage(
request_id=request_id,
stage_id=0,
engine_outputs=direct_output,
metrics=StageRequestStats(
batch_id=1,
batch_size=1,
num_tokens_in=160,
num_tokens_out=8,
stage_gen_time_ms=80.0,
rx_transfer_bytes=0,
rx_decode_time_ms=0.0,
rx_in_flight_time_ms=0.0,
stage_stats=StageStats(total_token=8, total_gen_time_ms=80.0),
vllm_ttft_ms=12.5,
vllm_tpot_ms=9.0,
vllm_itl_ms=8.75,
vllm_itls_ms=[8.0, 9.5],
),
finished=True,
)
)
outputs = await app._collect_duplex_data_plane_outputs(
request_id,
req_state,
response_stage_id=1,
timeout=1.0,
)
assert outputs == [direct_output]
assert direct_output.metrics["stage_metrics"]["0"]["vllm_ttft_ms"] == 12.5
assert direct_output.metrics["stage_metrics"]["0"]["vllm_tpot_ms"] == 9.0
assert direct_output.metrics["stage_metrics"]["0"]["vllm_itls_ms"] == [8.0, 9.5]
@pytest.mark.asyncio
async def test_duplex_metrics_cursor_isolates_resumable_collect_windows():
app = object.__new__(AsyncOmni)
app._duplex_request_client = None
app._final_output_handler = lambda: None
request_id = "duplex-sid-e0-stage0"
req_state = ClientRequestState(request_id)
req_state.metrics = OrchestratorAggregator(
num_stages=2,
log_stats=False,
wall_start_ts=0.0,
final_stage_id_for_e2e=1,
)
def stage_message(*, batch_id: int, tokens: int, ttft_ms: float, itls: list[float]) -> OutputMessage:
decision = DuplexOutputDecision(
action=DuplexOutputAction.DIRECT_RESPONSE,
metadata={"duplex_direct_response": True, "duplex_native_decision": "speak"},
)
output = attach_duplex_output_decision(
OmniRequestOutput(
request_id=request_id,
stage_id=0,
final_output_type="text",
finished=True,
),
decision,
)
return OutputMessage(
request_id=request_id,
stage_id=0,
engine_outputs=output,
metrics=StageRequestStats(
batch_id=batch_id,
batch_size=1,
num_tokens_in=160,
num_tokens_out=tokens,
stage_gen_time_ms=80.0,
rx_transfer_bytes=0,
rx_decode_time_ms=0.0,
rx_in_flight_time_ms=0.0,
stage_stats=StageStats(total_token=tokens, total_gen_time_ms=80.0),
vllm_ttft_ms=ttft_ms,
vllm_tpot_ms=9.0,
vllm_itl_ms=sum(itls) / len(itls),
vllm_itls_ms=itls,
),
finished=True,
)
await req_state.queue.put(stage_message(batch_id=1, tokens=71, ttft_ms=62.5, itls=[10.0, 11.0]))
first = await app._collect_duplex_data_plane_outputs(
request_id,
req_state,
response_stage_id=1,
timeout=1.0,
)
await req_state.queue.put(stage_message(batch_id=2, tokens=155, ttft_ms=81.0, itls=[12.0, 13.0, 14.0]))
second = await app._collect_duplex_data_plane_outputs(
request_id,
req_state,
response_stage_id=1,
timeout=1.0,
)
assert first[0].metrics["stage_metrics"]["0"]["num_tokens_out"] == 71
second_stage0 = second[0].metrics["stage_metrics"]["0"]
assert second_stage0["num_tokens_out"] == 155
assert second_stage0["vllm_ttft_ms"] == 81.0
assert second_stage0["vllm_itls_ms"] == [12.0, 13.0, 14.0]
def test_async_omni_duplex_direct_output_prefers_outer_control_metadata():
decision = DuplexOutputDecision(
action=DuplexOutputAction.DIRECT_RESPONSE,
metadata={
"duplex_direct_response": True,
"duplex_native_decision": "listen",
},
)
output = attach_duplex_output_decision(
OmniRequestOutput(
request_id="duplex-sid-e0-stage0",
stage_id=0,
outputs=[],
_multimodal_output={"kind": "processed-payload"},
_custom_output={"inner": "completion"},
),
decision,
)
assert AsyncOmni._is_direct_duplex_data_plane_response(output)
def test_async_omni_drops_unregistered_duplex_prefixed_output():
app = object.__new__(AsyncOmni)
app.request_states = {}
output = OmniRequestOutput(request_id="duplex-unregistered-e0-stage0", stage_id=0)
keep_processing, request_id, stage_id, request_state = app._handle_output_message(
OutputMessage(
request_id=output.request_id,
stage_id=0,
engine_outputs=output,
finished=False,
)
)
assert keep_processing is True
assert request_id is stage_id is request_state is None
assert app.request_states == {}
@pytest.mark.asyncio
async def test_async_omni_duplex_collect_wraps_raw_response_stage_output():
app = object.__new__(AsyncOmni)
app.log_stats = False
app._enable_ar_profiler = False
app.request_states = {}
app.prom_metrics = SimpleNamespace(
set_running=lambda value: None,
set_waiting=lambda value: None,
)
app.engine = SimpleNamespace(
num_stages=2,
get_stage_metadata=lambda stage_id: SimpleNamespace(
final_output=stage_id == 1,
final_output_type="audio",
),
)
req_state = ClientRequestState("duplex-sid-e0-stage0")
raw_stage1_output = SimpleNamespace(
finished=False,
outputs=[],
stage_durations={},
peak_memory_mb=0.0,
final_output_type="audio",
)
await req_state.queue.put(
OutputMessage(
request_id="duplex-sid-e0-stage0",
stage_id=1,
engine_outputs=raw_stage1_output,
finished=False,
)
)
outputs = await app._collect_duplex_data_plane_outputs(
"duplex-sid-e0-stage0",
req_state,
response_stage_id=1,
timeout=1.0,
)
assert len(outputs) == 1
assert outputs[0].request_id == "duplex-sid-e0-stage0"
assert outputs[0].finished is False
assert outputs[0].stage_id == 1
assert outputs[0].final_output_type == "audio"
assert outputs[0].peak_memory_mb == raw_stage1_output.peak_memory_mb
def test_async_omni_duplex_request_info_includes_response_stage():
request_id, response_stage_id = AsyncOmni._duplex_data_plane_request_info(
{
"stage_results": [
{
"result": {
"data_plane_append": True,
"request_id": "duplex-sid-e0-stage0",
"response_stage_id": 1,
}
}
]
}
)
assert request_id == "duplex-sid-e0-stage0"
assert response_stage_id == 1
@pytest.mark.asyncio
async def test_duplex_request_client_retains_output_route_when_close_fails():
fence = DuplexFence("sid-close-route")
request_id = duplex_resource_request_id(fence, "stage0")
request_states = {request_id: ClientRequestState(request_id)}
async def close_duplex_session_async(session_id, **kwargs):
del session_id, kwargs
raise RuntimeError("close failed")
client = DuplexRequestClient(
SimpleNamespace(close_duplex_session_async=close_duplex_session_async),
SimpleNamespace(request_states=request_states),
)
with pytest.raises(RuntimeError, match="close failed"):
await client.close(fence.session_id, reason="test", fence=fence, timeout=1.0)
assert request_id in request_states
@@ -53,7 +53,7 @@ def test_async_engine_consumes_disable_log_stats_before_resolution(monkeypatch:
)
monkeypatch.setattr(
"vllm_omni.engine.async_omni_engine.load_and_resolve_stage_configs",
"vllm_omni.engine.omni_engine_base.load_and_resolve_stage_configs",
fake_resolve,
)
engine = SimpleNamespace(_apply_strategy_lb_policy=lambda *args, **kwargs: None)
@@ -1,126 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import pytest
from tests.entrypoints.openai_api.test_duplex_handler import FakeChatService, TimedWebSocket
from vllm_omni.engine.duplex.messages import DuplexFence
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.entrypoints.duplex.protocol import DuplexSession, DuplexSessionConfig
from vllm_omni.entrypoints.duplex.serving import OmniDuplexSessionHandler
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class FenceRecordingEngine:
output_modalities = ["text", "audio"]
def __init__(self) -> None:
self.calls: list[tuple[str, DuplexFence | None]] = []
self.signal_next_fences: list[DuplexFence | None] = []
async def open_duplex_session_async(self, session_id, *, fence=None, **kwargs):
del session_id, kwargs
self.calls.append(("open", fence))
return {"ok": True}
async def append_duplex_input_async(self, session_id, *, fence=None, **kwargs):
del session_id, kwargs
self.calls.append(("append", fence))
return {"ok": True}
async def signal_duplex_turn_async(
self,
session_id,
*,
event,
fence=None,
next_fence=None,
timeout=None,
):
del session_id, event, timeout
self.calls.append(("signal", fence))
self.signal_next_fences.append(next_fence)
return {"ok": True}
async def close_duplex_session_async(self, session_id, *, fence=None, **kwargs):
del session_id, kwargs
self.calls.append(("close", fence))
return {"ok": True}
@pytest.mark.asyncio
async def test_async_omni_forwards_fence_for_all_runtime_operations():
engine = FenceRecordingEngine()
app = object.__new__(AsyncOmni)
app.engine = engine
app.request_states = {}
app._final_output_handler = lambda: None
fence = DuplexFence("sid", epoch=1, turn_id=2)
await app.open_duplex_session_async("sid", fence=fence)
await app.append_duplex_input_async(
"sid", mode="append_audio_chunk", payload={}, fence=fence, collect_outputs=False
)
await app.signal_duplex_turn_async("sid", event="turn.end", fence=fence)
await app.close_duplex_session_async("sid", reason="done", fence=fence)
assert engine.calls == [(operation, fence) for operation in ("open", "append", "signal", "close")]
@pytest.mark.asyncio
async def test_openai_handler_passes_current_session_fence_for_runtime_controls():
engine = FenceRecordingEngine()
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine))
session = DuplexSession(
session_id="sid",
config=DuplexSessionConfig(extra_body={"native_duplex": True}),
epoch=1,
turn_id=2,
)
ws = TimedWebSocket()
fence = DuplexFence("sid", epoch=1, turn_id=2)
await handler._open_runtime_session(session, ws.send_json)
await handler._signal_runtime_session(session, "turn.end", send_json=ws.send_json)
await handler._close_runtime_session(session, reason="done", send_json=ws.send_json)
assert engine.calls == [(operation, fence) for operation in ("open", "signal", "close")]
@pytest.mark.asyncio
async def test_runtime_signal_uses_fence_captured_before_session_turn_advances():
engine = FenceRecordingEngine()
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine))
session = DuplexSession(session_id="sid", config=DuplexSessionConfig(extra_body={"native_duplex": True}))
captured_fence = DuplexFence("sid", epoch=0, turn_id=0)
session.turn_id = 1
await handler._signal_runtime_session(
session,
"input.commit",
fence=captured_fence,
)
assert engine.calls == [("signal", captured_fence)]
@pytest.mark.asyncio
async def test_openai_handler_forwards_cancel_fence_through_async_omni_facade():
engine = FenceRecordingEngine()
app = object.__new__(AsyncOmni)
app.engine = engine
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(app))
session = DuplexSession(session_id="sid-cancel", config=DuplexSessionConfig(extra_body={"native_duplex": True}))
cancelled_fence = DuplexFence("sid-cancel", epoch=2, turn_id=3)
next_fence = DuplexFence("sid-cancel", epoch=3, turn_id=3)
assert await handler._signal_runtime_session(
session,
"input.cancel",
fence=cancelled_fence,
next_fence=next_fence,
)
assert engine.calls == [("signal", cancelled_fence)]
assert engine.signal_next_fences == [next_fence]
+326
View File
@@ -0,0 +1,326 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""``DuplexOmni`` / ``DuplexSessionHandle``: the thin Python API over a fake engine."""
from __future__ import annotations
import asyncio
from dataclasses import replace
from typing import Any
import pytest
from tests.entrypoints.test_omni_entrypoints import FakeAsyncOmniEngine
from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig
from vllm_omni.engine.duplex import commands
from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig
from vllm_omni.engine.duplex.events import (
AudioDelta,
DuplexEvent,
SessionClosed,
SessionCreated,
SessionExpired,
)
from vllm_omni.engine.duplex.messages import (
DuplexControlResultMessage,
DuplexSessionError,
DuplexSessionEventMessage,
)
from vllm_omni.engine.messages import ErrorMessage
from vllm_omni.entrypoints.duplex_omni import DuplexOmni, DuplexSessionHandle
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class FakeDuplexEngine(FakeAsyncOmniEngine):
"""The engine surface ``DuplexOmni`` uses; every call is recorded."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.duplex_session_config = DuplexSessionRuntimeConfig(max_sessions=2)
self.duplex_capabilities = DuplexCapabilities(supports_session_resume=True)
self.opened: list[tuple[str, DuplexSessionConfig]] = []
self.closed: list[tuple[str, str]] = []
self.resumed: list[tuple[str, int]] = []
self.touched: list[tuple[str, str]] = []
self.commands: list[tuple[str, commands.DuplexCommand]] = []
self.open_error: DuplexSessionError | None = None
self.close_error: DuplexSessionError | None = None
def _result(self, operation: str, session_id: str, **fields: Any) -> DuplexControlResultMessage:
return DuplexControlResultMessage(control_id="c", operation=operation, session_id=session_id, ok=True, **fields)
async def open_session_async(self, session_id, session_config, *, timeout=None):
self.opened.append((session_id, session_config))
if self.open_error is not None:
raise self.open_error
return self._result(
"open",
session_id,
capabilities=self.duplex_capabilities,
public_session={"id": session_id, "idle_timeout_s": 7},
lease_generation=0,
)
async def close_session_async(self, session_id, *, reason="client_close", timeout=None):
self.closed.append((session_id, reason))
if self.close_error is not None:
raise self.close_error
# The runner emits session.closed before answering the RPC.
self.emit(session_id, SessionClosed(reason=reason))
return self._result("close", session_id)
async def resume_session_async(self, session_id, *, expected_lease_generation, timeout=None):
self.resumed.append((session_id, expected_lease_generation))
return self._result("resume", session_id, lease_generation=expected_lease_generation + 1)
async def touch_session_async(self, session_id, *, activity, timeout=None):
self.touched.append((session_id, activity))
return self._result("touch", session_id)
async def submit_command_async(self, session_id, command):
self.commands.append((session_id, command))
def emit(self, session_id: str, event: DuplexEvent) -> None:
# The session manager binds the session identity to every event it emits.
event = replace(event, session_id=session_id)
self.output_q.put(DuplexSessionEventMessage(session_id=session_id, event=event))
def _make_omni(monkeypatch: pytest.MonkeyPatch) -> tuple[DuplexOmni, FakeDuplexEngine]:
engine = FakeDuplexEngine()
monkeypatch.setattr("vllm_omni.entrypoints.duplex_omni.DuplexOmniEngine", lambda *args, **kwargs: engine)
monkeypatch.setattr("vllm_omni.entrypoints.omni_base.omni_snapshot_download", lambda model: model)
return DuplexOmni("dummy-model"), engine
async def _collect(handle: DuplexSessionHandle, count: int, *, timeout_s: float = 2.0) -> list[DuplexEvent]:
events: list[DuplexEvent] = []
async def consume() -> None:
async for event in handle.events():
events.append(event)
if len(events) >= count:
break
await asyncio.wait_for(consume(), timeout=timeout_s)
return events
@pytest.mark.asyncio
async def test_open_session_allocates_the_id_and_ignores_client_ids(monkeypatch) -> None:
omni, engine = _make_omni(monkeypatch)
try:
handle = await omni.open_session({"session_id": "mine", "id": "mine-too", "instructions": "hi"})
assert handle.session_id.startswith("duplex-") and len(handle.session_id) == len("duplex-") + 32
assert handle.session_id != "mine"
((session_id, config),) = engine.opened
assert session_id == handle.session_id
assert isinstance(config, DuplexSessionConfig)
assert config.model == "dummy-model" and config.instructions == "hi"
assert handle.capabilities is engine.duplex_capabilities
assert handle.public_session["id"] == handle.session_id
assert omni.sessions == {handle.session_id: handle}
assert omni.get_session(handle.session_id) is handle
assert omni.active_session_count() == 1
second = await omni.open_session(DuplexSessionConfig(instructions="typed"))
assert second.session_id != handle.session_id
assert engine.opened[1][1].model == "dummy-model"
assert omni.duplex_session_config.max_sessions == 2
assert omni.duplex_capabilities.supports_session_resume is True
finally:
omni.shutdown()
@pytest.mark.asyncio
async def test_open_failure_drops_the_pending_handle_and_raises_the_engine_code(monkeypatch) -> None:
omni, engine = _make_omni(monkeypatch)
engine.open_error = DuplexSessionError("full", code="resource_exhausted", retryable=True)
try:
with pytest.raises(DuplexSessionError) as excinfo:
await omni.open_session()
assert excinfo.value.code == "resource_exhausted"
assert omni.sessions == {}
finally:
omni.shutdown()
@pytest.mark.asyncio
async def test_events_are_routed_to_their_handle_and_end_on_session_closed(monkeypatch) -> None:
omni, engine = _make_omni(monkeypatch)
try:
handle = await omni.open_session()
engine.emit("duplex-unknown", AudioDelta(delta="zzz")) # dropped
engine.emit(handle.session_id, SessionCreated(session={"id": handle.session_id}))
engine.emit(handle.session_id, AudioDelta(response_id="r1", delta="abc"))
engine.emit(handle.session_id, SessionClosed(reason="client_close"))
events = await _collect(handle, 10)
assert [event.type for event in events] == ["session.created", "response.output_audio.delta", "session.closed"]
assert events[0].session_id == handle.session_id
assert handle.closed and handle.close_reason == "client_close"
assert await handle.wait_closed() == "client_close"
assert handle.session_id not in omni.sessions
with pytest.raises(DuplexSessionError) as excinfo:
await handle.submit(commands.Heartbeat())
assert excinfo.value.code == "session_closed"
finally:
omni.shutdown()
@pytest.mark.asyncio
async def test_events_is_single_consumer_but_may_be_reentered(monkeypatch) -> None:
omni, engine = _make_omni(monkeypatch)
try:
handle = await omni.open_session()
engine.emit(handle.session_id, AudioDelta(delta="one"))
first = handle.events()
assert (await first.__anext__()).type == "response.output_audio.delta"
with pytest.raises(RuntimeError, match="active events"):
await handle.events().__anext__()
await first.aclose()
engine.emit(handle.session_id, SessionExpired(reason="idle_ttl_expired"))
events = await _collect(handle, 1)
assert events[0].type == "session.expired" and events[0].is_terminal
assert handle.closed and handle.close_reason == "idle_ttl_expired"
finally:
omni.shutdown()
@pytest.mark.asyncio
async def test_close_session_waits_for_the_typed_session_closed_event(monkeypatch) -> None:
omni, engine = _make_omni(monkeypatch)
try:
handle = await omni.open_session()
consumer = asyncio.create_task(_collect(handle, 1))
await handle.close(reason="done")
assert engine.closed == [(handle.session_id, "done")]
assert handle.closed and handle.close_reason == "done"
assert (await consumer)[0].type == "session.closed"
# Idempotent: a second close is a no-op.
await omni.close_session(handle.session_id)
assert len(engine.closed) == 1
finally:
omni.shutdown()
@pytest.mark.asyncio
async def test_close_failure_marks_the_handle_closed_and_propagates(monkeypatch) -> None:
omni, engine = _make_omni(monkeypatch)
engine.close_error = DuplexSessionError("boom", code="engine_error")
try:
handle = await omni.open_session()
with pytest.raises(DuplexSessionError, match="boom"):
await omni.close_session(handle.session_id)
assert handle.closed
# A failed close must not leave a dead handle shadowing the id.
assert omni.sessions == {}
finally:
omni.shutdown()
@pytest.mark.asyncio
async def test_engine_death_ends_every_session_handle(monkeypatch) -> None:
omni, engine = _make_omni(monkeypatch)
try:
handle = await omni.open_session()
engine.output_q.put(ErrorMessage(error="orchestrator died", fatal=True))
await asyncio.wait_for(handle.wait_closed(), timeout=2.0)
assert handle.closed
assert "engine_dead" in (handle.close_reason or "")
assert omni.sessions == {}
finally:
omni.shutdown()
@pytest.mark.asyncio
async def test_resume_detach_and_touch_forward_to_the_engine(monkeypatch) -> None:
omni, engine = _make_omni(monkeypatch)
try:
handle = await omni.open_session()
resumed = await omni.resume_session(handle.session_id, expected_lease_generation=0)
assert resumed is handle and handle.lease_generation == 1
assert engine.resumed == [(handle.session_id, 0)]
await omni.detach_session(handle.session_id)
await omni.touch_session(handle.session_id, activity="heartbeat")
assert engine.touched == [(handle.session_id, "detach"), (handle.session_id, "heartbeat")]
for call in (
omni.resume_session("duplex-nope", expected_lease_generation=0),
omni.detach_session("duplex-nope"),
):
with pytest.raises(DuplexSessionError) as excinfo:
await call
assert excinfo.value.code == "unknown_session"
finally:
omni.shutdown()
@pytest.mark.asyncio
async def test_handle_wrappers_build_typed_commands_in_caller_order(monkeypatch) -> None:
omni, engine = _make_omni(monkeypatch)
try:
handle = await omni.open_session()
await handle.append_audio(b"\x00\x01", sample_rate_hz=16000, is_speech=True)
await handle.commit(create_response=True)
await handle.create_response({"instructions": "x"})
await handle.cancel_response("resp-1")
await handle.barge_in()
await handle.ack_playback(120.7, response_id="resp-1", committed_ms=100)
await handle.update({"temperature": 0.2})
await handle.create_item({"id": "item_1", "type": "message"}, previous_item_id="root")
await handle.delete_item("item_1")
await handle.truncate_item("item_1", audio_end_ms=50)
await handle.signal_turn("user_started", {"x": 1})
await handle.heartbeat()
submitted = [command for session_id, command in engine.commands if session_id == handle.session_id]
assert [type(command) for command in submitted] == [
commands.AppendAudio,
commands.Commit,
commands.CreateResponse,
commands.CancelResponse,
commands.BargeIn,
commands.AckPlayback,
commands.UpdateSession,
commands.CreateItem,
commands.DeleteItem,
commands.TruncateItem,
commands.SignalTurn,
commands.Heartbeat,
]
append, commit, create, cancel, _, ack, update, item, delete, truncate, signal, _ = submitted
assert append.audio == b"\x00\x01" and append.sample_rate_hz == 16000 and append.is_speech is True
assert commit.create_response is True and commit.final is True
assert create.options == {"instructions": "x"}
assert cancel.response_id == "resp-1"
assert ack.played_ms == 120 and ack.committed_ms == 100 and ack.response_id == "resp-1"
assert update.patch == {"temperature": 0.2}
assert item.previous_item_id == "root" and item.item["id"] == "item_1"
assert delete.item_id == "item_1"
assert truncate.audio_end_ms == 50
assert signal.event == "user_started" and signal.signal_payload == {"x": 1}
finally:
omni.shutdown()
@pytest.mark.asyncio
async def test_shutdown_closes_every_handle(monkeypatch) -> None:
omni, engine = _make_omni(monkeypatch)
first = await omni.open_session()
second = await omni.open_session()
omni.shutdown()
assert first.closed and second.closed
assert first.close_reason == "shutdown"
assert omni.sessions == {}
assert engine.shutdown_called
+15 -4
View File
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Unit tests for OmniBase and AsyncOmni profiler methods."""
from types import SimpleNamespace
@@ -28,10 +31,14 @@ class TestOmniBaseProfiler:
@pytest.fixture
def omni_base_instance(self, mock_engine, mocker: MockerFixture):
"""Create an OmniBase instance with mocked dependencies."""
mocker.patch("vllm_omni.entrypoints.omni_base.AsyncOmniEngine", return_value=mock_engine)
from vllm_omni.entrypoints.omni_base import OmniBase
# ``OmniBase`` builds its engine through the ``_create_engine`` seam that
# ``AsyncOmni`` / ``DuplexOmni`` implement; there is no module-level
# engine class to patch any more.
mocker.patch.object(OmniBase, "_create_engine", return_value=mock_engine)
mocker.patch("vllm_omni.entrypoints.omni_base.omni_snapshot_download", side_effect=lambda x: x)
mocker.patch("vllm_omni.entrypoints.omni_base.weakref.finalize")
from vllm_omni.entrypoints.omni_base import OmniBase
instance = OmniBase(model="test-model")
return instance
@@ -48,10 +55,14 @@ class TestOmniBaseProfiler:
def test_removed_diffusion_batch_size_fails_loudly(self, mock_engine, mocker: MockerFixture):
"""Legacy diffusion_batch_size callers receive an actionable error."""
mocker.patch("vllm_omni.entrypoints.omni_base.AsyncOmniEngine", return_value=mock_engine)
from vllm_omni.entrypoints.omni_base import OmniBase
# ``OmniBase`` builds its engine through the ``_create_engine`` seam that
# ``AsyncOmni`` / ``DuplexOmni`` implement; there is no module-level
# engine class to patch any more.
mocker.patch.object(OmniBase, "_create_engine", return_value=mock_engine)
mocker.patch("vllm_omni.entrypoints.omni_base.omni_snapshot_download", side_effect=lambda x: x)
mocker.patch("vllm_omni.entrypoints.omni_base.weakref.finalize")
from vllm_omni.entrypoints.omni_base import OmniBase
with pytest.raises(TypeError, match="diffusion_batch_size.*max_num_seqs"):
OmniBase(model="test-model", diffusion_batch_size=8)
+2 -3
View File
@@ -118,8 +118,6 @@ class FakeAsyncOmniEngine:
self.output_processors = [SimpleNamespace(tokenizer=None) for _ in range(self.num_stages)]
self.input_processor = None
self.endpoint_restrictions = ()
self.duplex_session_config = None
self.duplex_serving_adapter_path = None
self.output_q: queue.Queue[Any] = queue.Queue()
self.submitted: list[dict[str, Any]] = []
@@ -183,7 +181,8 @@ class FakeAsyncOmniEngine:
def _patch_engine(monkeypatch: pytest.MonkeyPatch, engine: FakeAsyncOmniEngine) -> None:
monkeypatch.setattr("vllm_omni.entrypoints.omni_base.AsyncOmniEngine", lambda *args, **kwargs: engine)
monkeypatch.setattr("vllm_omni.entrypoints.omni.AsyncOmniEngine", lambda *args, **kwargs: engine)
monkeypatch.setattr("vllm_omni.entrypoints.async_omni.AsyncOmniEngine", lambda *args, **kwargs: engine)
monkeypatch.setattr("vllm_omni.entrypoints.omni_base.omni_snapshot_download", lambda model: model)
# Don't add random UUIDs to requests calling .generate since we usually
# just want to check for present requests anyway, and would need to just