Merge main and preserve event-driven defaults in shared engine bases

Signed-off-by: Sy03 <1370724210@qq.com>
This commit is contained in:
Sy03 committed 2026-09-16 20:14:33 +08:00
commit d2bb944863
371 files changed
+43235 -38379

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
# 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.engine.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,
)
+453
View File
@@ -0,0 +1,453 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""OpenAI Realtime client events -> typed ``DuplexCommand`` objects and their mailbox payloads."""
from __future__ import annotations
import base64
import numpy as np
import pytest
from vllm_omni.engine.duplex.commands import (
REALTIME_COMMAND_TYPES,
AckPlayback,
AppendAudio,
AppendText,
BargeIn,
CancelInput,
CancelResponse,
ClearInput,
ClearOutputAudio,
CloseSession,
Commit,
CreateItem,
CreateResponse,
DeleteItem,
DuplexCommand,
DuplexCommandError,
Heartbeat,
SignalTurn,
TruncateItem,
UpdateSession,
command_from_realtime,
)
from vllm_omni.engine.duplex.realtime_commands import (
RealtimeInputDefaults,
translate_realtime_command,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
_LOUD_PCM16 = base64.b64encode(b"\x00\x10" * 8).decode("ascii")
_SILENT_PCM16 = base64.b64encode(b"\x00\x00" * 8).decode("ascii")
_JPEG_FRAME = base64.b64encode(b"\xff\xd8\xff\xe0" + b"\x00" * 12).decode("ascii")
_MINIMAL_PAYLOADS: dict[str, tuple[dict[str, object], type[DuplexCommand]]] = {
"input_audio_buffer.append": ({"audio": _LOUD_PCM16}, AppendAudio),
"input_audio_buffer.commit": ({}, Commit),
"input_audio_buffer.clear": ({}, ClearInput),
"output_audio_buffer.clear": ({}, ClearOutputAudio),
"response.create": ({}, CreateResponse),
"response.cancel": ({}, CancelResponse),
"conversation.item.create": ({"item": {"type": "message", "role": "user", "content": []}}, CreateItem),
"conversation.item.delete": ({"item_id": "item_1"}, DeleteItem),
"conversation.item.truncate": ({"item_id": "item_1", "audio_end_ms": 100}, TruncateItem),
"session.update": ({"session": {"instructions": "hi"}}, UpdateSession),
"playback.ack": ({"played_ms": 10}, AckPlayback),
"session.heartbeat": ({}, Heartbeat),
"session.close": ({}, CloseSession),
"turn.signal": ({"event": "user_started"}, SignalTurn),
"input.text.append": ({"text": "hello"}, AppendText),
"input.cancel": ({}, CancelInput),
"barge_in": ({}, BargeIn),
}
def test_every_realtime_command_type_has_a_mapping_case():
assert set(_MINIMAL_PAYLOADS) == set(REALTIME_COMMAND_TYPES)
@pytest.mark.parametrize("event_type", sorted(REALTIME_COMMAND_TYPES))
def test_command_from_realtime_maps_each_type_to_its_dataclass_and_propagates_event_id(event_type: str):
body, expected_cls = _MINIMAL_PAYLOADS[event_type]
command = command_from_realtime({"type": event_type, "event_id": f"evt-{event_type}", **body})
assert type(command) is expected_cls
assert command.event_id == f"evt-{event_type}"
payload = command.payload()
assert payload["type"] == expected_cls.type
assert payload["realtime_event_id"] == f"evt-{event_type}"
assert "event_id" not in payload
anonymous = command_from_realtime({"type": event_type, **body})
assert anonymous.event_id is None
assert "realtime_event_id" not in anonymous.payload()
@pytest.mark.parametrize(
("alias", "expected_cls", "body"),
[
("push_text", AppendText, {"text": "hello"}),
("input_text.append", AppendText, {"text": "hello"}),
("signal_turn", SignalTurn, {"event": "user_started"}),
("close_session", CloseSession, {}),
("close", CloseSession, {}),
("audio.playback_ack", AckPlayback, {"played_ms": 10}),
("input.commit", Commit, {}),
],
)
def test_pre_realtime_client_event_aliases_map_to_their_canonical_command(
alias: str, expected_cls: type[DuplexCommand], body: dict[str, object]
):
assert type(command_from_realtime({"type": alias, **body})) is expected_cls
def test_append_audio_decodes_and_converts_pcm16_to_16k_float32():
command = command_from_realtime(
{"type": "input_audio_buffer.append", "audio": _LOUD_PCM16, "format": "pcm16", "sample_rate_hz": 16000}
)
assert isinstance(command, AppendAudio)
assert command.format == "pcm_f32le"
assert command.sample_rate_hz == 16000
samples = np.frombuffer(command.audio, dtype="<f4")
assert samples.shape == (8,)
assert np.allclose(samples, 4096 / 32768.0)
assert command.is_speech is True
assert command.video_frames == ()
assert command.duration_ms is None
assert command.audio_end_ms is None
def test_append_audio_defaults_come_from_the_session_payload():
defaults = RealtimeInputDefaults().with_session_payload({"input_audio_format": "pcm16", "sample_rate_hz": 8000})
assert defaults.input_audio_format == "pcm16"
assert defaults.input_sample_rate_hz == 8000
command = translate_realtime_command({"type": "input_audio_buffer.append", "audio": _LOUD_PCM16}, defaults=defaults)
assert isinstance(command, AppendAudio)
assert command.format == "pcm_f32le"
# 8 kHz input is resampled to the model's 16 kHz float stream.
assert command.sample_rate_hz == 16000
assert len(command.audio) > 8 * 4
passthrough = translate_realtime_command(
{"type": "input_audio_buffer.append", "audio": base64.b64encode(np.zeros(4, dtype="<f4").tobytes()).decode()},
defaults=RealtimeInputDefaults(input_audio_format="pcm_f32le", input_sample_rate_hz=16000),
)
assert passthrough.format == "pcm_f32le"
assert passthrough.audio == np.zeros(4, dtype="<f4").tobytes()
def test_append_audio_speech_classification_uses_hints_then_rms():
silent = command_from_realtime({"type": "input_audio_buffer.append", "audio": _SILENT_PCM16})
assert silent.is_speech is False
forced = command_from_realtime({"type": "input_audio_buffer.append", "audio": _SILENT_PCM16, "is_speech": True})
assert forced.is_speech is True
assert forced.hints == {"is_speech": True}
vad = command_from_realtime(
{"type": "input_audio_buffer.append", "audio": _LOUD_PCM16, "vad": {"speech_probability": 0.1}}
)
assert vad.is_speech is False
assert vad.hints["vad"] == {"speech_probability": 0.1}
def test_append_audio_carries_wire_hints_and_video_frames():
command = command_from_realtime(
{
"type": "input_audio_buffer.append",
"event_id": "evt-append",
"audio": _LOUD_PCM16,
"transcript": "hi there",
"duration_ms": 1000,
"audio_end_ms": 3000,
"video_frames": [_JPEG_FRAME],
"unknown_key": "dropped",
}
)
assert isinstance(command, AppendAudio)
assert command.duration_ms == 1000
assert command.audio_end_ms == 3000
assert command.video_frames == (_JPEG_FRAME,)
assert dict(command.hints) == {"transcript": "hi there", "duration_ms": 1000, "audio_end_ms": 3000}
payload = command.payload()
assert payload["type"] == "input_audio_buffer.append"
assert payload["realtime_event_id"] == "evt-append"
assert payload["audio"] == base64.b64encode(command.audio).decode("ascii")
assert payload["format"] == "pcm_f32le"
assert payload["sample_rate_hz"] == 16000
assert payload["is_speech"] is True
assert payload["transcript"] == "hi there"
assert payload["duration_ms"] == 1000
assert payload["audio_end_ms"] == 3000
assert payload["video_frames"] == [_JPEG_FRAME]
assert "hints" not in payload
assert "unknown_key" not in payload
def test_append_audio_payload_re_encodes_bytes_and_flattens_hints():
command = AppendAudio(audio=b"\x01\x02", hints={"vad": {"is_speech": True}}, is_speech=True)
assert command.payload() == {
"type": "input_audio_buffer.append",
"audio": "AQI=",
"format": "pcm16",
"is_speech": True,
"vad": {"is_speech": True},
}
@pytest.mark.parametrize(
("body", "code"),
[
({"audio": _LOUD_PCM16, "format": "mp3"}, "unsupported_audio_format"),
({"audio": "@@@@", "format": "pcm_f32le"}, "bad_audio"),
({"audio": _LOUD_PCM16, "video_frames": ["aGVsbG8="]}, "invalid_video_frames"),
({"audio": _LOUD_PCM16, "video_frames": [_JPEG_FRAME], "max_slice_nums": 4}, "invalid_video_frames"),
({"audio": _LOUD_PCM16, "format": "pcm16", "sample_rate_hz": 3}, "bad_event"),
],
)
def test_malformed_append_raises_command_error_with_code(body: dict[str, object], code: str):
with pytest.raises(DuplexCommandError) as excinfo:
command_from_realtime({"type": "input_audio_buffer.append", "event_id": "evt-bad", **body})
assert excinfo.value.code == code
assert excinfo.value.event_id == "evt-bad"
# ---- other malformed payloads ----
@pytest.mark.parametrize(
("payload", "code"),
[
({}, "bad_event"),
({"type": 7}, "bad_event"),
({"type": "session.resume", "session_id": "s", "resume_token": "t"}, "unknown_event"),
({"type": "session.event_ack", "server_event_seq": 1}, "unknown_event"),
({"type": "conversation.item.frobnicate", "item_id": "item_1"}, "unknown_event"),
({"type": "conversation.item.create"}, "bad_event"),
(
{
"type": "conversation.item.create",
"item": {"type": "message", "role": "user", "content": [{"type": "input_audio", "format": "mp3"}]},
},
"unsupported_audio_format",
),
({"type": "conversation.item.delete"}, "missing_item_id"),
({"type": "conversation.item.truncate", "audio_end_ms": 1}, "missing_item_id"),
({"type": "conversation.item.truncate", "item_id": "item_1"}, "bad_event"),
({"type": "session.update", "session": {"output_audio_format": "opus"}}, "unsupported_audio_format"),
(
{"type": "session.update", "session": {"turn_detection": {"type": "semantic_vad"}}},
"unsupported_turn_detection",
),
({"type": "response.create", "response": {"output_audio_format": "opus"}}, "unsupported_audio_format"),
({"type": "playback.ack"}, "bad_event"),
({"type": "input.text.append"}, "bad_event"),
({"type": "turn.signal"}, "bad_event"),
({"type": "turn.signal", "event": "conversation.item.delete", "payload": {}}, "missing_item_id"),
({"type": "turn.signal", "event": "conversation.item.truncate", "payload": {"item_id": "i"}}, "bad_event"),
],
)
def test_malformed_payloads_raise_command_error_with_code(payload: dict[str, object], code: str):
with pytest.raises(DuplexCommandError) as excinfo:
command_from_realtime({"event_id": "evt-bad", **payload})
assert excinfo.value.code == code
assert excinfo.value.event_id == "evt-bad"
# ---- payload() rendering ----
def test_commit_maps_create_response_to_response_create():
command = command_from_realtime(
{"type": "input_audio_buffer.commit", "event_id": "evt-commit", "response_create": True, "final": False}
)
assert command == Commit(event_id="evt-commit", final=False, create_response=True)
assert command.payload() == {
"type": "input_audio_buffer.commit",
"realtime_event_id": "evt-commit",
"final": False,
"response_create": True,
}
assert Commit().payload() == {"type": "input_audio_buffer.commit", "final": True}
assert Commit(is_speech=False, realtime_item_id="item_1").payload() == {
"type": "input_audio_buffer.commit",
"final": True,
"is_speech": False,
"realtime_item_id": "item_1",
}
assert (
command_from_realtime({"type": "input_audio_buffer.commit", "create_response": False}).create_response is False
)
def test_create_response_renders_options_as_response_object():
command = command_from_realtime({"type": "response.create", "response": {"modalities": ["text"]}})
assert isinstance(command, CreateResponse)
assert dict(command.options) == {"modalities": ["text"]}
assert command.payload() == {"type": "response.create", "response": {"modalities": ["text"]}}
assert command_from_realtime({"type": "response.create"}).payload() == {"type": "response.create", "response": {}}
def test_cancel_and_clear_commands_keep_optional_response_id():
assert command_from_realtime({"type": "response.cancel", "response_id": ""}).response_id is None
cancel = command_from_realtime({"type": "response.cancel", "response_id": "resp_1"})
assert cancel == CancelResponse(response_id="resp_1")
assert cancel.payload() == {"type": "response.cancel", "response_id": "resp_1"}
clear = command_from_realtime({"type": "output_audio_buffer.clear", "response_id": "resp_2"})
assert clear == ClearOutputAudio(response_id="resp_2")
assert ClearInput().payload() == {"type": "input_audio_buffer.clear"}
def test_update_session_renders_as_turn_signal_payload():
command = command_from_realtime(
{"type": "session.update", "session": {"instructions": "be brief", "voice": "alloy"}}
)
assert command == UpdateSession(patch={"instructions": "be brief", "voice": "alloy"})
assert command.payload() == {
"type": "turn.signal",
"event": "session.update",
"payload": {"instructions": "be brief", "voice": "alloy"},
}
def test_create_item_normalizes_item_and_renders_as_turn_signal_payload():
command = command_from_realtime(
{
"type": "conversation.item.create",
"previous_item_id": "item_0",
"item": {"type": "message", "role": "assistant", "content": [{"type": "text", "text": "hi"}]},
}
)
assert isinstance(command, CreateItem)
assert command.previous_item_id == "item_0"
assert command.item["id"].startswith("item_")
assert command.item["object"] == "realtime.item"
assert command.item["status"] == "completed"
payload = command.payload()
assert payload["type"] == "turn.signal"
assert payload["event"] == "conversation.item.create"
assert payload["payload"] == {"item": dict(command.item), "previous_item_id": "item_0"}
assert "item" not in payload
assert "previous_item_id" not in payload
without_previous = command_from_realtime({"type": "conversation.item.create", "item": {"content": "x"}})
assert without_previous.item["role"] == "user"
assert without_previous.item["content"] == []
assert without_previous.payload()["payload"] == {"item": dict(without_previous.item)}
def test_delete_and_truncate_render_as_turn_signal_payloads():
delete = command_from_realtime({"type": "conversation.item.delete", "item_id": "item_1"})
assert delete == DeleteItem(item_id="item_1")
assert delete.payload() == {
"type": "turn.signal",
"event": "conversation.item.delete",
"payload": {"item_id": "item_1"},
}
truncate = command_from_realtime(
{"type": "conversation.item.truncate", "item_id": "item_1", "audio_end_ms": 1500.0, "content_index": 1}
)
assert truncate == TruncateItem(item_id="item_1", audio_end_ms=1500, content_index=1)
assert truncate.payload() == {
"type": "turn.signal",
"event": "conversation.item.truncate",
"payload": {"item_id": "item_1", "audio_end_ms": 1500, "content_index": 1},
}
def test_playback_ack_close_and_text_append_payloads():
ack = command_from_realtime(
{"type": "playback.ack", "played_ms": 1200.0, "committed_ms": 1000, "response_id": "resp_1", "item_id": ""}
)
assert ack == AckPlayback(played_ms=1200, committed_ms=1000, response_id="resp_1")
assert ack.payload() == {
"type": "playback.ack",
"played_ms": 1200,
"committed_ms": 1000,
"response_id": "resp_1",
}
assert command_from_realtime({"type": "session.close"}) == CloseSession(reason="client_close")
assert command_from_realtime({"type": "session.close", "reason": "done"}).payload() == {
"type": "session.close",
"reason": "done",
}
assert command_from_realtime({"type": "input.text.append", "text": "hello"}).payload() == {
"type": "input.text.append",
"text": "hello",
}
assert Heartbeat().payload() == {"type": "session.heartbeat"}
def test_turn_signal_renders_payload_only_when_present_and_dispatches_known_events():
signal = command_from_realtime({"type": "turn.signal", "event": "user_started", "payload": {"source": "client"}})
assert signal == SignalTurn(event="user_started", signal_payload={"source": "client"})
assert signal.payload() == {"type": "turn.signal", "event": "user_started", "payload": {"source": "client"}}
assert command_from_realtime({"type": "turn.signal", "event": "user_started"}).payload() == {
"type": "turn.signal",
"event": "user_started",
}
assert command_from_realtime({"type": "turn.signal", "event": "barge_in"}) == BargeIn()
assert command_from_realtime({"type": "turn.signal", "event": "input.cancel"}) == CancelInput()
assert command_from_realtime(
{"type": "turn.signal", "event": "response.cancel", "payload": {"response_id": "resp_1"}}
) == CancelResponse(response_id="resp_1")
assert command_from_realtime(
{"type": "turn.signal", "event": "session.update", "payload": {"voice": "alloy"}}
) == UpdateSession(patch={"voice": "alloy"})
assert command_from_realtime(
{"type": "turn.signal", "event": "conversation.item.delete", "payload": {"item_id": "item_1"}}
) == DeleteItem(item_id="item_1")
assert command_from_realtime(
{"type": "turn.signal", "event": "conversation.item.truncate", "payload": {"item_id": "i", "audio_end_ms": 5}}
) == TruncateItem(item_id="i", audio_end_ms=5)
created = command_from_realtime(
{"type": "turn.signal", "event": "conversation.item.create", "payload": {"item": {"role": "system"}}}
)
assert isinstance(created, CreateItem)
assert created.item["role"] == "system"
def test_raw_wire_hints_cannot_override_the_normalized_typed_fields():
"""``build_append_audio`` normalizes; a client's raw value must not undo it.
A wire ``"is_speech": 0`` used to land in ``hints`` and overwrite the
computed ``bool | None`` in the rendered payload, so the runner's
silent-commit fast path (``event.get("is_speech") is False``) missed and
overlap classification followed the unvalidated value.
"""
command = AppendAudio(
audio=b"\x00\x00" * 8,
is_speech=False,
hints={"is_speech": 0, "rms": 0.001},
)
payload = command.payload()
assert payload["is_speech"] is False, "the normalized field wins over the raw hint"
assert payload["rms"] == 0.001, "a hint with no typed counterpart still comes through"
def test_a_hint_survives_when_its_typed_field_is_unset():
"""Unset typed fields are absent from the payload, so the hint is the only value."""
command = AppendAudio(audio=b"\x00\x00" * 8, hints={"is_speech": True})
assert command.payload()["is_speech"] is True
@@ -1,110 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import pytest
from vllm_omni.engine.duplex.control_client import (
DuplexControlClient,
DuplexControlRequestError,
)
from vllm_omni.engine.duplex.lease import DuplexLeaseActivity
from vllm_omni.engine.duplex.messages import (
DuplexControlError,
DuplexControlResultMessage,
DuplexFence,
ResumeDuplexSessionMessage,
TouchDuplexSessionMessage,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class _Transport:
def __init__(self) -> None:
self.calls = []
def execute(self, key, message, **kwargs):
self.calls.append((key, message, kwargs))
return DuplexControlResultMessage(
control_id=message.control_id,
fence=message.fence,
operation="touch" if isinstance(message, TouchDuplexSessionMessage) else "resume",
session_id=message.session_id,
ok=True,
stage_results=[],
)
class _UnsupportedTransport:
def execute(self, key, message, **kwargs):
del key, kwargs
return DuplexControlResultMessage(
control_id=message.control_id,
fence=message.fence,
operation="touch",
session_id=message.session_id,
ok=False,
stage_results=[{"result": {"supported": False}}],
unsupported_count=1,
error=DuplexControlError(code="invalid_capability", message="unsupported", retryable=False),
accepted_fence=DuplexFence(message.session_id, epoch=2),
lease_generation=7,
)
def test_control_client_routes_touch_and_resume_by_control_id() -> None:
transport = _Transport()
control_ids = iter(("touch-id", "resume-id"))
client = DuplexControlClient(transport, control_id_factory=lambda: next(control_ids))
fence = DuplexFence("sid-client")
assert (
client.touch(
fence.session_id,
fence=fence,
activity=DuplexLeaseActivity.PLAYBACK_ACK,
timeout=2.0,
)["ok"]
is True
)
assert (
client.resume(
fence.session_id,
fence=fence,
expected_lease_generation=7,
timeout=3.0,
)["ok"]
is True
)
touch_key, touch_message, touch_kwargs = transport.calls[0]
assert touch_key == ("duplex", "touch-id")
assert isinstance(touch_message, TouchDuplexSessionMessage)
assert touch_message.activity == DuplexLeaseActivity.PLAYBACK_ACK.value
assert touch_kwargs["timeout"] == 2.0
resume_key, resume_message, resume_kwargs = transport.calls[1]
assert resume_key == ("duplex", "resume-id")
assert isinstance(resume_message, ResumeDuplexSessionMessage)
assert resume_message.expected_lease_generation == 7
assert resume_kwargs["timeout"] == 3.0
def test_control_client_treats_ok_false_as_authoritative_failure() -> None:
client = DuplexControlClient(_UnsupportedTransport(), control_id_factory=lambda: "unsupported")
fence = DuplexFence("sid-unsupported")
with pytest.raises(DuplexControlRequestError, match="duplex touch failed") as exc_info:
client.touch(
fence.session_id,
fence=fence,
activity=DuplexLeaseActivity.HEARTBEAT,
timeout=1.0,
)
assert exc_info.value.code == "invalid_capability"
assert exc_info.value.retryable is False
assert exc_info.value.accepted_fence == DuplexFence(fence.session_id, epoch=2)
assert exc_info.value.lease_generation == 7
File diff suppressed because it is too large. Load diff
+386
View File
@@ -0,0 +1,386 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Typed duplex events: pure ``to_realtime()`` rendering and the stateful Realtime projection."""
from __future__ import annotations
import base64
import inspect
import pytest
from vllm_omni.engine.duplex import events as events_module
from vllm_omni.engine.duplex.events import (
REALTIME_ERROR_TYPES_BY_CODE,
AudioDelta,
DuplexEvent,
DuplexRawEvent,
ErrorEvent,
InputCommitted,
ItemAdded,
ItemCreated,
ItemDone,
Listen,
OutputItemAdded,
OverlapDecision,
ResponseDone,
SessionClosed,
SessionCreated,
SessionExpired,
SessionHeartbeatAck,
SessionReplaced,
SessionResumed,
SessionResyncRequired,
TextDelta,
TranscriptDone,
TurnEvent,
error_event,
)
from vllm_omni.engine.duplex.realtime_events import (
RealtimeProjectionState,
project_internal_event,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
#: Classes whose wire object carries ``session_id`` by contract.
_SESSION_ID_ON_WIRE = {
SessionClosed,
SessionExpired,
SessionHeartbeatAck,
Listen,
TurnEvent,
OverlapDecision,
SessionResumed,
SessionReplaced,
SessionResyncRequired,
}
#: Classes whose wire object carries ``epoch`` by contract.
_EPOCH_ON_WIRE = {Listen, TurnEvent, OverlapDecision}
def _public_event_classes() -> list[type[DuplexEvent]]:
classes = [
getattr(events_module, name)
for name in events_module.__all__
if inspect.isclass(getattr(events_module, name)) and issubclass(getattr(events_module, name), DuplexEvent)
]
assert len(classes) > 40
return [cls for cls in classes if cls is not DuplexEvent]
@pytest.mark.parametrize("event_cls", _public_event_classes(), ids=lambda cls: cls.__name__)
def test_to_realtime_renders_wire_type_event_id_and_documented_fields(event_cls: type[DuplexEvent]):
event = event_cls(session_id="duplex-hidden", epoch=7)
wire = event.to_realtime()
assert wire["type"] == event.type
assert wire["event_id"] == event.event_id
assert wire["event_id"].startswith("event_")
if event_cls is DuplexRawEvent:
assert wire["type"] == "duplex."
else:
assert wire["type"] == event_cls.wire_type
if event_cls in _SESSION_ID_ON_WIRE:
assert wire["session_id"] == "duplex-hidden"
else:
assert "session_id" not in wire
if event_cls in _EPOCH_ON_WIRE:
assert wire["epoch"] == 7
else:
assert "epoch" not in wire
# Every value is plain JSON (mappings / sequences are copied into dict / list).
for value in wire.values():
assert isinstance(value, str | int | float | bool | dict | list) or value is None
def test_optional_wire_fields_are_omitted_when_none():
created = SessionCreated(session={"id": "sid"})
assert created.optional_wire_fields == frozenset({"attachment_generation", "resume_token"})
wire = created.to_realtime()
assert "attachment_generation" not in wire
assert "resume_token" not in wire
assert wire["session"] == {"id": "sid"}
with_resume = SessionCreated(session={"id": "sid"}, attachment_generation=2, resume_token="tok").to_realtime()
assert with_resume["attachment_generation"] == 2
assert with_resume["resume_token"] == "tok"
delta = AudioDelta(response_id="resp_1", item_id="item_resp_1", delta="AAAA").to_realtime()
assert "sample_rate_hz" not in delta
assert "metadata" not in delta
assert delta["format"] == "pcm16"
assert delta["output_index"] == 0
assert delta["content_index"] == 0
anonymous_listen = Listen(session_id="sid", epoch=0, details={"reason": "silence"}).to_realtime()
assert "response_id" not in anonymous_listen
assert "id" not in anonymous_listen["response"]
assert anonymous_listen["response"]["status"] == "listening"
assert anonymous_listen["response"]["metadata"] == {"reason": "silence"}
bound_listen = Listen(session_id="sid", epoch=0, response_id="resp_1").to_realtime()
assert bound_listen["response_id"] == "resp_1"
assert bound_listen["response"]["id"] == "resp_1"
def test_error_event_maps_codes_to_openai_error_types_and_echoes_client_event_id():
assert REALTIME_ERROR_TYPES_BY_CODE["resource_exhausted"] == "rate_limit_error"
assert REALTIME_ERROR_TYPES_BY_CODE["internal_error"] == "server_error"
assert REALTIME_ERROR_TYPES_BY_CODE["bad_event"] == "invalid_request_error"
error = ErrorEvent(
code="resource_exhausted",
message="too many sessions",
related_event_id="evt_client",
param="session",
extra={"retryable": True},
)
assert error.error_type == "rate_limit_error"
assert error.text == "too many sessions"
assert error.error == {
"type": "rate_limit_error",
"code": "resource_exhausted",
"message": "too many sessions",
"event_id": "evt_client",
"param": "session",
"retryable": True,
}
wire = error.to_realtime()
assert wire["type"] == "error"
assert wire["error"] == error.error
# The server event id and the echoed client event id are distinct.
assert wire["event_id"] != "evt_client"
assert ErrorEvent(code="never-seen-code").error_type == "invalid_request_error"
def test_error_event_helper_normalizes_optional_client_event_id_and_param():
event = error_event("bad_event", "nope", event_id="evt_1", param="item", extra={"retryable": False})
assert isinstance(event, ErrorEvent)
assert event.related_event_id == "evt_1"
assert event.param == "item"
assert event.error["retryable"] is False
anonymous = error_event("bad_event", "nope", event_id=42, param="")
assert anonymous.related_event_id is None
assert anonymous.param is None
assert "event_id" not in anonymous.error
assert "param" not in anonymous.error
def test_audio_delta_decodes_base64_and_invalid_payloads_yield_none():
raw = bytes(range(16))
delta = AudioDelta(delta=base64.b64encode(raw).decode("ascii"), format="pcm16", sample_rate_hz=24000)
assert delta.audio == raw
assert delta.to_realtime()["sample_rate_hz"] == 24000
assert AudioDelta(delta="@@not base64@@").audio is None
def test_session_closed_and_expired_are_the_only_terminal_events():
closed = SessionClosed(session_id="sid", reason="client_close", details={"type": "session.closed"})
expired = SessionExpired(session_id="sid", reason="idle_timeout")
assert closed.is_terminal is True
assert expired.is_terminal is True
assert closed.to_realtime() == {
"type": "session.closed",
"event_id": closed.event_id,
"session_id": "sid",
"reason": "client_close",
"event": {"type": "session.closed"},
}
assert expired.to_realtime() == {
"type": "session.expired",
"event_id": expired.event_id,
"session_id": "sid",
"reason": "idle_timeout",
}
non_terminal = [cls for cls in _public_event_classes() if cls().is_terminal]
assert non_terminal == [SessionClosed, SessionExpired]
def test_generic_accessors_expose_response_item_text_and_status():
assert TextDelta(response_id="resp_1", item_id="item_resp_1", delta="hi").text == "hi"
assert TranscriptDone(transcript="done").text == "done"
assert OutputItemAdded(item={"id": "item_1"}).item_id == "item_1"
assert ItemAdded(item={"id": 3}).item_id is None
assert ResponseDone(response={"status": "cancelled"}).status == "cancelled"
assert ResponseDone().status is None
assert TurnEvent(event="user_started", turn_state="user_speaking").response_id is None
assert TurnEvent().audio is None
def test_raw_event_prefixes_internal_type_and_wraps_details():
raw = DuplexRawEvent(internal_type="model.custom", details={"type": "model.custom", "value": (1, 2)})
assert raw.type == "duplex.model.custom"
assert raw.to_realtime() == {
"type": "duplex.model.custom",
"event_id": raw.event_id,
"event": {"type": "model.custom", "value": [1, 2]},
}
def test_turn_event_and_overlap_decision_carry_session_identity_on_the_wire():
turn = TurnEvent(session_id="sid", epoch=2, event="barge_in", turn_state="barge_in").to_realtime()
assert turn == {
"type": "turn.event",
"event_id": turn["event_id"],
"session_id": "sid",
"event": "barge_in",
"turn_state": "barge_in",
"epoch": 2,
}
decision = OverlapDecision(
session_id="sid", epoch=2, policy="listen_only", action="ignore", reason="short", details={"ms": 300}
).to_realtime()
assert decision["policy"] == "listen_only"
assert decision["action"] == "ignore"
assert decision["metadata"] == {"ms": 300}
assert decision["session_id"] == "sid"
# ---- RealtimeProjectionState / project_internal_event ----
def _types(events: list[DuplexEvent]) -> list[str]:
return [event.type for event in events]
def test_projection_of_the_main_internal_event_sequence():
state = RealtimeProjectionState(session_id="duplex-proj", model="test-model", initial_session_update=True)
pcm = base64.b64encode(b"\x00\x10" * 8).decode("ascii")
created = project_internal_event(state, {"type": "session.created", "session": {"voice": "alloy"}})
assert _types(created) == ["session.created", "session.updated"]
assert isinstance(created[0], SessionCreated)
session_payload = created[0].session
assert session_payload["id"] == "duplex-proj"
assert session_payload["model"] == "test-model"
assert session_payload["voice"] == "alloy"
assert session_payload["input_audio_format"] == "pcm16"
assert session_payload["output_audio_format"] == "pcm16"
assert state.initial_session_update is False
# The extra session.updated is a one-shot for the opening session.update.
assert _types(project_internal_event(state, {"type": "session.created", "session": {}})) == ["session.created"]
response_created = project_internal_event(
state, {"type": "response.created", "response_id": "resp_1", "modalities": ["audio", "text"]}
)
assert _types(response_created) == [
"response.created",
"conversation.item.added",
"conversation.item.created",
"response.output_item.added",
"response.content_part.added",
]
assert state.active_response_id == "resp_1"
assert response_created[0].response_id == "resp_1"
assert response_created[0].to_realtime()["response"]["status"] == "in_progress"
assert isinstance(response_created[1], ItemAdded)
assert isinstance(response_created[2], ItemCreated)
assert response_created[1].item_id == "item_resp_1"
assert response_created[3].item_id == "item_resp_1"
assert response_created[4].to_realtime()["part"] == {"type": "audio", "transcript": ""}
audio_delta = project_internal_event(
state,
{"type": "response.output_audio.delta", "response_id": "resp_1", "audio": pcm, "text": "hi", "format": "pcm16"},
)
assert _types(audio_delta) == ["response.output_audio.delta", "response.output_audio_transcript.delta"]
assert isinstance(audio_delta[0], AudioDelta)
assert audio_delta[0].delta == pcm
assert audio_delta[0].format == "pcm16"
assert audio_delta[0].item_id == "item_resp_1"
assert audio_delta[1].text == "hi"
assert state.conversation_items["item_resp_1"]["content"][0]["transcript"] == "hi"
done = project_internal_event(state, {"type": "response.done", "response_id": "resp_1"})
assert _types(done) == [
"response.output_audio.done",
"response.output_audio_transcript.done",
"response.content_part.done",
"response.output_item.done",
"conversation.item.done",
"response.done",
"rate_limits.updated",
]
assert done[1].text == "hi"
assert isinstance(done[4], ItemDone)
assert done[4].item["status"] == "completed"
assert isinstance(done[5], ResponseDone)
assert done[5].status == "completed"
assert done[5].response["output"][0]["content"][0] == {"type": "output_audio", "transcript": "hi"}
assert state.active_response_id is None
# A duplicate terminal for the same response is idempotent.
assert project_internal_event(state, {"type": "response.done", "response_id": "resp_1"}) == []
committed = project_internal_event(
state, {"type": "input.committed", "message": {"role": "user", "content": "hello"}, "input_commit_seq": 1}
)
assert _types(committed) == [
"conversation.item.added",
"conversation.item.created",
"input_audio_buffer.committed",
"conversation.item.done",
]
assert isinstance(committed[2], InputCommitted)
assert committed[2].previous_item_id == "item_resp_1"
assert committed[2].item_id == committed[0].item_id
assert committed[2].to_realtime()["event"]["input_commit_seq"] == 1
assert committed[0].item["content"] == [{"type": "input_text", "text": "hello"}]
project_internal_event(state, {"type": "response.created", "response_id": "resp_2"})
project_internal_event(state, {"type": "response.output_audio.delta", "response_id": "resp_2", "audio": pcm})
cancelled = project_internal_event(
state, {"type": "audio.cancelled", "response_id": "resp_2", "reason": "barge_in", "committed_ms": 0}
)
assert _types(cancelled) == [
"response.output_audio.done",
"response.content_part.done",
"response.output_item.done",
"conversation.item.done",
"response.done",
"rate_limits.updated",
]
assert cancelled[4].status == "cancelled"
assert cancelled[4].response["status_details"] == {"type": "cancelled", "reason": "barge_in"}
assert state.active_response_id is None
closed = project_internal_event(state, {"type": "session.closed", "reason": "client_close"})
assert _types(closed) == ["session.closed"]
assert closed[0].is_terminal is True
assert closed[0].to_realtime()["reason"] == "client_close"
def test_projection_of_output_audio_buffer_clear_emits_cleared_before_terminals():
state = RealtimeProjectionState(session_id="duplex-clear")
pcm = base64.b64encode(b"\x00\x10" * 8).decode("ascii")
project_internal_event(state, {"type": "response.created", "response_id": "resp_1"})
project_internal_event(state, {"type": "response.output_audio.delta", "response_id": "resp_1", "audio": pcm})
cleared = project_internal_event(
state, {"type": "audio.cancelled", "reason": "output_audio_buffer_clear", "committed_ms": 250}
)
assert _types(cleared)[:2] == ["output_audio_buffer.cleared", "response.output_audio.done"]
assert cleared[0].response_id == "resp_1"
assert _types(cleared)[-2:] == ["response.done", "rate_limits.updated"]
assert state.item_truncation_cursors["item_resp_1"] == (0, 250)
def test_projection_leaves_error_to_typed_emit_sites():
# Errors are constructed typed (``error_event``) at the emit site; the projector
# has no ``error`` branch and falls back to the raw wrapper for such a dict.
state = RealtimeProjectionState(session_id="duplex-err")
projected = project_internal_event(state, {"type": "error", "code": "bad_event", "error": "nope"})
assert len(projected) == 1
assert isinstance(projected[0], DuplexRawEvent)
assert projected[0].type == "duplex.error"
assert projected[0].to_realtime()["event"]["code"] == "bad_event"
+81 -125
View File
@@ -1,19 +1,22 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Lease semantics of the engine-resident duplex session (idle TTL, disconnect grace, resume CAS)."""
from __future__ import annotations
from dataclasses import FrozenInstanceError
import pytest
from vllm_omni.engine.duplex.lease import (
from vllm_omni.engine.duplex.config import DuplexSessionConfig
from vllm_omni.engine.duplex.contracts import DuplexFence
from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession, DuplexFenceMismatchError
from vllm_omni.engine.duplex.session.lease import (
DuplexLeaseActivity,
DuplexLeaseConfig,
DuplexLeaseState,
)
from vllm_omni.engine.duplex.messages import DuplexFence
from vllm_omni.engine.duplex.runtime import DuplexInputMode
from vllm_omni.engine.duplex.session import DuplexSessionRuntimeManager
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -30,195 +33,148 @@ class FakeMonotonicClock:
def _lease_config(*, idle_ttl_s: float | None = 300.0) -> DuplexLeaseConfig:
return DuplexLeaseConfig(
idle_ttl_s=idle_ttl_s,
disconnect_grace_s=30.0,
return DuplexLeaseConfig(idle_ttl_s=idle_ttl_s, disconnect_grace_s=30.0)
def _session(session_id: str, clock: FakeMonotonicClock, *, idle_ttl_s: float | None = 300.0) -> DuplexEngineSession:
return DuplexEngineSession(
session_id=session_id,
config=DuplexSessionConfig(),
lease=DuplexLeaseState(config=_lease_config(idle_ttl_s=idle_ttl_s), generation=0, last_activity=clock()),
_clock=clock,
)
def test_open_touch_detach_and_idle_expiry_use_monotonic_time() -> None:
def test_open_touch_detach_and_expiry_use_monotonic_time() -> None:
clock = FakeMonotonicClock(100.0)
manager = DuplexSessionRuntimeManager(clock=clock)
fence = DuplexFence("sid-expiry")
session = manager.open_session(fence, lease_config=_lease_config())
session = _session("sid-expiry", clock)
assert session.lease.last_activity == 100.0
clock.advance(10.0)
session.touch(fence, DuplexLeaseActivity.HEARTBEAT)
session.detach(fence)
session.touch_lease(DuplexLeaseActivity.HEARTBEAT)
assert session.lease.last_activity == 110.0
session.detach_lease()
clock.advance(29.0)
assert session.lease.disconnect_grace_expired(clock()) is False
assert manager.collect_expired() == []
assert session.lease.idle_expired(clock()) is False
clock.advance(272.0)
expired = manager.collect_expired()
clock.advance(1.0)
assert session.lease.disconnect_grace_expired(clock()) is True
assert [item.session_id for item in expired] == ["sid-expiry"]
assert expired[0].reason == "disconnect_grace_expired"
assert manager.get("sid-expiry") is session
manager.finalize_close_session(session)
assert manager.get("sid-expiry") is None
clock.advance(271.0)
assert session.lease.idle_expired(clock()) is True
def test_detach_grace_and_resume_advance_token_independent_lease_generation() -> None:
def test_detach_grace_and_resume_advance_the_lease_generation() -> None:
clock = FakeMonotonicClock(10.0)
manager = DuplexSessionRuntimeManager(clock=clock)
fence = DuplexFence("sid-resume")
session = manager.open_session(fence, lease_config=_lease_config())
session = _session("sid-resume", clock)
session.detach(fence)
session.detach_lease()
clock.advance(29.0)
assert session.lease.disconnect_grace_expired(clock()) is False
clock.advance(2.0)
assert session.lease.disconnect_grace_expired(clock()) is True
generation = session.resume(fence, expected_lease_generation=0)
generation = session.resume_lease(expected_lease_generation=0)
assert generation == 1
assert session.lease_generation == 1
assert session.lease.detached_at is None
assert session.lease.disconnect_grace_expired(clock()) is False
with pytest.raises(ValueError, match="lease generation mismatch"):
session.resume(fence, expected_lease_generation=0)
session.resume_lease(expected_lease_generation=0)
def test_active_operation_prevents_mid_transaction_expiry() -> None:
clock = FakeMonotonicClock(0.0)
manager = DuplexSessionRuntimeManager(clock=clock)
fence = DuplexFence("sid-operation")
session = manager.open_session(fence, lease_config=_lease_config(idle_ttl_s=5.0))
session = _session("sid-operation", clock, idle_ttl_s=5.0)
session.begin_operation(fence, "append-1")
session.begin_lease_operation(session.fence, "append-1")
clock.advance(10.0)
assert manager.collect_expired() == []
assert session.lease.idle_expired(clock()) is False
session.end_operation(fence, "append-1")
assert manager.collect_expired() == []
session.end_lease_operation("append-1")
assert session.lease.idle_expired(clock()) is False
clock.advance(6.0)
assert [item.session_id for item in manager.collect_expired()] == ["sid-operation"]
assert session.lease.idle_expired(clock()) is True
def test_sessions_have_independent_activity_deadlines_and_resources() -> None:
clock = FakeMonotonicClock(0.0)
manager = DuplexSessionRuntimeManager(clock=clock)
fence_a = DuplexFence("sid-a")
fence_b = DuplexFence("sid-b")
session_a = manager.open_session(fence_a, lease_config=_lease_config(idle_ttl_s=10.0))
session_b = manager.open_session(fence_b, lease_config=_lease_config(idle_ttl_s=10.0))
session_a.reserve_stage_request(0, "req-a-reserved", fence=fence_a)
session_a.bind_stage_request(1, "req-a-submitted", fence=fence_a)
session_b.bind_stage_request(0, "req-b", fence=fence_b)
session_a = _session("sid-a", clock, idle_ttl_s=10.0)
session_b = _session("sid-b", clock, idle_ttl_s=10.0)
session_a.reserve_stage_request(0, "req-a-reserved", fence=session_a.fence)
session_a.bind_stage_request(1, "req-a-submitted", fence=session_a.fence)
session_b.bind_stage_request(0, "req-b", fence=session_b.fence)
clock.advance(6.0)
session_b.touch(fence_b, DuplexLeaseActivity.MODEL_OUTPUT)
session_b.touch_lease(DuplexLeaseActivity.MODEL_OUTPUT)
clock.advance(5.0)
expired = manager.collect_expired()
assert len(expired) == 1
assert expired[0].session_id == "sid-a"
assert expired[0].reserved_request_ids == ("req-a-reserved",)
assert expired[0].submitted_request_ids == ("req-a-submitted",)
assert manager.get("sid-b") is session_b
assert session_a.lease.idle_expired(clock()) is True
assert session_b.lease.idle_expired(clock()) is False
assert session_a.resource_request_ids(submitted=False) == ["req-a-reserved"]
assert session_a.resource_request_ids(submitted=True) == ["req-a-submitted"]
assert session_b.resource_request_ids() == ["req-b"]
def test_close_and_reaper_select_exactly_one_terminal_transition() -> None:
def test_close_is_a_single_terminal_transition() -> None:
clock = FakeMonotonicClock(0.0)
manager = DuplexSessionRuntimeManager(clock=clock)
fence = DuplexFence("sid-race")
manager.open_session(fence, lease_config=_lease_config(idle_ttl_s=1.0))
session = _session("sid-race", clock, idle_ttl_s=1.0)
clock.advance(2.0)
assert session.lease.idle_expired(clock()) is True
assert len(manager.collect_expired()) == 1
assert manager.close_session(fence, reason="explicit_close") is None
assert manager.collect_expired() == []
fence_2 = DuplexFence("sid-race-2")
manager.open_session(fence_2, lease_config=_lease_config(idle_ttl_s=1.0))
assert manager.close_session(fence_2, reason="explicit_close") is not None
clock.advance(2.0)
assert manager.collect_expired() == []
assert session.begin_close(reason="explicit_close") is True
assert session.lease.terminal_reason == "explicit_close"
# A terminal lease is never reaped again and a second close is a no-op.
assert session.lease.idle_expired(clock()) is False
assert session.lease.disconnect_grace_expired(clock()) is False
assert session.begin_close(reason="idle_ttl_expired") is True
assert session.lease.terminal_reason == "explicit_close"
with pytest.raises(RuntimeError, match="terminal"):
session.touch_lease(DuplexLeaseActivity.HEARTBEAT)
def test_stale_fence_cannot_touch_detach_or_resume_lease() -> None:
def test_stale_fence_cannot_start_a_lease_operation() -> None:
clock = FakeMonotonicClock(0.0)
manager = DuplexSessionRuntimeManager(clock=clock)
current = DuplexFence("sid-fence", epoch=1)
session = _session("sid-fence", clock)
session.epoch = 1
session.sync_fence()
stale = DuplexFence("sid-fence", epoch=0)
session = manager.open_session(current, lease_config=_lease_config())
with pytest.raises(RuntimeError, match="fence mismatch"):
session.touch(stale, DuplexLeaseActivity.APPEND)
with pytest.raises(RuntimeError, match="fence mismatch"):
session.detach(stale)
with pytest.raises(RuntimeError, match="fence mismatch"):
session.resume(stale, expected_lease_generation=0)
with pytest.raises(DuplexFenceMismatchError, match="fence mismatch"):
session.begin_lease_operation(stale, "append-1")
with pytest.raises(DuplexFenceMismatchError, match="fence mismatch"):
session.begin_lease_operation(DuplexFence("other-session", epoch=1), "append-2")
def test_disabled_idle_expiry_never_collects_session() -> None:
def test_disabled_idle_expiry_never_expires_session() -> None:
clock = FakeMonotonicClock(0.0)
manager = DuplexSessionRuntimeManager(clock=clock)
manager.open_session(DuplexFence("sid-no-expiry"), lease_config=_lease_config(idle_ttl_s=None))
session = _session("sid-no-expiry", clock, idle_ttl_s=None)
clock.advance(1_000_000.0)
assert manager.collect_expired() == []
assert session.lease.expires_at is None
assert session.lease.idle_expired(clock()) is False
def test_detached_session_expires_at_disconnect_grace_when_idle_ttl_is_disabled() -> None:
clock = FakeMonotonicClock(0.0)
manager = DuplexSessionRuntimeManager(clock=clock)
fence = DuplexFence("sid-disconnect-grace")
session = manager.open_session(fence, lease_config=_lease_config(idle_ttl_s=None))
session = _session("sid-disconnect-grace", clock, idle_ttl_s=None)
session.detach(fence)
session.detach_lease()
clock.advance(29.0)
assert manager.collect_expired() == []
assert session.lease.disconnect_grace_expired(clock()) is False
clock.advance(1.0)
expired = manager.collect_expired()
assert [item.session_id for item in expired] == [fence.session_id]
assert expired[0].reason == "disconnect_grace_expired"
assert session.lease.disconnect_grace_expired(clock()) is True
def test_runtime_manager_enforces_server_owned_session_admission() -> None:
manager = DuplexSessionRuntimeManager(max_sessions=2)
manager.open_session(DuplexFence("sid-admission-a"))
manager.open_session(DuplexFence("sid-admission-b"))
with pytest.raises(RuntimeError, match="duplex_session_capacity_exhausted"):
manager.open_session(DuplexFence("sid-admission-c"))
def test_completed_append_cache_is_bounded() -> None:
manager = DuplexSessionRuntimeManager(completed_append_limit=2)
fence = DuplexFence("sid-completed-cache")
session = manager.open_session(fence)
for index in range(3):
session.record_completed_append(
f"operation-{index}",
fence=fence,
mode=DuplexInputMode.TURN_COMMIT_ONLY,
final=True,
stage_results=[{"index": index}],
)
assert list(session.completed_appends) == ["operation-1", "operation-2"]
session.accept_fence(DuplexFence(fence.session_id, epoch=1))
assert session.completed_appends == {}
def test_lease_config_and_expiry_record_are_immutable() -> None:
def test_lease_config_is_immutable_and_validated() -> None:
config = _lease_config()
with pytest.raises(FrozenInstanceError):
config.idle_ttl_s = 1.0 # type: ignore[misc]
clock = FakeMonotonicClock(0.0)
manager = DuplexSessionRuntimeManager(clock=clock)
manager.open_session(DuplexFence("sid-record"), lease_config=_lease_config(idle_ttl_s=1.0))
clock.advance(2.0)
record = manager.collect_expired()[0]
with pytest.raises(FrozenInstanceError):
record.reason = "changed" # type: ignore[misc]
with pytest.raises(ValueError, match="idle_ttl_s"):
DuplexLeaseConfig(idle_ttl_s=0.0)
with pytest.raises(ValueError, match="disconnect_grace_s"):
DuplexLeaseConfig(disconnect_grace_s=0.0)
+324
View File
@@ -0,0 +1,324 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""``DuplexModelPlugin`` loading/validation helpers and the ``PipelineConfig.duplex_plugin`` binding."""
from __future__ import annotations
from dataclasses import fields
from pathlib import Path
from typing import Any
import pytest
from vllm.sampling_params import SamplingParams
from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig, PipelineConfig
from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig
from vllm_omni.engine.duplex.contracts import DuplexAppendPlan
from vllm_omni.engine.duplex.plugin import (
DuplexDataPlane,
DuplexModelPlugin,
DuplexModelSessionState,
DuplexRuntimeConfigError,
coerce_int,
load_duplex_plugin,
payload_turn_id,
reject_changed_runtime_value,
validate_duplex_plugin_sampling,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _encode_audio(audio: object, sample_rate_hz: int, fmt: str, speed: float | None) -> str | None:
del audio, sample_rate_hz, fmt, speed
return None
class FakeDataPlane(DuplexDataPlane):
def begin_request(self, request_id: str) -> None:
pass
def is_terminal(self, request_id: str | None) -> bool:
return False
def mark_terminal(self, request_id: str) -> None:
pass
def close_stream(self, request_id: str) -> None:
pass
def close_session(self, session_id: str, *, active_request_id: str | None = None) -> None:
pass
def project(self, result, *, context=None):
del result, context
return []
class FakePlugin(DuplexModelPlugin):
"""Minimal plugin; referenced by dotted path from ``load_duplex_plugin`` tests."""
plugin_id = "fake-duplex"
def __init__(self, encode_audio) -> None:
super().__init__(encode_audio)
self.data_plane = FakeDataPlane()
def configure_sampling_params(self, *, runtime_config, defaults):
del runtime_config
return tuple(defaults)
def plan_append(self, **kwargs) -> DuplexAppendPlan:
del kwargs
return DuplexAppendPlan(prompt={"prompt_token_ids": [1]})
def decide_output(self, **kwargs):
del kwargs
return None
def create_session_state(self) -> DuplexModelSessionState:
raise NotImplementedError
def capabilities(self, *, max_sessions: int) -> DuplexCapabilities:
del max_sessions
return DuplexCapabilities()
def validate_client_extra_body(self, extra_body: object) -> None:
pass
async def prepare_runtime_config(self, config: DuplexSessionConfig, *, model_config: Any) -> dict[str, object]:
del config, model_config
return {}
def runtime_config_for_update(self, config, current):
del config
return dict(current)
def data_plane_context(self, **kwargs) -> object:
return dict(kwargs)
class PluginWithoutId(FakePlugin):
plugin_id = ""
class PluginWithoutDataPlane(FakePlugin):
def __init__(self, encode_audio) -> None:
DuplexModelPlugin.__init__(self, encode_audio)
class PluginWithWrongDataPlane(FakePlugin):
def __init__(self, encode_audio) -> None:
DuplexModelPlugin.__init__(self, encode_audio)
self.data_plane = object() # type: ignore[assignment]
class NotAPlugin:
plugin_id = "lookalike"
data_plane = FakeDataPlane()
def __init__(self, encode_audio) -> None:
self.encode_audio = encode_audio
def _path(cls: type) -> str:
return f"{__name__}.{cls.__name__}"
# --------------------------------------------------------------------------- #
# load_duplex_plugin #
# --------------------------------------------------------------------------- #
def test_load_duplex_plugin_instantiates_the_class_at_the_dotted_path() -> None:
plugin = load_duplex_plugin(_path(FakePlugin), _encode_audio)
assert type(plugin) is FakePlugin
assert plugin.plugin_id == "fake-duplex"
assert isinstance(plugin.data_plane, FakeDataPlane)
# Defaults of the optional class-level policy.
assert plugin.private_runtime_config_keys == frozenset()
assert plugin.silence_continuation_samples == 16000
assert plugin.runtime_config_for_function_output(DuplexSessionConfig(), {}, {"name": "tool"}) is None
def test_load_duplex_plugin_rejects_a_non_plugin_class() -> None:
with pytest.raises(TypeError, match="not a DuplexModelPlugin"):
load_duplex_plugin(_path(NotAPlugin), _encode_audio)
def test_load_duplex_plugin_requires_a_plugin_id() -> None:
with pytest.raises(TypeError, match="plugin_id"):
load_duplex_plugin(_path(PluginWithoutId), _encode_audio)
@pytest.mark.parametrize("plugin_cls", [PluginWithoutDataPlane, PluginWithWrongDataPlane])
def test_load_duplex_plugin_requires_a_data_plane(plugin_cls: type) -> None:
with pytest.raises(TypeError, match="DuplexDataPlane as data_plane"):
load_duplex_plugin(_path(plugin_cls), _encode_audio)
def test_load_duplex_plugin_rejects_paths_without_a_module() -> None:
with pytest.raises(ValueError, match="Invalid duplex plugin path"):
load_duplex_plugin("FakePlugin", _encode_audio)
def test_load_duplex_plugin_propagates_missing_attributes() -> None:
with pytest.raises(AttributeError):
load_duplex_plugin(f"{__name__}.NoSuchPlugin", _encode_audio)
# --------------------------------------------------------------------------- #
# validate_duplex_plugin_sampling #
# --------------------------------------------------------------------------- #
def test_validate_duplex_plugin_sampling_accepts_one_parameter_per_stage() -> None:
plugin = FakePlugin(_encode_audio)
validate_duplex_plugin_sampling(plugin, sampling_defaults=(SamplingParams(), SamplingParams()))
validate_duplex_plugin_sampling(plugin, sampling_defaults=())
def test_validate_duplex_plugin_sampling_rejects_stage_count_mismatch() -> None:
class WrongStageCountPlugin(FakePlugin):
def configure_sampling_params(self, *, runtime_config, defaults):
del runtime_config
return defaults[:1]
with pytest.raises(ValueError, match="one sampling parameter per stage"):
validate_duplex_plugin_sampling(
WrongStageCountPlugin(_encode_audio),
sampling_defaults=(SamplingParams(), SamplingParams()),
)
def test_validate_duplex_plugin_sampling_rejects_type_mismatch() -> None:
class WrongSamplingTypePlugin(FakePlugin):
def configure_sampling_params(self, *, runtime_config, defaults):
del runtime_config
return tuple(object() for _ in defaults)
with pytest.raises(TypeError, match="sampling parameter type mismatch for stage 0"):
validate_duplex_plugin_sampling(
WrongSamplingTypePlugin(_encode_audio),
sampling_defaults=(SamplingParams(), SamplingParams()),
)
def test_validate_duplex_plugin_sampling_skips_type_check_for_stages_without_defaults() -> None:
class ObjectSamplingPlugin(FakePlugin):
def configure_sampling_params(self, *, runtime_config, defaults):
del runtime_config
return tuple(object() for _ in defaults)
validate_duplex_plugin_sampling(ObjectSamplingPlugin(_encode_audio), sampling_defaults=(None, None))
def test_validate_duplex_plugin_sampling_rejects_non_tuple_results() -> None:
class ListSamplingPlugin(FakePlugin):
def configure_sampling_params(self, *, runtime_config, defaults):
del runtime_config
return list(defaults)
with pytest.raises(TypeError, match="as a tuple"):
validate_duplex_plugin_sampling(ListSamplingPlugin(_encode_audio), sampling_defaults=(SamplingParams(),))
# --------------------------------------------------------------------------- #
# Runtime-config helpers #
# --------------------------------------------------------------------------- #
def test_reject_changed_runtime_value_only_raises_on_change() -> None:
reject_changed_runtime_value("voice-a", "voice-a", message="voice is fixed", code="voice_changed")
with pytest.raises(DuplexRuntimeConfigError, match="voice is fixed") as excinfo:
reject_changed_runtime_value("voice-b", "voice-a", message="voice is fixed", code="voice_changed")
assert excinfo.value.code == "voice_changed"
assert isinstance(excinfo.value, ValueError)
def test_reject_changed_runtime_value_uses_the_requested_error_class() -> None:
class CustomRuntimeConfigError(DuplexRuntimeConfigError):
pass
with pytest.raises(CustomRuntimeConfigError) as excinfo:
reject_changed_runtime_value(1, 2, message="changed", code="custom", error_cls=CustomRuntimeConfigError)
assert excinfo.value.code == "custom"
def test_duplex_runtime_config_error_defaults_its_code() -> None:
assert DuplexRuntimeConfigError("bad").code == "invalid_duplex_runtime_config"
@pytest.mark.parametrize(
("payload", "expected"),
[
({"duplex_turn_id": 3}, 3),
({"duplex_turn_id": "7"}, 7),
({"model_turn_id": 2}, 2),
({"duplex_turn_id": 5, "model_turn_id": 9}, 5),
({"duplex_turn_id": "not-a-number"}, None),
({}, None),
("turn", None),
(None, None),
],
)
def test_payload_turn_id_prefers_duplex_turn_id_and_coerces(payload: object, expected: int | None) -> None:
assert payload_turn_id(payload) == expected
@pytest.mark.parametrize(
("value", "expected"),
[(4, 4), ("11", 11), (2.9, 2), (True, 1), (None, None), ("x", None), (object(), None), ([1], None)],
)
def test_coerce_int(value: object, expected: int | None) -> None:
assert coerce_int(value) == expected
# --------------------------------------------------------------------------- #
# PipelineConfig binding #
# --------------------------------------------------------------------------- #
def test_pipeline_config_binds_one_duplex_plugin_path() -> None:
field_defaults = {f.name: f.default for f in fields(PipelineConfig)}
assert "duplex_plugin" in field_defaults
assert field_defaults["duplex_plugin"] is None
# The pre-framework fields survive only so that the pipelines of the models
# that are not ported yet still construct (RFC vllm-omni#7181 ports them).
# Nothing in the duplex framework may read them: a pipeline is a duplex
# model iff it declares ``duplex_plugin``.
legacy_fields = ("duplex_runtime_extension", "duplex_serving_adapter", "duplex_control_enabled")
for legacy_field in legacy_fields:
assert legacy_field in field_defaults
framework_sources = [
*(Path("vllm_omni/engine/duplex").glob("*.py")),
*(Path("vllm_omni/entrypoints/duplex").glob("*.py")),
Path("vllm_omni/engine/duplex_omni_engine.py"),
Path("vllm_omni/engine/duplex_orchestrator.py"),
Path("vllm_omni/entrypoints/duplex_omni.py"),
]
readers = [
f"{source}:{legacy_field}"
for source in framework_sources
for legacy_field in legacy_fields
if legacy_field in source.read_text(encoding="utf-8")
]
assert readers == []
# Same rule for the runtime-config knob the framework stopped reading: the
# append-retry table it bounded went away with the correlated append RPC.
assert "completed_append_cache_size" in {f.name for f in fields(DuplexSessionRuntimeConfig)}
assert [
str(source)
for source in framework_sources
if "completed_append_cache_size" in source.read_text(encoding="utf-8")
] == []
-563
View File
@@ -1,563 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from dataclasses import FrozenInstanceError
from types import SimpleNamespace
import pytest
from vllm.sampling_params import SamplingParams
from vllm_omni.engine.duplex import runtime as duplex_runtime
from vllm_omni.engine.duplex.contracts import (
duplex_data_plane_request_info,
duplex_resource_request_belongs_to_session,
duplex_resource_request_id,
)
from vllm_omni.engine.duplex.messages import DuplexFence
from vllm_omni.engine.duplex.runtime import (
DuplexInputMode,
DuplexOutputAction,
DuplexOutputDecision,
DuplexRuntimeCapabilities,
DuplexSessionRuntimeManager,
)
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.runtime import (
MiniCPMO45DuplexRuntimeExtension,
_duplex_hd_slice_count,
build_duplex_data_plane_prompt,
duplex_scheduler_token_budget,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_duplex_fence_is_immutable():
fence = DuplexFence("session")
with pytest.raises(FrozenInstanceError):
fence.epoch = 1 # type: ignore[misc]
assert not hasattr(fence, "__dict__")
def test_duplex_runtime_tracks_stage_bindings_and_barge_in_epoch():
manager = DuplexSessionRuntimeManager()
session = manager.open_session(
DuplexFence("sid-1"),
capabilities=DuplexRuntimeCapabilities(
input_modes={DuplexInputMode.APPEND_TOKENS},
),
)
session.bind_stage_request(stage_id=0, request_id="req-stage0", fence=session.fence)
session.bind_stage_request(stage_id=1, request_id="req-stage1", fence=session.fence)
update = session.append_input(mode=DuplexInputMode.APPEND_TOKENS, fence=session.fence)
next_fence = DuplexFence("sid-1", epoch=1)
stale_request_ids = session.release_fence(session.fence)
session.accept_fence(next_fence)
assert update.seq == 1
assert session.fence == next_fence
assert stale_request_ids == ["req-stage0", "req-stage1"]
assert session.stage_bindings == {}
def test_duplex_runtime_tracks_same_request_id_for_each_pipeline_stage():
manager = DuplexSessionRuntimeManager()
fence = DuplexFence("sid-shared-pipeline-request")
session = manager.open_session(fence)
session.reserve_stage_request(0, "req-shared", fence=fence)
session.bind_stage_request(0, "req-shared", fence=fence)
session.bind_stage_request(1, "req-shared", fence=fence)
assert session.stage_bindings[0].request_id == "req-shared"
assert session.stage_bindings[1].request_id == "req-shared"
assert session.resource_request_ids() == ["req-shared"]
assert session.input_seq == 0
def test_duplex_runtime_extension_validation_rejects_missing_methods():
class IncompleteExtension:
def configure_sampling_params(self, *, runtime_config, defaults):
del runtime_config
return defaults
with pytest.raises(TypeError, match="plan_append"):
duplex_runtime.validate_duplex_runtime_extension(IncompleteExtension())
def test_duplex_runtime_extension_validation_rejects_stage_count_mismatch():
class WrongStageCountExtension(MiniCPMO45DuplexRuntimeExtension):
def configure_sampling_params(self, *, runtime_config, defaults):
del runtime_config
return defaults[:1]
with pytest.raises(ValueError, match="one sampling parameter per stage"):
duplex_runtime.validate_duplex_runtime_extension(
WrongStageCountExtension(),
sampling_defaults=(SamplingParams(), SamplingParams()),
)
def test_duplex_runtime_extension_validation_rejects_sampling_type_mismatch():
class WrongSamplingTypeExtension(MiniCPMO45DuplexRuntimeExtension):
def configure_sampling_params(self, *, runtime_config, defaults):
del runtime_config
return tuple(object() for _ in defaults)
with pytest.raises(TypeError, match="sampling parameter type"):
duplex_runtime.validate_duplex_runtime_extension(
WrongSamplingTypeExtension(),
sampling_defaults=(SamplingParams(), SamplingParams()),
)
def test_duplex_output_decision_metadata_is_immutable():
decision = DuplexOutputDecision(
action=DuplexOutputAction.DIRECT_RESPONSE,
metadata={"model_listen": True},
)
with pytest.raises(TypeError):
decision.metadata["model_listen"] = False
def _decide_minicpmo_output(
output: object,
*,
segment_token_ids: tuple[int, ...] = (),
segment_output_metadata: dict | None = None,
):
return MiniCPMO45DuplexRuntimeExtension().decide_output(
stage_id=0,
final_stage_id=1,
segment_finished=True,
segment_token_ids=segment_token_ids,
segment_output_metadata=segment_output_metadata or {},
output=output,
)
def test_minicpmo_extension_owns_stage_sampling_overrides():
defaults = (
SamplingParams(max_tokens=4),
SamplingParams(max_tokens=8),
)
configured = MiniCPMO45DuplexRuntimeExtension().configure_sampling_params(
runtime_config={
"duplex_stage_max_tokens": {"0": 20},
"duplex_stage_sampling_params": {"1": {"stop_token_ids": [151645]}},
},
defaults=defaults,
)
assert configured[0].max_tokens == 20
assert configured[1].stop_token_ids == [151645]
assert defaults[0].max_tokens == 4
assert 151645 not in (defaults[1].stop_token_ids or [])
def test_minicpmo_output_decision_uses_raw_streaming_token_snapshot():
decision = _decide_minicpmo_output(
SimpleNamespace(outputs=[SimpleNamespace()]),
segment_token_ids=(151705,),
segment_output_metadata={"special_token_ids": {"listen_token_id": 151705}},
)
assert decision is not None
assert decision.action is DuplexOutputAction.DIRECT_RESPONSE
assert decision.metadata["duplex_native_decision"] == "listen"
assert decision.metadata["model_listen"] is True
@pytest.mark.parametrize("attr", ["token_ids", "cumulative_token_ids"])
def test_minicpmo_output_decision_ignores_output_level_token_history(attr):
output = SimpleNamespace(
multimodal_output={"special_token_ids": {"listen_token_id": 151705}},
outputs=[SimpleNamespace()],
**{attr: [42, 151705]},
)
assert _decide_minicpmo_output(output) is None
@pytest.mark.parametrize("attr", ["token_ids", "cumulative_token_ids"])
def test_minicpmo_output_decision_uses_completion_token_ids(attr):
output = SimpleNamespace(
multimodal_output={"special_token_ids": {"listen_token_id": 151705}},
outputs=[SimpleNamespace(**{attr: [42, 151705]})],
)
assert _decide_minicpmo_output(output) is not None
def test_minicpmo_output_decision_uses_completion_stop_reason():
output = SimpleNamespace(
multimodal_output={"special_token_ids": {"listen_token_id": 151705}},
outputs=[SimpleNamespace(stop_reason=151705)],
)
assert _decide_minicpmo_output(output) is not None
def test_duplex_runtime_cancel_fence_rejects_late_append_and_accepts_next_epoch():
manager = DuplexSessionRuntimeManager()
cancelled_fence = DuplexFence("sid-cancel-race")
next_fence = DuplexFence("sid-cancel-race", epoch=1)
session = manager.open_session(
cancelled_fence,
capabilities=DuplexRuntimeCapabilities(
input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK},
),
)
session.bind_stage_request(
stage_id=0,
request_id="req-cancelled",
fence=cancelled_fence,
)
stale_request_ids = session.cancel_fence(cancelled_fence, next_fence)
assert stale_request_ids == ["req-cancelled"]
assert session.fence == next_fence
assert session.stage_bindings == {}
with pytest.raises(RuntimeError, match="fence mismatch"):
session.append_input(
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
fence=cancelled_fence,
)
update = session.append_input(
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
fence=next_fence,
)
assert update.seq == 1
def test_duplex_runtime_stale_close_preserves_live_session_and_bindings():
manager = DuplexSessionRuntimeManager()
current_fence = DuplexFence("sid-stale-close", epoch=1)
session = manager.open_session(
current_fence,
capabilities=DuplexRuntimeCapabilities(
input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK},
),
)
session.bind_stage_request(0, "req-live", fence=current_fence)
with pytest.raises(RuntimeError, match="fence mismatch"):
manager.close_session(DuplexFence("sid-stale-close"))
assert manager.get("sid-stale-close") is session
assert session.stage_request_ids() == ["req-live"]
def test_duplex_runtime_reopen_rejects_late_append_from_old_incarnation():
manager = DuplexSessionRuntimeManager()
old_fence = DuplexFence("sid-reopen", incarnation=0)
old_session = manager.open_session(
old_fence,
capabilities=DuplexRuntimeCapabilities(
input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK},
),
)
manager.close_session(old_fence)
new_fence = DuplexFence("sid-reopen", incarnation=1)
new_session = manager.open_session(
new_fence,
capabilities=old_session.capabilities,
)
with pytest.raises(RuntimeError, match="fence mismatch"):
new_session.append_input(
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
fence=old_fence,
)
assert (
new_session.append_input(
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
fence=new_fence,
).seq
== 1
)
def test_duplex_prompt_expands_incarnation_metadata():
fence = DuplexFence("sid-incarnation", incarnation=3)
prompt = build_duplex_data_plane_prompt(
request_id=duplex_resource_request_id(fence, "stage0"),
fence=fence,
session_config={},
runtime_config={},
seq=1,
turn_seq=1,
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
payload={"is_speech": True},
final=False,
)
assert prompt["model_intermediate_buffer"]["duplex"]["incarnation"] == 3
def test_duplex_runtime_tracks_turn_local_append_sequence():
manager = DuplexSessionRuntimeManager()
session = manager.open_session(
DuplexFence("sid-turn-seq"),
capabilities=DuplexRuntimeCapabilities(
input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK},
),
)
first = session.append_input(mode=DuplexInputMode.APPEND_AUDIO_CHUNK, fence=session.fence)
second = session.append_input(mode=DuplexInputMode.APPEND_AUDIO_CHUNK, fence=session.fence)
next_turn = DuplexFence("sid-turn-seq", turn_id=1, response_seq=1)
third = session.append_input(
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
fence=next_turn,
)
fourth = session.append_input(
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
fence=next_turn,
)
assert [first.seq, second.seq, third.seq, fourth.seq] == [1, 2, 3, 4]
assert [first.turn_seq, second.turn_seq, third.turn_seq, fourth.turn_seq] == [1, 2, 1, 2]
assert [first.turn_id, second.turn_id, third.turn_id, fourth.turn_id] == [0, 0, 1, 1]
def test_duplex_runtime_rejects_unsupported_append_mode():
manager = DuplexSessionRuntimeManager()
session = manager.open_session(
DuplexFence("sid-2"),
capabilities=DuplexRuntimeCapabilities(input_modes={DuplexInputMode.TURN_COMMIT_ONLY}),
)
with pytest.raises(ValueError, match="not supported"):
session.append_input(mode=DuplexInputMode.APPEND_TOKENS, fence=session.fence)
def test_duplex_data_plane_request_info_extracts_structured_stage_result():
request_id, response_stage_id = duplex_data_plane_request_info(
{
"stage_results": [
{"result": {"supported": True}},
{
"result": {
"data_plane_append": True,
"request_id": "duplex-sid-e0-stage0-s1",
"response_stage_id": 1,
}
},
]
}
)
assert request_id == "duplex-sid-e0-stage0-s1"
assert response_stage_id == 1
def test_duplex_data_plane_request_info_rejects_missing_request_id():
assert duplex_data_plane_request_info(
{
"stage_results": [
{
"result": {
"data_plane_append": True,
"request_id": "",
"response_stage_id": 1,
}
}
]
}
) == (None, None)
def test_duplex_scheduler_token_budget_estimates_pcm_slots():
assert (
duplex_scheduler_token_budget(
{
"audio": "AAAAAA==",
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
== 16
)
def test_duplex_scheduler_token_budget_ignores_client_budget_fields():
assert (
duplex_scheduler_token_budget(
{
"audio": "AAAAAA==",
"format": "pcm_f32le",
"duplex_num_input_tokens": 999,
"num_input_tokens": 999,
}
)
== 16
)
def test_resource_state_rejects_fence_regression_and_requires_explicit_fence():
current = DuplexFence("sid", epoch=2, turn_id=3, response_seq=4)
manager = DuplexSessionRuntimeManager()
session = manager.open_session(
current,
capabilities=DuplexRuntimeCapabilities(input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK}),
)
for stale in (
DuplexFence("sid", epoch=1, turn_id=99, response_seq=99),
DuplexFence("sid", epoch=2, turn_id=2, response_seq=4),
DuplexFence("sid", epoch=2, turn_id=3, response_seq=3),
):
with pytest.raises(RuntimeError, match="fence mismatch"):
session.accept_fence(stale)
assert session.fence == current
with pytest.raises(TypeError, match="fence"):
session.bind_stage_request(0, "request")
with pytest.raises(TypeError, match="fence"):
session.append_input(mode=DuplexInputMode.APPEND_AUDIO_CHUNK)
with pytest.raises(TypeError, match="DuplexFence"):
manager.open_session("legacy-session")
with pytest.raises(TypeError, match="DuplexFence"):
manager.close_session("legacy-session")
def test_resource_request_id_is_derived_from_fence_and_role():
fence = DuplexFence("sid-with-dashes", epoch=7, turn_id=11, response_seq=13)
assert duplex_resource_request_id(fence, "stage0") == "duplex-s.c2lkLXdpdGgtZGFzaGVz.i.0.e.7.r.stage0"
assert duplex_resource_request_id(fence, "stage1") == "duplex-s.c2lkLXdpdGgtZGFzaGVz.i.0.e.7.r.stage1"
def test_resource_request_id_codec_separates_session_id_from_incarnation():
embedded_incarnation = duplex_resource_request_id(
DuplexFence("foo-i1", incarnation=0),
"stage0",
)
actual_incarnation = duplex_resource_request_id(
DuplexFence("foo", incarnation=1),
"stage0",
)
assert embedded_incarnation != actual_incarnation
def test_resource_request_id_session_membership_uses_encoded_identity():
request_id = duplex_resource_request_id(
DuplexFence("sid-with-dashes", incarnation=2, epoch=7),
"stage0",
)
assert duplex_resource_request_belongs_to_session(request_id, "sid-with-dashes") is True
assert duplex_resource_request_belongs_to_session(request_id, "sid") is False
assert duplex_resource_request_belongs_to_session("duplex-s.invalid.i.x.e.7.r.stage0", "sid") is False
def test_placeholder_budget_is_planned_inside_omni_engine_boundary():
fence = DuplexFence("sid", turn_id=1, response_seq=1)
prompt = build_duplex_data_plane_prompt(
request_id=duplex_resource_request_id(fence, "stage0"),
fence=fence,
session_config={},
runtime_config={},
seq=2,
turn_seq=1,
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
payload={
"audio": "AAAAAA==",
"format": "pcm_f32le",
"duplex_num_input_tokens": 999,
"num_input_tokens": 999,
},
final=False,
)
assert len(prompt["prompt_token_ids"]) == 16
assert prompt["model_intermediate_buffer"]["duplex"]["fence"] == fence
assert prompt["model_intermediate_buffer"]["duplex"]["scheduler_token_budget"] == 16
def _jpeg_b64(width: int, height: int) -> str:
import base64
from io import BytesIO
from PIL import Image
buffer = BytesIO()
Image.new("RGB", (width, height), (32, 64, 96)).save(buffer, format="JPEG")
return base64.b64encode(buffer.getvalue()).decode("ascii")
def _pcm_unit_payload(frames: list[str]) -> dict[str, object]:
import base64
return {
"audio": base64.b64encode(b"\x00" * (16000 * 4)).decode("ascii"),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
"video_frames": frames,
}
def test_duplex_scheduler_token_budget_one_frame_is_one_block():
audio_only = duplex_scheduler_token_budget(_pcm_unit_payload([]))
one_frame = duplex_scheduler_token_budget(_pcm_unit_payload([_jpeg_b64(960, 540)]))
assert one_frame - audio_only == 66
@pytest.mark.parametrize(
("base_size", "expected_blocks"),
[
((960, 540), 4), # official camera frame: source + 2 HD patches + composite
((540, 960), 4), # portrait phone capture
((640, 360), 4),
((448, 448), 2), # fits one tile: the processor does not slice it
((320, 240), 2),
],
)
def test_duplex_scheduler_token_budget_matches_stage0_hd_slicing(base_size, expected_blocks):
"""A stacked pair is processed with max_slice_nums=[2, 1]; the budget must
equal the blocks Stage0 builds for that base-frame size."""
audio_only = duplex_scheduler_token_budget(_pcm_unit_payload([]))
pair = duplex_scheduler_token_budget(_pcm_unit_payload([_jpeg_b64(*base_size), _jpeg_b64(448, 448)]))
assert pair - audio_only == expected_blocks * 66
@pytest.mark.parametrize(
("size", "expected_patches"),
[
# multiple = min(ceil(w * h / 448**2), max_slice_nums); <= 1 is never sliced.
((448, 448), 0), # exactly one tile
((320, 240), 0), # smaller than one tile
((447, 449), 0), # just under one tile
((449, 449), 2), # just over: the processor cuts a 2-tile grid
((640, 360), 2),
((960, 540), 2), # official camera frame
((540, 960), 2), # same frame rotated
((1920, 1080), 2), # capped by max_slice_nums=2, not by area
],
)
def test_duplex_hd_slice_count_matches_official_grid(size, expected_patches):
"""Lock the port of ``MiniCPMVImageProcessor.get_sliced_grid``.
Stage0 slices a stacked unit with ``max_slice_nums=[2, 1]``, so the patch
count for the base frame decides the scheduler budget. A frame that fits one
``scale_resolution=448`` tile is not sliced at all.
"""
assert _duplex_hd_slice_count(size, 2) == expected_patches
def test_duplex_scheduler_token_budget_unreadable_base_frame_keeps_hd_fallback():
audio_only = duplex_scheduler_token_budget(_pcm_unit_payload([]))
pair = duplex_scheduler_token_budget(_pcm_unit_payload(["not-an-image", _jpeg_b64(448, 448)]))
assert pair - audio_only == 4 * 66
+612
View File
@@ -0,0 +1,612 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Session-state semantics of the engine-resident duplex session (``DuplexEngineSession``)."""
from __future__ import annotations
from dataclasses import FrozenInstanceError
import pytest
from vllm_omni.engine.duplex.config import (
DuplexCapabilities,
DuplexConfigError,
DuplexOverlapPolicy,
DuplexSessionConfig,
DuplexTurnEventType,
DuplexTurnState,
ResponseCreateOptions,
)
from vllm_omni.engine.duplex.contracts import DuplexFence
from vllm_omni.engine.duplex.events import TurnEvent
from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession, DuplexFenceMismatchError
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.capabilities import (
minicpmo45_native_capabilities,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _session(session_id: str = "duplex-test", config: DuplexSessionConfig | None = None) -> DuplexEngineSession:
return DuplexEngineSession(session_id=session_id, config=config or DuplexSessionConfig(model="test-model"))
def test_commit_audio_input_does_not_advance_model_turn_identity():
session = _session()
first = session.commit_audio_input(transcript="first chunk")
second = session.commit_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
assert first.message["transcript"] == "first chunk"
assert first.message["content"] == [
{"type": "audio_url", "audio_url": {"url": "native-duplex:input-audio"}, "transcript": "first chunk"}
]
assert len(session.history) == 2
assert session.turn_state == DuplexTurnState.USER_COMMITTED
def test_commit_audio_input_accepts_explicit_turn_id():
session = _session()
committed = session.commit_audio_input(turn_id=4)
assert committed.turn_id == 4
assert session.turn_id == 0
assert "transcript" not in committed.message
# ---- response options ----
def test_response_options_apply_to_one_response_without_mutating_session_defaults():
session = _session(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"
assert session.response_config is session.config
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.response_config is session.config
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 = _session(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 = _session(config=DuplexSessionConfig(instructions="base"))
session.begin_response()
with pytest.raises(RuntimeError, match="active"):
session.reserve_response_options(ResponseCreateOptions(instructions="too late"))
def test_discarded_response_options_do_not_apply():
session = _session(config=DuplexSessionConfig(instructions="base"))
session.reserve_response_options(ResponseCreateOptions(instructions="dropped"))
session.discard_response_options()
session.begin_response()
assert session.response_config.instructions == "base"
# ---- response / overlap identity ----
def test_session_owns_response_and_overlap_identity():
session = _session()
response_id = session.begin_response()
assert session.turn_state == DuplexTurnState.ASSISTANT_GENERATING
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
assert session.turn_state == DuplexTurnState.IDLE
def test_session_composes_single_owner_ledgers_with_immutable_views():
session = _session()
session.bind_request("req-1")
response_id = session.begin_response(turn_id=3)
session.mark_audio_sent(duration_ms=240)
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
assert session.turn_state == DuplexTurnState.ASSISTANT_PLAYING
with pytest.raises(FrozenInstanceError):
session.playback.sent_ms = 480 # type: ignore[misc]
assert session.playback.sent_ms == 240
def test_barge_in_advances_epoch_and_drops_uncommitted_assistant_text():
session = _session()
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.accepted_fence == DuplexFence(session.session_id, epoch=1, turn_id=0)
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
# ---- playback ledger ----
def test_playback_ack_tracks_committed_cursor_separately():
session = _session()
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
assert session.playback.as_dict() == {
"generated_ms": 10_000,
"sent_ms": 10_000,
"played_ms": 2_000,
"committed_ms": 2_000,
}
def test_playback_ack_is_scoped_per_response():
session = _session()
first = session.begin_response()
session.mark_audio_sent(duration_ms=3_000)
session.end_response()
second = session.begin_response()
session.mark_audio_sent(duration_ms=1_000)
session.acknowledge_playback(played_ms=2_500, response_id=first)
assert session.playback_for_response(first).played_ms == 2_500
assert session.playback_for_response(second).played_ms == 0
assert session.playback.played_ms == 0
def test_history_commit_uses_audio_text_alignment_marks():
session = _session()
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)
assert [(mark.text_chars, mark.audio_end_ms) for mark in session.assistant_audio_text_marks] == [
(6, 1_000),
(11, 2_000),
]
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"}
assert session.history[-1] == committed
assert session.last_assistant_full_message == {"role": "assistant", "content": "hello world"}
def test_history_commit_with_ack_only_policy_defers_unacknowledged_text():
session = _session(config=DuplexSessionConfig(playback_commit_policy="ack_only"))
response_id = session.begin_response()
session.append_assistant_text("never played")
session.mark_audio_sent(duration_ms=1_000, text_chars=12)
committed = session.end_response(commit_text=True)
assert committed is None
assert session.history == ()
assert f"item_{response_id}" in session.pending_history_item_ids
def test_truncate_history_item_uses_response_alignment_marks():
session = _session()
response_id = 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=2_000)
committed = session.end_response(commit_text=True)
assert committed == {"role": "assistant", "content": "hello world"}
item_id = f"item_{response_id}"
session.register_history_item(item_id, committed)
assert session.truncate_history_item(item_id, audio_end_ms=1_000) is True
assert session.history[-1] == {"role": "assistant", "content": "hello"}
assert session.history_item_ids[item_id] == {"role": "assistant", "content": "hello"}
assert session.delete_history_item(item_id) is True
assert session.history == ()
# ---- turn signals / model turns ----
@pytest.mark.parametrize(
("event_type", "turn_state"),
[
(DuplexTurnEventType.USER_STARTED, DuplexTurnState.USER_SPEAKING),
(DuplexTurnEventType.USER_COMMITTED, DuplexTurnState.USER_COMMITTED),
(DuplexTurnEventType.ASSISTANT_STARTED, DuplexTurnState.ASSISTANT_GENERATING),
(DuplexTurnEventType.ASSISTANT_DONE, DuplexTurnState.IDLE),
(DuplexTurnEventType.BARGE_IN, DuplexTurnState.BARGE_IN),
],
)
def test_signal_turn_transitions_and_returns_typed_turn_event(event_type, turn_state):
session = _session()
event = session.signal_turn(event_type.value)
assert isinstance(event, TurnEvent)
assert session.turn_state == turn_state
wire = event.to_realtime()
assert wire["type"] == "turn.event"
assert wire["event"] == event_type.value
assert wire["turn_state"] == turn_state.value
def test_signal_turn_playback_ack_updates_cursor_and_close_marks_session_closing():
session = _session()
session.mark_audio_sent(duration_ms=5_000)
ack = session.signal_turn("playback_ack", {"played_ms": 1_500, "committed_ms": 1_000})
assert ack.event == "playback_ack"
assert session.playback.played_ms == 1_500
assert session.playback.committed_ms == 1_000
close = session.signal_turn(DuplexTurnEventType.CLOSE.value)
assert close.event == "close"
assert session.state.value == "closing"
def test_complete_model_turn_advances_turn_id_and_fence():
session = _session()
assert session.fence == DuplexFence(session.session_id, epoch=0, turn_id=0)
session.complete_model_turn(0)
assert session.turn_id == 1
assert session.fence == DuplexFence(session.session_id, epoch=0, turn_id=1)
assert session.accepted_fence == session.fence
# A stale terminal for an already-completed turn does not move identity backwards.
session.complete_model_turn(0)
assert session.turn_id == 1
session.complete_model_turn(3)
assert session.turn_id == 4
assert session.accepted_fence.turn_id == 4
# ---- fence validation ----
def test_accept_fence_rejects_stale_or_foreign_fences():
session = _session("sid-fence")
session.complete_model_turn(0)
with pytest.raises(DuplexFenceMismatchError):
session.accept_fence(DuplexFence("sid-fence", epoch=0, turn_id=0))
with pytest.raises(DuplexFenceMismatchError):
session.accept_fence(DuplexFence("sid-other", epoch=0, turn_id=1))
session.accept_fence(DuplexFence("sid-fence", epoch=1, turn_id=0))
assert session.accepted_fence == DuplexFence("sid-fence", epoch=1, turn_id=0)
def test_prepare_and_commit_append_sequence_chunks_per_turn_and_epoch():
session = _session("sid-append")
fence = session.fence
first = session.commit_append(session.prepare_append(fence))
second = session.commit_append(session.prepare_append(fence))
assert (first.seq, first.turn_seq, first.turn_id) == (1, 1, 0)
assert (second.seq, second.turn_seq, second.turn_id) == (2, 2, 0)
session.complete_model_turn(0)
third = session.commit_append(session.prepare_append(session.fence))
assert (third.seq, third.turn_seq, third.turn_id) == (3, 1, 1)
# Barge-in advances the epoch (turn_id is kept) and restarts the append sequence.
session.barge_in()
assert session.fence == DuplexFence("sid-append", epoch=1, turn_id=1)
fourth = session.commit_append(session.prepare_append(session.fence))
assert (fourth.seq, fourth.turn_seq, fourth.turn_id) == (1, 1, 1)
assert session.input_seq == 1
def test_commit_append_rejects_stale_reservation():
session = _session("sid-append-stale")
fence = session.fence
reservation = session.prepare_append(fence)
session.commit_append(session.prepare_append(fence))
with pytest.raises(RuntimeError, match="stale"):
session.commit_append(reservation)
with pytest.raises(DuplexFenceMismatchError):
session.prepare_append(DuplexFence("sid-other", epoch=0, turn_id=0))
def test_cancel_fence_releases_stage_requests_and_advances_identity():
session = _session("sid-cancel")
cancelled = session.fence
session.reserve_stage_request(0, "req-a", fence=cancelled)
session.bind_stage_request(1, "req-b", fence=cancelled)
assert session.stage_request_submitted(1, "req-b") is True
assert session.stage_request_submitted(0, "req-a") is False
next_fence = DuplexFence("sid-cancel", epoch=1, turn_id=0)
stale = session.cancel_fence(cancelled, next_fence)
assert stale == ["req-a", "req-b"]
assert session.resource_request_ids() == []
assert session.accepted_fence == next_fence
with pytest.raises(DuplexFenceMismatchError):
session.cancel_fence(next_fence, DuplexFence("sid-cancel", epoch=1, turn_id=1))
with pytest.raises(DuplexFenceMismatchError):
session.cancel_fence(DuplexFence("sid-other", epoch=1, turn_id=0), DuplexFence("sid-cancel", epoch=2))
# ---- public view ----
def test_as_public_dict_exposes_identity_capabilities_and_playback():
session = _session("duplex-public", config=DuplexSessionConfig(model="m", voice="alloy"))
session.mark_audio_sent(duration_ms=100)
payload = session.as_public_dict()
assert payload["id"] == "duplex-public"
assert payload["model"] == "m"
assert payload["voice"] == "alloy"
assert payload["state"] == "open"
assert payload["turn_state"] == "assistant_playing"
assert payload["epoch"] == 0
assert payload["turn_id"] == 0
assert payload["capabilities"] == DuplexCapabilities().as_dict()
assert payload["playback"] == {"generated_ms": 100, "sent_ms": 100, "played_ms": 0, "committed_ms": 0}
# ---- DuplexSessionConfig.from_realtime ----
def test_from_realtime_rejects_unsupported_audio_formats():
with pytest.raises(DuplexConfigError) as excinfo:
DuplexSessionConfig.from_realtime({"input_audio_format": "mp3"})
assert excinfo.value.code == "unsupported_audio_format"
with pytest.raises(DuplexConfigError) as excinfo:
DuplexSessionConfig.from_realtime({"audio": {"output": {"format": {"type": "audio/opus"}}}})
assert excinfo.value.code == "unsupported_audio_format"
def test_from_realtime_validates_turn_detection():
with pytest.raises(DuplexConfigError) as excinfo:
DuplexSessionConfig.from_realtime({"turn_detection": {"type": "semantic_vad"}})
assert excinfo.value.code == "unsupported_turn_detection"
assert excinfo.value.param == "turn_detection"
with pytest.raises(DuplexConfigError) as excinfo:
DuplexSessionConfig.from_realtime({"overlap_policy": "barge_in_on_speech"})
assert excinfo.value.code == "unsupported_turn_detection"
server_vad = DuplexSessionConfig.from_realtime({"turn_detection": {"type": "server_vad"}})
assert server_vad.overlap_policy == DuplexOverlapPolicy.BARGE_IN_ON_SPEECH.value
assert server_vad.extra_body["realtime_turn_detection"]["threshold"] == 0.5
model_owned = DuplexSessionConfig.from_realtime({"turn_detection": None})
assert model_owned.overlap_policy == DuplexOverlapPolicy.LISTEN_ONLY.value
assert model_owned.extra_body["realtime_turn_detection"] is None
def test_from_realtime_maps_wire_fields_and_stores_realtime_keys_in_extra_body():
config = DuplexSessionConfig.from_realtime(
{
"model": "openbmb/MiniCPM-o-4_5",
"instructions": "be brief",
"output_audio_format": "pcm16",
"max_response_output_tokens": "inf",
"audio": {"output": {"voice": "alloy", "speed": 1.25}},
"tools": [{"type": "function", "name": "lookup"}],
"tool_choice": "auto",
"metadata": {"tenant": "t1"},
"include": ["item.input_audio_transcription.logprobs"],
"input_audio_transcription": {"model": "whisper-1"},
"extra_body": {"custom": True},
}
)
assert config.model == "openbmb/MiniCPM-o-4_5"
assert config.instructions == "be brief"
assert config.voice == "alloy"
assert config.speed == 1.25
assert config.max_tokens is None
assert config.response_format == "pcm"
assert config.modalities == ["text", "audio"]
assert config.extra_body["custom"] is True
assert config.extra_body["realtime_tools"] == [{"type": "function", "name": "lookup"}]
assert config.extra_body["realtime_tool_choice"] == "auto"
assert config.extra_body["realtime_metadata"] == {"tenant": "t1"}
assert config.extra_body["realtime_include"] == ["item.input_audio_transcription.logprobs"]
assert config.extra_body["realtime_input_audio_transcription"] == {"model": "whisper-1"}
assert config.extra_body["realtime_audio"] == {"output": {"voice": "alloy", "speed": 1.25}}
assert config.extra_body["realtime_output_audio_format"] == "pcm16"
assert "extra_body" not in config.extra_body["realtime_session_payload"]
assert config.extra_body["realtime_session_payload"]["model"] == "openbmb/MiniCPM-o-4_5"
def test_from_realtime_uses_served_model_when_payload_has_none():
config = DuplexSessionConfig.from_realtime({}, model="served-model")
assert config.model == "served-model"
assert config.response_format == "pcm"
assert config.idle_timeout_s == 300.0
def test_from_realtime_ignores_client_chosen_session_ids():
config = DuplexSessionConfig.from_realtime({"id": "client-chosen", "session_id": "also-client-chosen"})
assert not hasattr(config, "session_id")
assert "id" not in config.as_dict()
session = _session("duplex-engine-allocated", config=config)
assert session.as_public_dict()["id"] == "duplex-engine-allocated"
# ---- DuplexSessionConfig.apply_realtime_update ----
def test_apply_realtime_update_rejects_model_change():
config = DuplexSessionConfig(model="served-model")
with pytest.raises(DuplexConfigError) as excinfo:
config.apply_realtime_update({"model": "other-model"}, session_id="sid")
assert excinfo.value.code == "model_update_unsupported"
assert config.model == "served-model"
def test_apply_realtime_update_rejects_voice_change_after_audio_started():
config = DuplexSessionConfig(model="m", voice="alloy")
config.apply_realtime_update({"voice": "verse"}, audio_started=False)
assert config.voice == "verse"
with pytest.raises(DuplexConfigError) as excinfo:
config.apply_realtime_update({"audio": {"output": {"voice": "alloy"}}}, audio_started=True)
assert excinfo.value.code == "voice_update_after_audio_unsupported"
assert config.voice == "verse"
def test_apply_realtime_update_rejects_ref_audio_change():
config = DuplexSessionConfig(model="m")
with pytest.raises(DuplexConfigError) as excinfo:
config.apply_realtime_update({"ref_audio": "/tmp/voice.wav"})
assert excinfo.value.code == "ref_audio_update_unsupported"
assert config.ref_audio is None
def test_apply_realtime_update_patches_fields_and_realtime_keys():
config = DuplexSessionConfig(model="m", instructions="old", extra_body={"realtime_tools": [{"name": "x"}]})
config.apply_realtime_update(
{
"instructions": "new",
"output_audio_format": "g711_ulaw",
"max_output_tokens": 32,
"tools": None,
"metadata": {"k": "v"},
"overlap_policy": "not-a-policy",
}
)
assert config.instructions == "new"
assert config.response_format == "pcm"
assert config.max_tokens == 32
assert "realtime_tools" not in config.extra_body
assert config.extra_body["realtime_metadata"] == {"k": "v"}
assert config.overlap_policy == DuplexOverlapPolicy.LISTEN_ONLY.value
assert config.extra_body["realtime_session_payload"]["instructions"] == "new"
# ---- overlap policy / capabilities ----
def test_overlap_policy_defaults_and_invalid_values_to_listen_only():
assert DuplexSessionConfig().overlap_policy == DuplexOverlapPolicy.LISTEN_ONLY.value
assert DuplexSessionConfig._normalize_overlap_policy("auto") == DuplexOverlapPolicy.LISTEN_ONLY.value
assert DuplexSessionConfig._normalize_overlap_policy("not-a-policy") == DuplexOverlapPolicy.LISTEN_ONLY.value
assert (
DuplexSessionConfig._normalize_overlap_policy(" Barge_In_On_Speech ")
== DuplexOverlapPolicy.BARGE_IN_ON_SPEECH.value
)
assert DuplexSessionConfig.from_event({"session": {"overlap_policy": "bogus"}}).overlap_policy == "listen_only"
def test_capabilities_as_dict_reports_model_native_duplex_constants():
caps = DuplexCapabilities().as_dict()
assert caps["implementation_level"] == "model_native_duplex"
assert caps["input_modes"] == ["append_audio_chunk"]
assert caps["supports_kv_lease"] is False
assert caps["supports_core_kv_lease"] is False
assert "client_event" in caps["signal_sources"]
assert set(caps) == set(minicpmo45_native_capabilities().as_dict())
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_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
+229
View File
@@ -0,0 +1,229 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""The overlap decision, reached directly.
These rules decide what happens when input arrives while the model is speaking.
They were unreachable except by driving a whole append through the runner, so
none of them had a test that named the rule it was checking.
"""
from __future__ import annotations
import base64
import numpy as np
import pytest
from vllm_omni.engine.duplex.config import DuplexSessionConfig
from vllm_omni.engine.duplex.session import overlap_policy
from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _session(**config: object) -> DuplexEngineSession:
values: dict[str, object] = {"model": "fake-model"}
values.update(config)
return DuplexEngineSession(session_id="sid", config=DuplexSessionConfig(**values))
def _pcm16(rms: float, samples: int = 1600) -> str:
data = np.full(samples, int(rms * 32768), dtype=np.int16)
return base64.b64encode(data.tobytes()).decode("ascii")
# --------------------------------------------------------------------------- #
# Speech detection #
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("flag", [True, False])
def test_an_explicit_is_speech_flag_wins_over_the_audio(flag: bool) -> None:
session = _session()
payload = {"format": "pcm16", "audio": _pcm16(0.9 if not flag else 0.0)}
assert overlap_policy.input_looks_like_speech(session, {"is_speech": flag}, payload) is flag
def test_vad_probability_decides_when_no_flag_is_given() -> None:
session = _session()
payload = {"format": "pcm16", "audio": _pcm16(0.0)}
assert overlap_policy.input_looks_like_speech(session, {"vad": {"speech_probability": 0.9}}, payload) is True
assert overlap_policy.input_looks_like_speech(session, {"vad": {"speech_probability": 0.1}}, payload) is False
def test_quiet_audio_below_the_configured_rms_is_not_speech() -> None:
session = _session(overlap_silence_rms=0.05)
loud = {"format": "pcm16", "audio": _pcm16(0.5)}
quiet = {"format": "pcm16", "audio": _pcm16(0.001)}
assert overlap_policy.input_looks_like_speech(session, {}, loud) is True
assert overlap_policy.input_looks_like_speech(session, {}, quiet) is False
def test_undecodable_audio_is_treated_as_speech() -> None:
"""Fail toward listening: dropping real speech is worse than a spurious turn."""
session = _session()
assert overlap_policy.input_looks_like_speech(session, {}, {"format": "pcm16", "audio": "!!not base64!!"}) is True
# --------------------------------------------------------------------------- #
# Barge-in #
# --------------------------------------------------------------------------- #
def test_an_explicit_client_overlap_action_requests_barge_in() -> None:
session = _session()
payload = {"format": "pcm16", "audio": _pcm16(0.5)}
decision = overlap_policy.decide(session, {"overlap_action": "barge_in"}, payload, auto_responds=True)
assert decision["action"] == "barge_in"
assert decision["reason"] == "client_overlap_action"
def test_barge_in_is_deferred_when_the_session_cannot_support_it() -> None:
session = _session()
session.capabilities = type(session.capabilities)(supports_barge_in=False)
payload = {"format": "pcm16", "audio": _pcm16(0.5)}
decision = overlap_policy.decide(session, {"force_barge_in": True}, payload, auto_responds=True)
assert decision["action"] != "barge_in"
# --------------------------------------------------------------------------- #
# Force-listen #
# --------------------------------------------------------------------------- #
def test_a_short_response_create_is_forced_to_listen() -> None:
"""A sub-threshold ack should not take the turn from the model."""
session = _session(overlap_short_ack_ms=700)
short = {"response_create": True, "duration_ms": 200}
long = {"response_create": True, "duration_ms": 1500}
assert overlap_policy.should_force_listen_for_short_commit(session, short, {}) is True
assert overlap_policy.should_force_listen_for_short_commit(session, long, {}) is False
def test_force_barge_in_overrides_the_short_ack_rule() -> None:
session = _session(overlap_short_ack_ms=700)
event = {"response_create": True, "duration_ms": 200, "force_barge_in": True}
assert overlap_policy.should_force_listen_for_short_commit(session, event, {}) is False
def test_auto_response_overlap_forces_listen_only_when_auto_responding() -> None:
event = {"force_listen": True}
assert overlap_policy.should_force_listen_for_auto_response_overlap(event, {}, auto_responds=True) is True
assert overlap_policy.should_force_listen_for_auto_response_overlap(event, {}, auto_responds=False) is False
# --------------------------------------------------------------------------- #
# Payload merging #
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("speech_first", [True, False], ids=["speech-then-silence", "silence-then-speech"])
def test_merging_two_appends_keeps_speech_if_either_half_was_speech(speech_first: bool) -> None:
"""Matching rates and decodable audio, so the halves really are concatenated.
Without a ``sample_rate_hz`` on both, ``merge_audio_payloads`` falls back
to the newer chunk before merging, and the speech flag it returned was the
second chunk's own -- the test passed without exercising the merge.
"""
speech = {
"format": "pcm_f32le",
"audio": base64.b64encode(b"\x01" * 8).decode("ascii"),
"sample_rate_hz": 16000,
"is_speech": True,
}
silence = {
"format": "pcm_f32le",
"audio": base64.b64encode(b"\x00" * 8).decode("ascii"),
"sample_rate_hz": 16000,
"is_speech": False,
}
first, second = (speech, silence) if speech_first else (silence, speech)
merged = overlap_policy.merge_audio_payloads(first, second)
assert base64.b64decode(merged["audio"]) == base64.b64decode(first["audio"]) + base64.b64decode(second["audio"])
assert merged["sample_rate_hz"] == 16000
assert merged["is_speech"] is True
@pytest.mark.parametrize(
("first", "second"),
[
({"format": "pcm_f32le", "audio": "", "sample_rate_hz": 16000}, {"format": "pcm16", "audio": ""}),
(
{"format": "pcm_f32le", "audio": "", "sample_rate_hz": 16000},
{"format": "pcm_f32le", "audio": "", "sample_rate_hz": 24000},
),
(
{"format": "pcm_f32le", "audio": "!!not base64!!", "sample_rate_hz": 16000},
{"format": "pcm_f32le", "audio": "", "sample_rate_hz": 16000},
),
],
ids=["format-mismatch", "rate-mismatch", "undecodable"],
)
def test_unmergeable_payloads_fall_back_to_the_newer_one(first: dict, second: dict) -> None:
"""Whatever cannot be concatenated keeps the newer chunk and drops the older.
Worth pinning: the fallback discards ``first`` silently, so a bug that makes
two mergeable payloads look mismatched loses buffered audio rather than
raising.
"""
assert overlap_policy.merge_audio_payloads(first, second) == second
def test_mergeable_payloads_are_concatenated_in_order() -> None:
head = bytes([1, 2, 3, 4])
tail = bytes([5, 6, 7, 8])
first = {"format": "pcm_f32le", "audio": base64.b64encode(head).decode(), "sample_rate_hz": 16000}
second = {"format": "pcm_f32le", "audio": base64.b64encode(tail).decode(), "sample_rate_hz": 16000}
merged = overlap_policy.merge_audio_payloads(first, second)
assert base64.b64decode(merged["audio"]) == head + tail
assert merged["sample_rate_hz"] == 16000
# --------------------------------------------------------------------------- #
# Server VAD + barge_in_on_speech #
# --------------------------------------------------------------------------- #
def test_server_vad_speech_started_reaches_a_barge_in_decision() -> None:
"""The configuration this PR introduces must decide, not raise.
Both call sites read the VAD verdict through the module-level
``vad_speech_started`` helper while binding its result to a local of the
same name, which made the name local for the whole scope and raised
``UnboundLocalError`` on the call itself. Every ``server_vad`` +
``barge_in_on_speech`` append hit it, and the client saw ``internal_error``
instead of a barge-in. The MiniCPM-o preset is ``listen_only``, so no demo
exercised this branch.
"""
session = _session(overlap_policy="barge_in_on_speech")
payload = {"format": "pcm16", "audio": _pcm16(0.5)}
event = {"vad": {"speech_started": True}}
decision = overlap_policy.decide(session, event, payload, auto_responds=True)
assert decision["action"] == "barge_in"
assert decision["reason"] == "server_vad_speech_started"
assert decision["cancel_reason"] == "turn_detected"
def test_server_vad_mid_utterance_keeps_listening_instead_of_barging_in() -> None:
"""``speech_started`` false means the utterance is already running: not a new turn."""
session = _session(overlap_policy="barge_in_on_speech")
payload = {"format": "pcm16", "audio": _pcm16(0.5)}
event = {"vad": {"speech_started": False}}
decision = overlap_policy.decide(session, event, payload, auto_responds=True)
assert decision["action"] != "barge_in"
def test_barge_in_on_speech_without_vad_still_barges_in_on_speech() -> None:
"""No VAD verdict: the policy falls back to its own speech classification."""
session = _session(overlap_policy="barge_in_on_speech")
payload = {"format": "pcm16", "audio": _pcm16(0.5)}
decision = overlap_policy.decide(session, {}, payload, auto_responds=True)
assert decision["action"] == "barge_in"
assert decision["reason"] == "barge_in_on_speech"
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+379
View File
@@ -0,0 +1,379 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""The engine-side Silero VAD: backend selection and endpoint rules.
The endpoint rules are the part worth pinning hardest. They were reimplemented
when turn detection moved into the engine, and drifted from upstream's
``ThresholdEndpointPolicy`` in two ways that only show up mid-utterance: a loud
frame used to cancel an in-progress silence timer, and ``audio_end_ms`` used to
exclude the trailing silence OpenAI says it should include. The parity cases
below hold upstream's own outputs, so neither can come back unnoticed.
"""
from __future__ import annotations
import base64
import hashlib
import sys
import threading
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
import numpy as np
import pytest
from vllm_omni.engine.duplex.turn_detection import validate_realtime_turn_detection
from vllm_omni.engine.duplex.vad import (
SILERO_VAD_SHA256,
ServerVADUnavailableError,
SileroStreamingVAD,
SileroVADBackend,
SileroVADBackendProvider,
SileroVADConfig,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
FRAME = 512
SAMPLE_RATE_HZ = 16_000
def _drive(config: SileroVADConfig, probabilities: list[float]) -> list[tuple[str, int | None]]:
"""Feed one probability per frame and collect the endpoint events."""
scores = iter(probabilities)
vad = SileroStreamingVAD(config, frame_scorer=lambda _frame: next(scores))
events: list[tuple[str, int | None]] = []
for _ in probabilities:
result = vad.process(np.zeros(FRAME, dtype=np.float32))
if result.speech_started:
events.append(("start", result.speech_start_ms))
if result.speech_stopped:
events.append(("stop", result.speech_end_ms))
return events
# --------------------------------------------------------------------------- #
# Parity with the serving-side policy this was ported from #
# --------------------------------------------------------------------------- #
#: Probability sequences with the endpoint events upstream's
#: ``ThresholdEndpointPolicy`` produced for them, captured from
#: ``entrypoints/duplex/server_vad.py`` at `e2d2617f` before that module was
#: removed. Golden rather than computed because the oracle no longer ships: the
#: serving layer is transport only, so the VAD it used to carry is gone. Any
#: drift in the engine's rules still breaks these.
_PARITY_CASES = {
"one utterance": (
[0.0, 0.9, 0.9, 0.9, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
{"threshold": 0.5, "prefix_padding_ms": 0, "silence_duration_ms": 100, "min_speech_duration_ms": 32},
[("start", 32), ("stop", 256)],
),
"prefix padding reaches back": (
[0.0, 0.9, 0.9, 0.0, 0.0, 0.0, 0.0],
{"threshold": 0.5, "prefix_padding_ms": 300, "silence_duration_ms": 64, "min_speech_duration_ms": 32},
[("start", 0), ("stop", 160)],
),
"loud frames inside the silence timer": (
[0.9, 0.9, 0.1, 0.45, 0.1, 0.1, 0.1],
{"threshold": 0.5, "prefix_padding_ms": 0, "silence_duration_ms": 96, "min_speech_duration_ms": 32},
[("start", 0), ("stop", 160)],
),
"min speech duration rejects a blip": (
[0.9, 0.0, 0.9, 0.9, 0.9, 0.0, 0.0, 0.0, 0.0],
{"threshold": 0.5, "prefix_padding_ms": 0, "silence_duration_ms": 64, "min_speech_duration_ms": 96},
[("start", 64), ("stop", 224)],
),
"negative threshold floor at a low threshold": (
[0.9, 0.9, 0.1, 0.1, 0.1, 0.1],
{"threshold": 0.16, "prefix_padding_ms": 0, "silence_duration_ms": 64, "min_speech_duration_ms": 32},
[("start", 0)],
),
}
@pytest.mark.parametrize(("probabilities", "options", "expected"), _PARITY_CASES.values(), ids=list(_PARITY_CASES))
def test_endpoint_decisions_match_the_serving_side_policy(
probabilities: list[float],
options: dict[str, object],
expected: list[tuple[str, int | None]],
) -> None:
"""The engine detector reproduces the policy server VAD shipped with upstream."""
assert _drive(SileroVADConfig(**options), probabilities) == expected
# --------------------------------------------------------------------------- #
# The two rules that had drifted #
# --------------------------------------------------------------------------- #
def test_a_loud_frame_does_not_restart_the_silence_timer() -> None:
"""Silero v6.2 hysteresis: once silence is running, a loud frame only delays it.
The frame at 0.45 sits above the negative threshold (0.35) but below the
activation threshold. It must not cancel the pending endpoint, or a speaker
who trails off unevenly never gets their turn committed.
"""
config = SileroVADConfig(threshold=0.5, prefix_padding_ms=0, silence_duration_ms=96, min_speech_duration_ms=32)
assert _drive(config, [0.9, 0.9, 0.1, 0.45, 0.1, 0.1]) == [("start", 0), ("stop", 160)]
def test_audio_end_ms_includes_the_trailing_silence() -> None:
"""OpenAI defines audio_end_ms as the end of the audio sent to the model.
That includes the silence spent deciding the turn was over, so it is the
frame boundary the detector stopped on, not where speech last was.
"""
config = SileroVADConfig(threshold=0.5, prefix_padding_ms=0, silence_duration_ms=100, min_speech_duration_ms=32)
events = _drive(config, [0.0, 0.9, 0.9, 0.9, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])
stop_ms = next(ms for kind, ms in events if kind == "stop")
# 8 frames consumed when the timer expires: 8 * 512 / 16 kHz = 256 ms.
assert stop_ms == 256
def test_reset_keeps_the_session_clock_and_clamps_the_prefix() -> None:
"""A barge-in resets the detector but not the session's timeline.
Timestamps have to stay comparable with the rest of the session, and prefix
padding must not reach back into audio the reset discarded.
"""
config = SileroVADConfig(threshold=0.5, prefix_padding_ms=300, silence_duration_ms=64, min_speech_duration_ms=32)
scores = iter([0.0] * 10 + [0.9, 0.9, 0.9])
vad = SileroStreamingVAD(config, frame_scorer=lambda _frame: next(scores))
for _ in range(10):
vad.process(np.zeros(FRAME, dtype=np.float32))
stream_start_ms = round(10 * FRAME * 1000 / SAMPLE_RATE_HZ)
vad.reset()
result = vad.process(np.zeros(FRAME * 3, dtype=np.float32))
assert result.speech_started
# Without the clock the start would land at 0; without the clamp, 300 ms of
# prefix would reach back before the reset.
assert result.speech_start_ms == stream_start_ms
# --------------------------------------------------------------------------- #
# Input handling #
# --------------------------------------------------------------------------- #
def _pcm16_b64(samples: np.ndarray) -> str:
return base64.b64encode((samples * 32767).astype("<i2").tobytes()).decode("ascii")
def test_pcm16_input_is_accepted_alongside_float32() -> None:
config = SileroVADConfig(threshold=0.5)
tone = np.zeros(FRAME * 2, dtype=np.float32)
seen: list[int] = []
vad = SileroStreamingVAD(config, frame_scorer=lambda frame: seen.append(frame.size) or 0.0)
vad.process_base64(_pcm16_b64(tone), fmt="pcm16", sample_rate_hz=SAMPLE_RATE_HZ)
assert seen == [FRAME, FRAME]
def test_an_odd_pcm16_byte_count_is_rejected() -> None:
vad = SileroStreamingVAD(SileroVADConfig(), frame_scorer=lambda _frame: 0.0)
with pytest.raises(ValueError, match="incomplete pcm16"):
vad.process_base64(base64.b64encode(b"\x00\x01\x02").decode(), fmt="pcm16", sample_rate_hz=SAMPLE_RATE_HZ)
def test_resampling_keeps_the_frame_grid_from_drifting() -> None:
"""The 24 kHz remainder carries across chunks, so frame boundaries cannot drift.
This is a weaker claim than upstream's ``StreamingAudioResampler``: the
interpolation is per chunk, so values near a chunk edge differ slightly.
What must hold is the frame *count* and alignment, which is what the
endpoint timestamps are derived from.
"""
source = np.sin(np.linspace(0, 40 * np.pi, 24_000, dtype=np.float32))
def frames_for(chunk_sizes: list[int]) -> int:
frames = 0
def count(_frame: np.ndarray) -> float:
nonlocal frames
frames += 1
return 0.0
vad = SileroStreamingVAD(SileroVADConfig(), frame_scorer=count)
offset = 0
for size in chunk_sizes:
vad.process_base64(_pcm16_b64(source[offset : offset + size]), fmt="pcm16", sample_rate_hz=24_000)
offset += size
assert offset == source.size
return frames
whole = frames_for([source.size])
split = frames_for([7, 13, 511, 512, 513, source.size - 1_556])
assert whole == split == 16_000 // FRAME
def test_a_sample_rate_change_mid_stream_is_rejected() -> None:
vad = SileroStreamingVAD(SileroVADConfig(), frame_scorer=lambda _frame: 0.0)
chunk = _pcm16_b64(np.zeros(1_024, dtype=np.float32))
vad.process_base64(chunk, fmt="pcm16", sample_rate_hz=24_000)
with pytest.raises(ValueError, match="sample rate cannot change"):
vad.process_base64(chunk, fmt="pcm16", sample_rate_hz=48_000)
# --------------------------------------------------------------------------- #
# Backend selection #
# --------------------------------------------------------------------------- #
def test_a_configured_model_path_that_does_not_exist_is_an_error() -> None:
"""An operator who names an artifact gets told it is missing, not a fallback."""
provider = SileroVADBackendProvider(model_path="/nonexistent/silero_vad.onnx")
with pytest.raises(ServerVADUnavailableError, match="does not exist"):
provider.get()
def test_a_configured_model_with_the_wrong_checksum_is_refused(tmp_path) -> None:
"""A different Silero revision changes endpointing, so the pin is enforced."""
impostor = tmp_path / "silero_vad.onnx"
impostor.write_bytes(b"not the pinned model")
provider = SileroVADBackendProvider(model_path=str(impostor))
with pytest.raises(ServerVADUnavailableError, match="checksum mismatch"):
provider.get()
assert hashlib.sha256(impostor.read_bytes()).hexdigest() != SILERO_VAD_SHA256
def test_the_onnx_backend_is_shared_by_every_session() -> None:
"""One ONNX session for the whole engine: its state is passed in, not held."""
provider = SileroVADBackendProvider()
try:
backend = provider.get()
except ServerVADUnavailableError as exc:
pytest.skip(f"no Silero backend available here: {exc}")
if not isinstance(backend, SileroVADBackend):
pytest.skip("no local ONNX artifact; the torch fallback is per-session by design")
assert provider.get() is backend
# State is the caller's, so two sessions on one backend cannot interfere.
first, second = backend.new_state(), backend.new_state()
assert first is not second
probability, next_state = backend.infer(np.zeros(FRAME, dtype=np.float32), first)
assert 0.0 <= probability <= 1.0
assert next_state is not first
def test_the_onnx_backend_holds_the_upstream_silero_v62_contract(monkeypatch, tmp_path) -> None:
"""Pin the ONNX call shape without needing the real artifact or runtime.
The context carry is the part worth pinning: Silero v6.2 is fed 64 samples
of the previous frame ahead of the current 512, and that tail travels in the
caller's state rather than inside the session -- which is exactly what lets
one session serve every duplex session at once.
"""
sessions: list[FakeInferenceSession] = []
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),
]
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]
# Single-threaded on purpose: many sessions share this one ORT session, so
# it must not fan out per call.
assert session.providers == ["CPUExecutionProvider"]
assert session.sess_options.inter_op_num_threads == 1
assert session.sess_options.intra_op_num_threads == 1
# Warm-up already ran: 64 context + 512 frame.
assert session.calls[0]["input"].shape == (1, 576)
assert session.calls[0]["state"].shape == (2, 1, 128)
assert session.calls[0]["sr"].item() == SAMPLE_RATE_HZ
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, :])
second_frame = -first_frame
backend.infer(second_frame, state)
second_call = session.calls[2]
# The previous frame's tail becomes this frame's context.
np.testing.assert_array_equal(
second_call["input"][:, : backend.context_samples],
first_frame[None, -backend.context_samples :],
)
np.testing.assert_array_equal(second_call["state"], np.full((2, 1, 128), 2, dtype=np.float32))
# Two sessions may enter the shared ORT session at the same time.
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])
# --------------------------------------------------------------------------- #
# Session payload validation #
# --------------------------------------------------------------------------- #
def test_an_unknown_server_vad_field_is_refused() -> None:
"""A misspelled knob that silently does nothing is worse than a refusal.
The endpointing would still look configured while running on defaults.
"""
error = validate_realtime_turn_detection({"turn_detection": {"type": "server_vad", "semantic_eagerness": "high"}})
assert error is not None
assert "semantic_eagerness" in error
def test_the_documented_server_vad_fields_are_all_accepted() -> None:
assert (
validate_realtime_turn_detection(
{
"turn_detection": {
"type": "server_vad",
"threshold": 0.6,
"prefix_padding_ms": 200,
"silence_duration_ms": 400,
"create_response": True,
"interrupt_response": True,
"min_speech_duration_ms": 120,
},
"overlap_policy": "barge_in_on_speech",
}
)
is None
)
@@ -54,7 +54,7 @@ def test_abort_async_waits_for_ack(mocker: MockerFixture):
rpc_q: queue.Queue = queue.Queue()
engine = _make_engine(request_q, rpc_q)
mocker.patch(
"vllm_omni.engine.async_omni_engine.uuid.uuid4",
"vllm_omni.engine.omni_engine_base.uuid.uuid4",
return_value=SimpleNamespace(hex="abort-rpc-1"),
)
@@ -81,7 +81,7 @@ def test_abort_async_raises_on_orchestrator_error(mocker: MockerFixture):
rpc_q: queue.Queue = queue.Queue()
engine = _make_engine(request_q, rpc_q)
mocker.patch(
"vllm_omni.engine.async_omni_engine.uuid.uuid4",
"vllm_omni.engine.omni_engine_base.uuid.uuid4",
return_value=SimpleNamespace(hex="abort-rpc-err"),
)
@@ -109,7 +109,7 @@ def test_abort_async_times_out_without_result(mocker: MockerFixture):
rpc_q: queue.Queue = queue.Queue()
engine = _make_engine(request_q, rpc_q)
mocker.patch(
"vllm_omni.engine.async_omni_engine.uuid.uuid4",
"vllm_omni.engine.omni_engine_base.uuid.uuid4",
return_value=SimpleNamespace(hex="abort-rpc-timeout"),
)
@@ -139,7 +139,7 @@ def test_abort_async_preserves_request_queue_backpressure(mocker: MockerFixture)
rpc_q: queue.Queue = queue.Queue()
engine = _make_engine(request_q, rpc_q)
mocker.patch(
"vllm_omni.engine.async_omni_engine.uuid.uuid4",
"vllm_omni.engine.omni_engine_base.uuid.uuid4",
return_value=SimpleNamespace(hex="blocked-abort"),
)
+4 -332
View File
@@ -7,7 +7,6 @@ Focuses on the critical behavior: when the orchestrator thread dies,
subsequent attempts to collect output raise RuntimeError.
"""
import asyncio
import queue
import threading
from concurrent.futures import ThreadPoolExecutor
@@ -18,16 +17,6 @@ from pytest_mock import MockerFixture
from vllm_omni.engine.async_engine_utils import weak_shutdown_async_omni_engine
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine
from vllm_omni.engine.duplex.control_client import (
DuplexControlClient,
DuplexControlRequestError,
)
from vllm_omni.engine.duplex.messages import (
AppendDuplexInputMessage,
DuplexControlResultMessage,
DuplexFence,
SignalDuplexTurnMessage,
)
from vllm_omni.engine.messages import (
CollectiveRPCResultMessage,
ErrorMessage,
@@ -166,9 +155,9 @@ def test_output_remains_on_shared_output_path(mocker: MockerFixture):
raw_queue = queue.Queue()
raw_queue.put_nowait(
OutputMessage(
request_id="legacy-duplex-request",
request_id="shared-path-request",
stage_id=0,
engine_outputs=OmniRequestOutput(request_id="legacy-duplex-request"),
engine_outputs=OmniRequestOutput(request_id="shared-path-request"),
finished=False,
)
)
@@ -176,293 +165,7 @@ def test_output_remains_on_shared_output_path(mocker: MockerFixture):
output = engine.try_get_output(timeout=0.01)
assert output.request_id == "legacy-duplex-request"
def test_duplex_control_client_uses_engine_owned_correlated_transport():
engine = object.__new__(AsyncOmniEngine)
engine._duplex_control_client = None
engine._correlated_rpc_client = CorrelatedRpcClient(queue.Queue(), queue.Queue())
try:
client = engine._get_duplex_control_client()
assert isinstance(client, DuplexControlClient)
assert client._transport is engine._correlated_rpc_client
assert not hasattr(engine, "_submit_rpc_and_wait")
finally:
engine._correlated_rpc_client.close()
def test_duplex_control_api_requires_explicit_fence():
engine = object.__new__(AsyncOmniEngine)
with pytest.raises(TypeError, match="fence"):
engine.open_duplex_session("sid")
with pytest.raises(TypeError, match="fence"):
engine.append_duplex_input("sid", mode="append_audio_chunk", payload={})
with pytest.raises(TypeError, match="fence"):
engine.signal_duplex_turn("sid", event="turn.end")
with pytest.raises(TypeError, match="fence"):
engine.close_duplex_session("sid")
def test_open_duplex_session_waits_for_control_ack(mocker: MockerFixture):
request_q = queue.Queue()
rpc_q = queue.Queue()
control_result = DuplexControlResultMessage(
control_id="ctrl-1",
fence=DuplexFence("sid"),
operation="open",
session_id="sid",
ok=False,
stage_results=[{"stage_id": 0, "replica_id": 0, "result": {"supported": False}}],
unsupported_count=1,
error_count=0,
)
engine = object.__new__(AsyncOmniEngine)
engine.request_queue = SimpleNamespace(sync_q=request_q)
engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q)
engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q)
engine._duplex_control_client = None
mocker.patch("vllm_omni.engine.async_omni_engine.uuid.uuid4", return_value=SimpleNamespace(hex="ctrl-1"))
with ThreadPoolExecutor(max_workers=1) as executor:
pending = executor.submit(engine.open_duplex_session, "sid", fence=DuplexFence("sid"), timeout=1)
msg = request_q.get(timeout=1)
assert msg.type == "open_duplex_session"
assert msg.control_id == "ctrl-1"
rpc_q.put(control_result)
with pytest.raises(DuplexControlRequestError) as exc_info:
pending.result(timeout=1)
assert exc_info.value.result["unsupported_count"] == 1
assert exc_info.value.result["stage_results"][0]["result"]["supported"] is False
engine._correlated_rpc_client.close()
def test_signal_duplex_turn_message_carries_next_fence(mocker: MockerFixture):
request_q = queue.Queue()
rpc_q = queue.Queue()
cancelled_fence = DuplexFence("sid-cancel", epoch=4, turn_id=2)
next_fence = DuplexFence("sid-cancel", epoch=5, turn_id=2)
control_result = DuplexControlResultMessage(
control_id="ctrl-signal",
fence=cancelled_fence,
operation="signal",
session_id="sid-cancel",
ok=True,
stage_results=[],
)
engine = object.__new__(AsyncOmniEngine)
engine.request_queue = SimpleNamespace(sync_q=request_q)
engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q)
engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q)
engine._duplex_control_client = None
mocker.patch("vllm_omni.engine.async_omni_engine.uuid.uuid4", return_value=SimpleNamespace(hex="ctrl-signal"))
with ThreadPoolExecutor(max_workers=1) as executor:
pending = executor.submit(
engine.signal_duplex_turn,
"sid-cancel",
event="input.cancel",
fence=cancelled_fence,
next_fence=next_fence,
session_config={"temperature": 0.0},
timeout=1,
)
msg = request_q.get(timeout=1)
assert isinstance(msg, SignalDuplexTurnMessage)
assert msg.fence == cancelled_fence
assert msg.next_fence == next_fence
assert msg.session_config == {"temperature": 0.0}
rpc_q.put(control_result)
assert pending.result(timeout=1)["ok"] is True
engine._correlated_rpc_client.close()
@pytest.mark.asyncio
async def test_cancelled_async_append_does_not_stop_executor_callable(mocker: MockerFixture):
entered = threading.Event()
release = threading.Event()
completed = threading.Event()
engine = object.__new__(AsyncOmniEngine)
def blocking_append(*args, **kwargs):
del args, kwargs
entered.set()
release.wait(timeout=1)
completed.set()
return {}
mocker.patch.object(engine, "append_duplex_input", side_effect=blocking_append)
task = asyncio.create_task(
engine.append_duplex_input_async(
"sid-executor-cancel",
mode="append_audio_chunk",
payload=b"audio",
fence=DuplexFence("sid-executor-cancel"),
)
)
assert await asyncio.to_thread(entered.wait, 1)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
release.set()
assert await asyncio.to_thread(completed.wait, 1)
def test_duplex_controls_route_out_of_order_without_global_lock():
request_q = queue.Queue()
rpc_q = queue.Queue()
engine = object.__new__(AsyncOmniEngine)
engine.request_queue = SimpleNamespace(sync_q=request_q)
engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q)
engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q)
engine._duplex_control_client = None
first_fence = DuplexFence("sid", epoch=1)
second_fence = DuplexFence("sid", epoch=2)
first = AppendDuplexInputMessage(
control_id="first",
fence=first_fence,
session_id="sid",
expected_epoch=1,
mode="audio",
payload=b"first",
)
second = AppendDuplexInputMessage(
control_id="second",
fence=second_fence,
session_id="sid",
expected_epoch=2,
mode="audio",
payload=b"second",
)
with ThreadPoolExecutor(max_workers=2) as executor:
client = engine._get_duplex_control_client()
first_result = executor.submit(client.execute, first, timeout=1)
second_result = executor.submit(client.execute, second, timeout=1)
submitted = {request_q.get(timeout=1).control_id, request_q.get(timeout=1).control_id}
assert submitted == {"first", "second"}
rpc_q.put(
DuplexControlResultMessage(
control_id="second",
fence=second_fence,
operation="append",
session_id="sid",
ok=True,
stage_results=[],
)
)
rpc_q.put(
DuplexControlResultMessage(
control_id="first",
fence=first_fence,
operation="append",
session_id="sid",
ok=True,
stage_results=[],
)
)
assert first_result.result(timeout=1)["fence"] == first_fence
assert second_result.result(timeout=1)["fence"] == second_fence
assert not hasattr(engine, "_rpc_lock")
engine._correlated_rpc_client.close()
def test_terminal_rpc_router_rejects_without_enqueuing_new_control():
request_q = queue.Queue()
rpc_q = queue.Queue()
engine = object.__new__(AsyncOmniEngine)
engine.request_queue = SimpleNamespace(sync_q=request_q)
engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q)
engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q)
engine._duplex_control_client = None
pending_message = AppendDuplexInputMessage(
control_id="pending",
fence=DuplexFence("sid"),
session_id="sid",
mode="audio",
payload=b"audio",
)
message = AppendDuplexInputMessage(
control_id="after-fatal",
fence=DuplexFence("sid"),
session_id="sid",
mode="audio",
payload=b"audio",
)
try:
with ThreadPoolExecutor(max_workers=1) as executor:
pending = executor.submit(engine._get_duplex_control_client().execute, pending_message, timeout=1)
assert request_q.get(timeout=1).control_id == "pending"
rpc_q.put(ErrorMessage(error="orchestrator failed", fatal=True))
with pytest.raises(RuntimeError, match="orchestrator failed"):
pending.result(timeout=1)
with pytest.raises(RuntimeError, match="orchestrator failed"):
engine._get_duplex_control_client().execute(message, timeout=1)
assert request_q.empty()
finally:
engine._correlated_rpc_client.close()
def test_collective_and_duplex_results_share_router_without_swapping(mocker: MockerFixture):
request_q = queue.Queue()
rpc_q = queue.Queue()
engine = object.__new__(AsyncOmniEngine)
engine.request_queue = SimpleNamespace(sync_q=request_q)
engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q)
engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q)
engine._duplex_control_client = None
fence = DuplexFence("sid")
duplex = AppendDuplexInputMessage(
control_id="duplex-control",
fence=fence,
session_id="sid",
mode="audio",
payload=b"audio",
)
mocker.patch("vllm_omni.engine.async_omni_engine.uuid.uuid4", return_value=SimpleNamespace(hex="collective-rpc"))
with ThreadPoolExecutor(max_workers=2) as executor:
duplex_result = executor.submit(engine._get_duplex_control_client().execute, duplex, timeout=1)
collective_result = executor.submit(engine.collective_rpc, "health", timeout=1)
submitted_types = {request_q.get(timeout=1).type, request_q.get(timeout=1).type}
assert submitted_types == {"append_duplex_input", "collective_rpc"}
rpc_q.put(
CollectiveRPCResultMessage(
rpc_id="collective-rpc",
method="health",
stage_ids=[0],
results=["healthy"],
)
)
rpc_q.put(
DuplexControlResultMessage(
control_id="duplex-control",
fence=fence,
operation="append",
session_id="sid",
ok=True,
stage_results=[],
)
)
assert collective_result.result(timeout=1) == ["healthy"]
assert duplex_result.result(timeout=1)["fence"] == fence
engine._correlated_rpc_client.close()
assert output.request_id == "shared-path-request"
def test_collective_rpc_preserves_request_queue_backpressure(mocker: MockerFixture):
@@ -483,8 +186,7 @@ def test_collective_rpc_preserves_request_queue_backpressure(mocker: MockerFixtu
engine.request_queue = SimpleNamespace(sync_q=request_q)
engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q)
engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q)
engine._duplex_control_client = None
mocker.patch("vllm_omni.engine.async_omni_engine.uuid.uuid4", return_value=SimpleNamespace(hex="blocked-rpc"))
mocker.patch("vllm_omni.engine.omni_engine_base.uuid.uuid4", return_value=SimpleNamespace(hex="blocked-rpc"))
with ThreadPoolExecutor(max_workers=1) as executor:
pending = executor.submit(engine.collective_rpc, "health", timeout=1)
@@ -506,36 +208,6 @@ def test_collective_rpc_preserves_request_queue_backpressure(mocker: MockerFixtu
engine._correlated_rpc_client.close()
def test_open_duplex_session_raises_on_stage_control_error(mocker: MockerFixture):
request_q = queue.Queue()
rpc_q = queue.Queue()
control_result = DuplexControlResultMessage(
control_id="ctrl-error",
fence=DuplexFence("sid"),
operation="open",
session_id="sid",
ok=False,
stage_results=[{"stage_id": 0, "replica_id": 0, "result": {"supported": False, "error": "boom"}}],
unsupported_count=1,
error_count=1,
)
engine = object.__new__(AsyncOmniEngine)
engine.request_queue = SimpleNamespace(sync_q=request_q)
engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q)
engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q)
engine._duplex_control_client = None
mocker.patch("vllm_omni.engine.async_omni_engine.uuid.uuid4", return_value=SimpleNamespace(hex="ctrl-error"))
with ThreadPoolExecutor(max_workers=1) as executor:
pending = executor.submit(engine.open_duplex_session, "sid", fence=DuplexFence("sid"), timeout=1)
request_q.get(timeout=1)
rpc_q.put(control_result)
with pytest.raises(RuntimeError, match="duplex open failed"):
pending.result(timeout=1)
engine._correlated_rpc_client.close()
def test_shutdown_does_not_race_runtime_cleanup_with_live_orchestrator(mocker: MockerFixture):
engine = object.__new__(AsyncOmniEngine)
engine._shutdown_called = False
@@ -12,7 +12,7 @@ import types
import pytest
from vllm_omni.diffusion.data import AttentionConfig
from vllm_omni.engine import async_omni_engine as async_omni_engine_module
from vllm_omni.engine import omni_engine_base as async_omni_engine_module
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine
from vllm_omni.engine.stage_init_utils import (
LogicalStageInitPlan,
@@ -188,7 +188,7 @@ def test_stage_engine_core_client_module_reload_keeps_forward_refs_deferred():
def test_async_omni_engine_initialize_stages_passes_log_stats_to_runtime(monkeypatch):
import vllm_omni.engine.async_omni_engine as engine_mod
import vllm_omni.engine.omni_engine_base as engine_mod
engine = object.__new__(AsyncOmniEngine)
engine.stage_configs = [types.SimpleNamespace()]
@@ -33,7 +33,7 @@ def _resolve(monkeypatch, kwargs: dict[str, Any]) -> dict[str, Any]:
return "config.yaml", [], None
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_load,
)
engine = SimpleNamespace(
+67 -16
View File
@@ -45,27 +45,62 @@ def test_isolated_import_ignores_conflicting_gpu_visibility(monkeypatch: pytest.
_assert_isolated_import_succeeds("import vllm_omni.outputs")
def test_stable_engine_imports_load_duplex_kernel_eagerly() -> None:
# The duplex kernel is imported eagerly by the stable engine modules.
# Model-specific duplex adapters must still load only via the
# dotted-string plugin paths, and the client-side vllm_omni.clients
# package must never enter the stable runtime import graph.
def test_turn_based_stack_has_no_duplex_vocabulary() -> None:
# The generic bases and the turn-based siblings (AsyncOmni / AsyncOmniEngine /
# Orchestrator) carry no duplex code: importing them must not load the
# engine-side duplex package, the model plugins or the client library.
_assert_isolated_import_succeeds("""
import sys
import vllm_omni.engine.async_omni_engine
import vllm_omni.engine.omni_engine_base
import vllm_omni.engine.orchestrator
import vllm_omni.entrypoints.async_omni
import vllm_omni.entrypoints.async_omni_base
forbidden_prefixes = (
"vllm_omni.engine.duplex",
"vllm_omni.engine.duplex_omni_engine",
"vllm_omni.engine.duplex_orchestrator",
"vllm_omni.entrypoints.duplex",
"vllm_omni.entrypoints.duplex_omni",
"vllm_omni.clients",
"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 forbidden_prefixes)
)
if loaded:
raise SystemExit("turn-based imports loaded duplex modules: " + ", ".join(loaded))
""")
def test_duplex_stack_loads_the_framework_but_not_the_model_plugins() -> None:
# The duplex siblings import the engine-side duplex framework eagerly;
# model plugins still load only through PipelineConfig.duplex_plugin, and
# the client-side package never enters the server import graph.
_assert_isolated_import_succeeds("""
import sys
import vllm_omni.engine.duplex_omni_engine
import vllm_omni.engine.duplex_orchestrator
import vllm_omni.entrypoints.duplex_omni
import vllm_omni.entrypoints.duplex.serving
expected_eager = (
"vllm_omni.engine.duplex.contracts",
"vllm_omni.engine.duplex.control_plane",
"vllm_omni.entrypoints.duplex_request_client",
"vllm_omni.outputs.duplex",
"vllm_omni.engine.duplex.commands",
"vllm_omni.engine.duplex.events",
"vllm_omni.engine.duplex.session.engine_session",
"vllm_omni.engine.duplex.session.manager",
"vllm_omni.engine.duplex.plugin",
)
missing = sorted(name for name in expected_eager if name not in sys.modules)
if missing:
raise SystemExit("duplex kernel modules not imported eagerly: " + ", ".join(missing))
raise SystemExit("duplex framework modules not imported eagerly: " + ", ".join(missing))
forbidden_prefixes = (
"vllm_omni.clients",
@@ -79,7 +114,7 @@ loaded = sorted(
if any(name == prefix or name.startswith(prefix + ".") for prefix in forbidden_prefixes)
)
if loaded:
raise SystemExit("stable imports loaded plugin-only duplex modules: " + ", ".join(loaded))
raise SystemExit("duplex imports loaded plugin-only or client modules: " + ", ".join(loaded))
""")
@@ -190,16 +225,32 @@ def test_engine_duplex_uses_canonical_contract_module_names() -> None:
core_dir = REPO_ROOT / "vllm_omni" / "experimental" / "fullduplex" / "core"
for name in (
"commands.py",
"contracts.py",
"lease.py",
"events.py",
"messages.py",
"session.py",
"control_plane.py",
"control_client.py",
"runtime.py",
"plugin.py",
"intermediate.py",
):
assert (engine_dir / name).is_file()
# one session and everything that runs it lives in the session subpackage
for name in (
"__init__.py",
"engine_session.py",
"manager.py",
"runner.py",
"context.py",
"emitter.py",
"model_channel.py",
"control.py",
"append_task.py",
"helpers.py",
"lease.py",
):
assert (engine_dir / "session" / name).is_file()
assert not (engine_dir / "session.py").exists()
for removed in ("control_plane.py", "control_client.py", "runtime.py"):
assert not (engine_dir / removed).exists()
# the duplex_ prefix is dropped inside the duplex package
assert not (engine_dir / "duplex_session.py").exists()
assert not (engine_dir / "duplex_lease.py").exists()
+210
View File
@@ -0,0 +1,210 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""``DuplexOmniEngine``: the session message surface over the generic engine base."""
from __future__ import annotations
import asyncio
import queue
from types import SimpleNamespace
from typing import Any
import pytest
from vllm_omni.engine.duplex.commands import Heartbeat
from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig
from vllm_omni.engine.duplex.messages import (
CloseDuplexSessionMessage,
DuplexControlResultMessage,
DuplexSessionCommandMessage,
DuplexSessionError,
OpenDuplexSessionMessage,
ResumeDuplexSessionMessage,
TouchDuplexSessionMessage,
)
from vllm_omni.engine.duplex_omni_engine import DuplexOmniEngine
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class _FakeRpcClient:
def __init__(self, result: Any) -> None:
self.result = result
self.calls: list[tuple[tuple[str, str], Any, float | None]] = []
#: Control RPCs must block rather than raise a raw queue.Full.
self.block_on_submit_flags: list[bool] = []
def execute(self, key, message, *, timeout=None, timeout_message=None, block_on_submit=False):
self.calls.append((key, message, timeout))
self.block_on_submit_flags.append(block_on_submit)
if isinstance(self.result, BaseException):
raise self.result
return self.result
class _FakeSyncQueue:
def __init__(self, *, full: bool = False) -> None:
self.items: list[Any] = []
self.full = full
def put(self, item: Any, timeout: float | None = None) -> None:
if self.full:
raise queue.Full
self.items.append(item)
def _engine(result: Any, *, alive: bool = True, full: bool = False) -> DuplexOmniEngine:
engine = object.__new__(DuplexOmniEngine)
engine._correlated_rpc_client = _FakeRpcClient(result)
engine.request_queue = SimpleNamespace(sync_q=_FakeSyncQueue(full=full))
engine.is_alive = lambda: alive
engine.plugin = None
return engine
def _ok(operation: str, **fields: Any) -> DuplexControlResultMessage:
return DuplexControlResultMessage(control_id="c", operation=operation, session_id="sid", ok=True, **fields)
@pytest.mark.asyncio
async def test_open_session_runs_the_correlated_rpc_with_the_typed_config() -> None:
result = _ok("open", capabilities=DuplexCapabilities(), public_session={"id": "sid"}, lease_generation=0)
engine = _engine(result)
config = DuplexSessionConfig(model="m")
returned = await engine.open_session_async("sid", config, timeout=3.0)
assert returned is result
((key, message, timeout),) = engine.rpc_client.calls
assert key == ("duplex", message.control_id)
assert isinstance(message, OpenDuplexSessionMessage)
assert message.session_id == "sid" and message.session_config is config
assert timeout == 3.0
# A momentarily full request queue is backpressure, not a failed open: the
# control RPC blocks rather than raising an untyped queue.Full at the caller.
assert engine.rpc_client.block_on_submit_flags == [True]
@pytest.mark.asyncio
async def test_close_resume_and_touch_build_their_messages_without_incarnation() -> None:
engine = _engine(_ok("close"))
await engine.close_session_async("sid", reason="bye")
await engine.resume_session_async("sid", expected_lease_generation=4)
await engine.touch_session_async("sid", activity="detach")
close, resume, touch = (call[1] for call in engine.rpc_client.calls)
assert isinstance(close, CloseDuplexSessionMessage) and close.reason == "bye"
assert isinstance(resume, ResumeDuplexSessionMessage) and resume.expected_lease_generation == 4
assert isinstance(touch, TouchDuplexSessionMessage) and touch.activity == "detach"
for message in (close, resume, touch):
assert message.session_id == "sid"
assert not hasattr(message, "incarnation")
@pytest.mark.asyncio
async def test_failed_control_result_maps_to_a_typed_session_error() -> None:
engine = _engine(
DuplexControlResultMessage(
control_id="c",
operation="open",
session_id="sid",
ok=False,
error_code="resource_exhausted",
error_message="duplex_session_capacity_exhausted: limit=1",
error_retryable=True,
)
)
with pytest.raises(DuplexSessionError) as excinfo:
await engine.open_session_async("sid", DuplexSessionConfig())
assert excinfo.value.code == "resource_exhausted"
assert excinfo.value.retryable is True
assert excinfo.value.session_id == "sid"
@pytest.mark.asyncio
async def test_engine_error_and_timeout_map_to_session_errors() -> None:
# The correlated RPC client raises RuntimeError for a closed router or a
# terminal orchestrator error; both become a typed engine_error.
engine = _engine(RuntimeError("orchestrator exploded"))
with pytest.raises(DuplexSessionError, match="orchestrator exploded") as excinfo:
await engine.close_session_async("sid")
assert excinfo.value.code == "engine_error"
engine = _engine(TimeoutError("duplex open timed out for session sid"))
with pytest.raises(DuplexSessionError) as excinfo:
await engine.open_session_async("sid", DuplexSessionConfig())
assert excinfo.value.code == "timeout" and excinfo.value.retryable is True
@pytest.mark.asyncio
async def test_submit_command_is_one_way_on_the_request_queue() -> None:
engine = _engine(None)
command = Heartbeat(event_id="evt-1")
await engine.submit_command_async("sid", command)
(message,) = engine.request_queue.sync_q.items
assert isinstance(message, DuplexSessionCommandMessage)
assert message.session_id == "sid" and message.command is command
assert engine.rpc_client.calls == []
@pytest.mark.asyncio
async def test_submit_command_rejects_dead_engine_and_full_queue() -> None:
with pytest.raises(DuplexSessionError) as excinfo:
await _engine(None, alive=False).submit_command_async("sid", Heartbeat())
assert excinfo.value.code == "engine_dead"
with pytest.raises(DuplexSessionError) as excinfo:
await _engine(None, full=True).submit_command_async("sid", Heartbeat())
assert excinfo.value.code == "engine_backpressure" and excinfo.value.retryable is True
def test_duplex_capabilities_come_from_the_loaded_plugin() -> None:
engine = object.__new__(DuplexOmniEngine)
engine.plugin = None
with pytest.raises(RuntimeError, match="not loaded"):
engine.duplex_capabilities
capabilities = DuplexCapabilities(supports_barge_in=False)
engine.plugin = SimpleNamespace(capabilities=lambda *, max_sessions: capabilities)
engine.duplex_session_config = SimpleNamespace(max_sessions=3)
assert engine.duplex_capabilities is capabilities
def test_validate_deployment_requires_a_plugin_and_duplex_session_mode() -> None:
engine = object.__new__(DuplexOmniEngine)
engine.model = "plain-model"
engine.pipeline_config = SimpleNamespace(duplex_plugin=None)
engine.deploy_config = SimpleNamespace(session_mode="duplex")
with pytest.raises(ValueError, match="not a duplex model"):
engine._validate_deployment()
engine.pipeline_config = SimpleNamespace(duplex_plugin="pkg.mod.Plugin")
engine.deploy_config = SimpleNamespace(session_mode="turn")
with pytest.raises(ValueError, match="session_mode: duplex"):
engine._validate_deployment()
engine.deploy_config = None
with pytest.raises(ValueError, match="none resolved"):
engine._validate_deployment()
@pytest.mark.asyncio
async def test_async_wrappers_run_the_blocking_calls_off_the_event_loop() -> None:
loop = asyncio.get_running_loop()
engine = _engine(_ok("touch"))
seen: list[bool] = []
original = engine.rpc_client.execute
def execute(*args, **kwargs):
# Called from the default executor thread, never on the event loop thread.
try:
seen.append(asyncio.get_running_loop() is loop)
except RuntimeError:
seen.append(False)
return original(*args, **kwargs)
engine.rpc_client.execute = execute
await engine.touch_session_async("sid", activity="heartbeat")
assert seen == [False]
+517
View File
@@ -0,0 +1,517 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""``DuplexOrchestrator``: the template seams and the stage port around ``OrchestratorBase``."""
from __future__ import annotations
import asyncio
import struct
from types import SimpleNamespace
from typing import Any
import numpy as np
import pytest
from vllm.v1.engine.exceptions import EngineDeadError
from tests.engine.test_orchestrator import (
FakeOutputProcessor,
FakeRunningCounter,
FakeStageClient,
_build_stage_pools,
)
from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig
from vllm_omni.engine.duplex import commands
from vllm_omni.engine.duplex.config import DuplexSessionConfig, DuplexSessionState
from vllm_omni.engine.duplex.contracts import DuplexFence, duplex_resource_request_id
from vllm_omni.engine.duplex.messages import (
CloseDuplexSessionMessage,
DuplexControlResultMessage,
DuplexSessionCommandMessage,
OpenDuplexSessionMessage,
)
from vllm_omni.engine.duplex_orchestrator import DuplexOrchestrator, DuplexOrchestratorRequestState
from vllm_omni.engine.messages import AbortRequestMessage, ShutdownRequestMessage
from vllm_omni.engine.orchestrator import Orchestrator
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.plugin import MiniCPMO45DuplexPlugin
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
SESSION_ID = "duplex-orch"
def _encode_audio(audio: object, sample_rate_hz: int, response_format: str, speed: float | None) -> str | None:
del sample_rate_hz, response_format, speed
samples = int(np.asarray(audio, dtype=np.float32).size) if audio is not None else 0
return f"wav-{samples}" if samples > 0 else None
def _stage_configs(count: int) -> list[object]:
return [SimpleNamespace(model_config=SimpleNamespace(max_model_len=4096)) for _ in range(count)]
def _build(
*,
stages: int = 1,
running_counter: FakeRunningCounter | None = None,
runtime_config: DuplexSessionRuntimeConfig | None = None,
) -> tuple[DuplexOrchestrator, list[FakeStageClient], asyncio.Queue, asyncio.Queue]:
clients = [FakeStageClient(stage_type="llm", final_output=index == stages - 1) for index in range(stages)]
pools = _build_stage_pools(
[[client] for client in clients],
output_processors=[FakeOutputProcessor() for _ in clients],
stage_vllm_configs=_stage_configs(stages),
)
rpc_q: asyncio.Queue = asyncio.Queue()
output_q: asyncio.Queue = asyncio.Queue()
orchestrator = DuplexOrchestrator(
request_async_queue=asyncio.Queue(),
output_async_queue=output_q,
rpc_async_queue=rpc_q,
stage_pools=pools,
running_counter=running_counter,
plugin=MiniCPMO45DuplexPlugin(_encode_audio),
duplex_session_config=runtime_config or DuplexSessionRuntimeConfig(reaper_interval_s=0.01),
model_config=None,
)
return orchestrator, clients, rpc_q, output_q
def _open_message(extra_body: dict[str, object] | None = None) -> OpenDuplexSessionMessage:
return OpenDuplexSessionMessage(
control_id=f"open-{SESSION_ID}",
session_id=SESSION_ID,
session_config=DuplexSessionConfig(
model="openbmb/MiniCPM-o-4_5",
modalities=["text"],
instructions="public instructions",
extra_body={"auto_response": True, **(extra_body or {})},
),
)
async def _open(orchestrator: DuplexOrchestrator, rpc_q: asyncio.Queue, **kwargs: Any) -> DuplexControlResultMessage:
await orchestrator._dispatch_message(_open_message(**kwargs))
result = await asyncio.wait_for(rpc_q.get(), timeout=2.0)
assert isinstance(result, DuplexControlResultMessage)
return result
async def _close(orchestrator: DuplexOrchestrator, rpc_q: asyncio.Queue) -> DuplexControlResultMessage:
await orchestrator._dispatch_message(
CloseDuplexSessionMessage(control_id=f"close-{SESSION_ID}", session_id=SESSION_ID, reason="test")
)
result = await asyncio.wait_for(rpc_q.get(), timeout=2.0)
await _settle(orchestrator)
return result
def _stage0_request_id(epoch: int = 0) -> str:
return duplex_resource_request_id(DuplexFence(SESSION_ID, epoch=epoch), "stage0")
def _append_audio(samples: int = 16000) -> commands.AppendAudio:
return commands.AppendAudio(
audio=struct.pack(f"<{samples}f", *([0.05] * samples)),
format="pcm_f32le",
sample_rate_hz=16000,
is_speech=True,
)
async def _submit(orchestrator: DuplexOrchestrator, command: commands.DuplexCommand) -> None:
assert await orchestrator._dispatch_message(DuplexSessionCommandMessage(session_id=SESSION_ID, command=command))
await _settle(orchestrator)
async def _settle(orchestrator: DuplexOrchestrator, *, timeout_s: float = 3.0) -> None:
"""Let the session runner drain its mailbox and append tasks."""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout_s
quiet = 0
while loop.time() < deadline:
runner = orchestrator.session_manager.runners.get(SESSION_ID)
busy = runner is not None and (
not runner._mailbox.empty()
or any(not task.done() for task in runner.tasks.append_tasks)
or any(not task.done() for task in runner._background_tasks)
)
busy = busy or any(not task.done() for task in orchestrator.session_manager._dispatched_control_tasks)
quiet = 0 if busy else quiet + 1
if quiet >= 5:
return
await asyncio.sleep(0.01)
def _tts_output(request_id: str, *, samples: int = 24000, text: str = "hello") -> SimpleNamespace:
return SimpleNamespace(
request_id=request_id,
finished=False,
outputs=[SimpleNamespace(text=text, token_ids=[], multimodal_output={})],
multimodal_output={
"audio": np.zeros(samples, dtype=np.float32),
"sr": 24000,
"meta.duplex_turn_id": np.array([0], dtype=np.int32),
"meta.duplex_epoch": np.array([0], dtype=np.int32),
},
)
# --------------------------------------------------------------------------- #
# Message dispatch and session lifecycle #
# --------------------------------------------------------------------------- #
def test_turn_based_orchestrator_has_no_session_manager() -> None:
orchestrator = Orchestrator(
request_async_queue=asyncio.Queue(),
output_async_queue=asyncio.Queue(),
rpc_async_queue=asyncio.Queue(),
stage_pools=[],
)
assert not hasattr(orchestrator, "session_manager")
@pytest.mark.asyncio
async def test_duplex_messages_are_dispatched_and_generic_ones_are_not() -> None:
orchestrator, _, rpc_q, _ = _build()
assert await orchestrator._dispatch_message(AbortRequestMessage(request_ids=["x"])) is False
assert await orchestrator._dispatch_message(ShutdownRequestMessage()) is False
assert await orchestrator._dispatch_message(_open_message()) is True
assert (await asyncio.wait_for(rpc_q.get(), timeout=2.0)).ok is True
await orchestrator.session_manager.shutdown()
@pytest.mark.asyncio
async def test_open_preregisters_the_stage0_request_and_close_releases_it() -> None:
counter = FakeRunningCounter()
orchestrator, clients, rpc_q, _ = _build(running_counter=counter)
result = await _open(orchestrator, rpc_q)
assert result.ok is True
assert result.public_session["id"] == SESSION_ID
assert result.capabilities is not None and result.capabilities.supports_input_append
request_id = _stage0_request_id()
request_state = orchestrator.request_states[request_id]
assert isinstance(request_state, DuplexOrchestratorRequestState)
assert request_state.session_owned is True
assert request_state.session_id == SESSION_ID
assert request_state.fence == DuplexFence(SESSION_ID)
assert request_state.streaming.enabled is True
# Preregistration reserves the id; nothing is running until an append submits.
assert counter.value == 0
assert clients[0].add_request_calls == []
assert orchestrator.session_manager.runner_for_request_id(request_id) is not None
closed = await _close(orchestrator, rpc_q)
assert closed.ok is True
assert request_id not in orchestrator.request_states
assert orchestrator.session_manager.runner_for_request_id(request_id) is None
assert counter.value == 0
assert orchestrator.session_manager.active_count() == 0
@pytest.mark.asyncio
async def test_open_failure_rolls_back_session_and_reserved_request() -> None:
orchestrator, _, rpc_q, _ = _build()
# Server-owned runtime keys may not come from the client.
result = await _open(orchestrator, rpc_q, extra_body={"duplex_stage_max_tokens": {"0": 99}})
assert result.ok is False
assert result.error_code == "invalid_duplex_runtime_config"
assert orchestrator.request_states == {}
assert orchestrator.session_manager.active_count() == 0
@pytest.mark.asyncio
async def test_open_fails_cleanly_when_a_stage_has_no_live_replica() -> None:
orchestrator, _, rpc_q, _ = _build(stages=2)
orchestrator.stage_pools[1].evict_replica(0)
result = await _open(orchestrator, rpc_q)
assert result.ok is False
assert "stage 1 has no live replica" in (result.error_message or "")
assert orchestrator.request_states == {}
assert orchestrator.session_manager.active_count() == 0
@pytest.mark.asyncio
async def test_bridge_state_keeps_public_and_runtime_config_separate() -> None:
orchestrator, _, rpc_q, _ = _build()
await _open(orchestrator, rpc_q)
request_state = orchestrator.request_states[_stage0_request_id()]
bridge = request_state.streaming.bridge_states["duplex"]
assert bridge["session_id"] == SESSION_ID
assert bridge["fence"] == DuplexFence(SESSION_ID)
assert (bridge["epoch"], bridge["turn_id"], bridge["model_turn_id"]) == (0, 0, 0)
assert "incarnation" not in bridge
assert bridge["session_config"]["instructions"] == "public instructions"
assert "duplex_stage_sampling_params" not in bridge["session_config"]
assert bridge["runtime_config"]["instructions"] == "public instructions"
assert bridge["runtime_config"]["duplex_stage_max_tokens"] == {"0": 20, "1": 8192}
# The plugin's sampling policy is applied to the preregistered request.
assert request_state.sampling_params_list[0].max_tokens == 20
await orchestrator.session_manager.shutdown()
# --------------------------------------------------------------------------- #
# Stage port #
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_append_submits_the_resumable_stage0_request_and_counts_it_running() -> None:
counter = FakeRunningCounter()
orchestrator, clients, rpc_q, _ = _build(running_counter=counter)
await _open(orchestrator, rpc_q)
request_id = _stage0_request_id()
await _submit(orchestrator, _append_audio())
assert len(clients[0].add_request_calls) == 1
submitted = clients[0].add_request_calls[0][0]
assert submitted.request_id == request_id
assert submitted.resumable is True
assert submitted.sampling_params.max_tokens == 20
request_state = orchestrator.request_states[request_id]
assert request_state.stage_fences[0] == DuplexFence(SESSION_ID)
assert 0 in request_state.stage_submit_ts
assert counter.value == 1
session = orchestrator.session_manager.get(SESSION_ID)
assert session is not None and session.stage_request_submitted(0, request_id)
# Later units update the same resumable request; the counter stays at one.
await _submit(orchestrator, _append_audio())
assert len(clients[0].add_request_calls) == 2
assert clients[0].add_request_calls[1][0].request_id == request_id
assert counter.value == 1
await _close(orchestrator, rpc_q)
assert clients[0].abort_calls == [[request_id]]
assert counter.value == 0
@pytest.mark.asyncio
async def test_session_update_refreshes_the_next_append_sampling_params() -> None:
orchestrator, clients, rpc_q, _ = _build()
await _open(orchestrator, rpc_q)
request_state = orchestrator.request_states[_stage0_request_id()]
assert request_state.sampling_params_list[0].max_tokens == 20
await _submit(orchestrator, commands.UpdateSession(patch={"max_output_tokens": 7}))
await _submit(orchestrator, _append_audio())
assert request_state.sampling_params_list[0].max_tokens == 7
assert clients[0].add_request_calls[-1][0].sampling_params.max_tokens == 7
await orchestrator.session_manager.shutdown()
@pytest.mark.asyncio
async def test_forwarded_stage_requests_are_bound_and_barge_in_aborts_them() -> None:
orchestrator, clients, rpc_q, _ = _build(stages=2)
await _open(orchestrator, rpc_q)
request_id = _stage0_request_id()
await _submit(orchestrator, _append_audio())
request_state = orchestrator.request_states[request_id]
session = orchestrator.session_manager.get(SESSION_ID)
assert session is not None
# The base forwards Stage0 text to the TTS stage and reports the submission.
forwarded = SimpleNamespace(request_id=request_id, prompt_token_ids=[1, 2], resumable=True)
replica_id = await orchestrator.stage_pools[1].submit_initial(request_id, request_state, forwarded)
orchestrator._on_stage_submitted(1, request_id, replica_id, request_state)
assert request_state.stage_fences[1] == DuplexFence(SESSION_ID)
assert session.stage_request_submitted(1, request_id)
await _submit(orchestrator, commands.BargeIn())
assert session.epoch == 1
assert clients[0].abort_calls == [[request_id]]
assert clients[1].abort_calls == [[request_id]]
assert request_id not in orchestrator.request_states
assert session.resource_request_ids() == []
await orchestrator.session_manager.shutdown()
@pytest.mark.asyncio
async def test_session_owned_outputs_reach_the_runner_and_never_the_client_queue() -> None:
orchestrator, _, rpc_q, output_q = _build(stages=2)
await _open(orchestrator, rpc_q)
request_id = _stage0_request_id()
await _submit(orchestrator, _append_audio())
request_state = orchestrator.request_states[request_id]
while not output_q.empty():
output_q.get_nowait()
consumed = await orchestrator._intercept_stage_output(1, 0, _tts_output(request_id), request_state, None, None)
await _settle(orchestrator)
assert consumed is True
session = orchestrator.session_manager.get(SESSION_ID)
assert session is not None and session.active_response_id is not None
types = [message.event.type for message in [output_q.get_nowait() for _ in range(output_q.qsize())]]
assert "response.created" in types and "response.output_audio.delta" in types
orphan = DuplexOrchestratorRequestState(
request_id="duplex-s.b3RoZXI.e.0.r.stage0",
prompt=None,
sampling_params_list=[],
final_stage_id=1,
session_owned=True,
session_id="other",
fence=DuplexFence("other"),
)
assert await orchestrator._intercept_stage_output(1, 0, _tts_output(orphan.request_id), orphan, None, None)
await orchestrator.session_manager.shutdown()
@pytest.mark.asyncio
async def test_forward_failure_closes_the_owning_session() -> None:
orchestrator, clients, rpc_q, output_q = _build(stages=2)
await _open(orchestrator, rpc_q)
request_id = _stage0_request_id()
await _submit(orchestrator, _append_audio())
request_state = orchestrator.request_states[request_id]
session = orchestrator.session_manager.get(SESSION_ID)
assert session is not None
absorbed = await orchestrator._handle_forward_failure(request_id, 1, request_state, ValueError("bad thinker"))
await _settle(orchestrator)
assert absorbed is True
assert request_id not in orchestrator.request_states
assert clients[0].abort_calls == [[request_id]]
assert SESSION_ID not in orchestrator.session_manager.runners
assert session.state == DuplexSessionState.CLOSED
assert orchestrator.session_manager.active_count() == 0
types = [message.event.type for message in [output_q.get_nowait() for _ in range(output_q.qsize())]]
# The stage failure is reported before the session expires, never after.
assert types.index("error") < types.index("session.expired")
assert types[-1] == "session.expired"
@pytest.mark.asyncio
async def test_request_cleanup_failure_is_deferred_and_retried_by_the_reaper(monkeypatch) -> None:
orchestrator, clients, rpc_q, _ = _build()
await _open(orchestrator, rpc_q)
request_id = _stage0_request_id()
await _submit(orchestrator, _append_audio())
manager = orchestrator.session_manager
async def failing_abort(request_ids: list[str]) -> None:
raise RuntimeError("stage abort failed")
monkeypatch.setattr(clients[0], "abort_requests_async", failing_abort)
with pytest.raises(RuntimeError, match="stage abort failed"):
await orchestrator._cleanup_request_ids([request_id], abort=True, release_owners=True)
await _settle(orchestrator)
assert SESSION_ID not in manager.runners
assert SESSION_ID in manager._closing # admission slot retained
assert manager._pending_request_cleanups and not manager._request_cleanups_in_progress
assert manager.active_count() == 0 and manager._admission_count() == 1
monkeypatch.undo()
assert await manager.reap_expired() >= 1
assert manager._pending_request_cleanups == {}
assert manager._admission_count() == 0
assert request_id not in orchestrator.request_states
@pytest.mark.asyncio
async def test_reaper_runs_as_a_background_task_and_survives_one_failure(monkeypatch) -> None:
orchestrator, _, _, _ = _build(runtime_config=DuplexSessionRuntimeConfig(reaper_interval_s=0.01))
calls = {"count": 0}
async def reap_expired(now: float | None = None) -> int:
calls["count"] += 1
if calls["count"] == 1:
raise RuntimeError("transient cleanup failure")
return 0
monkeypatch.setattr(orchestrator.session_manager, "reap_expired", reap_expired)
tasks = orchestrator._background_tasks()
assert len(tasks) == 1
task = asyncio.create_task(tasks[0])
await asyncio.sleep(0.05)
orchestrator._shutdown_event.set()
await task
assert calls["count"] >= 2
await orchestrator._shutdown_extensions()
# --------------------------------------------------------------------------- #
# The hierarchy the two surfaces depend on #
# --------------------------------------------------------------------------- #
def test_the_duplex_stack_extends_the_turn_based_one() -> None:
"""One engine serves a session and an ordinary request, so duplex must extend turn-based.
Pinned because it is the kind of relationship a later tidy-up turns back
into siblings over a shared base. If it does, a duplex server silently
stops serving /v1/chat/completions: the chat service needs a real
EngineClient with generate(), which only the turn-based side provides.
"""
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine
from vllm_omni.engine.duplex_omni_engine import DuplexOmniEngine
from vllm_omni.engine.orchestrator import Orchestrator
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.entrypoints.duplex_omni import DuplexOmni
assert issubclass(DuplexOrchestrator, Orchestrator)
assert issubclass(DuplexOmniEngine, AsyncOmniEngine)
assert issubclass(DuplexOmni, AsyncOmni)
# What the chat route actually needs off the engine client.
assert hasattr(DuplexOmni, "generate")
@pytest.mark.asyncio
async def test_an_unrecognised_message_reaches_the_turn_based_handler() -> None:
"""The duplex orchestrator consumes session messages and passes the rest down.
Without the fall-through, an ordinary add_request on a duplex engine is
silently dropped and the chat request that sent it hangs forever.
"""
orchestrator, _clients, _rpc_q, _output_q = _build()
handled: list[str] = []
async def _turn_based(msg: object) -> None:
handled.append(getattr(msg, "type", ""))
orchestrator._handle_add_request = _turn_based # type: ignore[method-assign]
msg = SimpleNamespace(type="add_request")
assert await orchestrator._dispatch_message(msg) is True
assert handled == ["add_request"], "an add_request must reach Orchestrator, not be dropped"
@pytest.mark.asyncio
async def test_a_dead_replica_closes_the_sessions_it_was_serving() -> None:
"""A session outlives its stage request, so replica death has to release the owner.
``_handle_dead_replica`` cleaned the stage request but not the runner that
owned it: the session stayed alive holding an admission slot, and the client
was told nothing — the request-scoped ``ErrorMessage`` it emits has no
frontend ``request_states`` entry to land in for a session-owned request, so
the frontend drops it. Heartbeats would then keep an unusable session
occupying capacity.
"""
orchestrator, clients, rpc_q, output_q = _build()
await _open(orchestrator, rpc_q)
request_id = _stage0_request_id()
await _submit(orchestrator, _append_audio())
session = orchestrator.session_manager.get(SESSION_ID)
assert session is not None
assert orchestrator.session_manager.active_count() == 1
await orchestrator._handle_dead_replica(0, 0, EngineDeadError("stage 0 replica 0 died"))
await _settle(orchestrator)
assert request_id not in orchestrator.request_states
assert SESSION_ID not in orchestrator.session_manager.runners
assert session.state == DuplexSessionState.CLOSED
assert orchestrator.session_manager.active_count() == 0, "the admission slot must come back"
messages = [output_q.get_nowait() for _ in range(output_q.qsize())]
types = [getattr(getattr(m, "event", None), "type", type(m).__name__) for m in messages]
assert types[-1] in {"session.expired", "session.closed"}, f"the client needs a terminal event, got {types}"
+54 -882
View File
@@ -21,20 +21,7 @@ from vllm.v1.engine import EngineCoreOutput, EngineCoreOutputs, FinishReason
from vllm.v1.engine.exceptions import EngineDeadError
from vllm.v1.metrics.stats import IterationStats
from vllm_omni.engine.duplex.control_plane import DuplexControlPlane
from vllm_omni.engine.duplex.messages import (
AppendDuplexInputMessage,
CloseDuplexSessionMessage,
DuplexFence,
OpenDuplexSessionMessage,
SignalDuplexTurnMessage,
)
from vllm_omni.engine.duplex.runtime import (
DuplexInputMode,
DuplexRuntimeCapabilities,
DuplexSessionRuntimeState,
duplex_resource_request_id,
)
from vllm_omni.engine import OmniEngineCoreOutput
from vllm_omni.engine.messages import (
AbortRequestMessage,
AbortResultMessage,
@@ -54,7 +41,6 @@ from vllm_omni.engine.orchestrator import (
)
from vllm_omni.engine.stage_pool import StagePool
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.runtime import MiniCPMO45DuplexRuntimeExtension
from vllm_omni.outputs import OmniRequestOutput
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -1118,778 +1104,6 @@ async def test_handle_abort_emits_error_result_on_failure() -> None:
assert "cleanup exploded" in (result.error or "")
def _duplex_open_message(
session_id: str,
*,
incarnation: int = 0,
session_config: dict[str, object] | None = None,
runtime_config: dict[str, object] | None = None,
) -> OpenDuplexSessionMessage:
return OpenDuplexSessionMessage(
control_id=f"open-{session_id}",
fence=DuplexFence(session_id, incarnation=incarnation),
session_id=session_id,
capabilities={
"input_modes": [DuplexInputMode.APPEND_AUDIO_CHUNK.value],
"implementation_level": "model_native_duplex",
},
session_config=session_config or {},
runtime_config=runtime_config or {},
)
async def _handle_duplex(orchestrator: Orchestrator, message: object) -> None:
await orchestrator._require_duplex_control_plane().handle(message)
def _duplex_request_state(
orchestrator: Orchestrator,
session: DuplexSessionRuntimeState,
*,
stage_id: int,
) -> OrchestratorRequestState | None:
context = orchestrator._require_duplex_control_plane().ensure_stage_request(
session,
stage_id=stage_id,
)
if context is None:
return None
return orchestrator.request_states.get(context.request_id)
@pytest.mark.asyncio
async def test_duplex_control_plane_keeps_public_and_runtime_config_separate() -> None:
stage0 = FakeStageClient(stage_type="llm", final_output=True)
rpc_q: asyncio.Queue = asyncio.Queue()
orchestrator = Orchestrator(
request_async_queue=asyncio.Queue(),
output_async_queue=asyncio.Queue(),
rpc_async_queue=rpc_q,
stage_pools=_build_stage_pools([[stage0]]),
duplex_runtime_extension=MiniCPMO45DuplexRuntimeExtension(),
enable_duplex_control=True,
)
open_message = _duplex_open_message(
"sid-config-channels",
incarnation=3,
session_config={
"instructions": "public instructions",
"extra_body": {"duplex_stage_max_tokens": {"0": 99}},
},
runtime_config={
"duplex_stage_max_tokens": {"0": 3},
"duplex_stage_sampling_params": {"0": {"stop_token_ids": [151705]}},
},
)
await _handle_duplex(orchestrator, open_message)
assert rpc_q.get_nowait().ok is True
session = orchestrator.duplex_sessions.require(open_message.session_id)
request_state = _duplex_request_state(orchestrator, session, stage_id=0)
assert request_state.sampling_params_list[0].max_tokens == 3
assert request_state.sampling_params_list[0].stop_token_ids == [151705]
bridge = request_state.streaming.bridge_states["duplex"]
assert bridge["incarnation"] == open_message.fence.incarnation
assert bridge["session_config"] == open_message.session_config
assert bridge["runtime_config"] == open_message.runtime_config
@pytest.mark.asyncio
async def test_duplex_control_plane_preserves_turn_commit_without_model_extension() -> None:
rpc_q: asyncio.Queue = asyncio.Queue()
orchestrator = Orchestrator(
request_async_queue=asyncio.Queue(),
output_async_queue=asyncio.Queue(),
rpc_async_queue=rpc_q,
stage_pools=[],
duplex_runtime_extension=None,
enable_duplex_control=True,
)
assert isinstance(orchestrator.duplex_control_plane, DuplexControlPlane)
message = OpenDuplexSessionMessage(
control_id="open-turn-commit",
fence=DuplexFence("sid-turn-commit"),
session_id="sid-turn-commit",
capabilities={"input_modes": [DuplexInputMode.TURN_COMMIT_ONLY.value]},
session_config={},
)
await _handle_duplex(orchestrator, message)
result = rpc_q.get_nowait()
assert result.ok is True
assert result.stage_results[0]["result"]["scheduler_request_context"] is False
def test_ordinary_orchestrator_bypasses_duplex_control_plane() -> None:
orchestrator = Orchestrator(
request_async_queue=asyncio.Queue(),
output_async_queue=asyncio.Queue(),
rpc_async_queue=asyncio.Queue(),
stage_pools=[],
duplex_runtime_extension=MiniCPMO45DuplexRuntimeExtension(),
)
assert orchestrator.duplex_control_plane is None
@pytest.mark.asyncio
async def test_duplex_close_cleans_preregistered_request_without_append() -> None:
stage0 = FakeStageClient(stage_type="llm", final_output=True)
rpc_q: asyncio.Queue = asyncio.Queue()
running_counter = FakeRunningCounter()
orchestrator = Orchestrator(
request_async_queue=asyncio.Queue(),
output_async_queue=asyncio.Queue(),
rpc_async_queue=rpc_q,
stage_pools=_build_stage_pools([[stage0]]),
duplex_runtime_extension=MiniCPMO45DuplexRuntimeExtension(),
enable_duplex_control=True,
running_counter=running_counter,
)
open_message = _duplex_open_message("sid-preregister-close")
await _handle_duplex(orchestrator, open_message)
open_result = rpc_q.get_nowait()
request_id = open_result.stage_results[0]["result"]["request_id"]
assert request_id in orchestrator.request_states
await _handle_duplex(
orchestrator,
CloseDuplexSessionMessage(
control_id="close-preregistered",
fence=open_message.fence,
session_id=open_message.session_id,
),
)
assert rpc_q.get_nowait().ok is True
assert request_id not in orchestrator.request_states
assert orchestrator.duplex_sessions.get(open_message.session_id) is None
assert running_counter.value == 0
@pytest.mark.asyncio
async def test_duplex_open_failure_rolls_back_session_and_reserved_request() -> None:
stage0 = FakeStageClient(stage_type="llm", final_output=True)
rpc_q: asyncio.Queue = asyncio.Queue()
orchestrator = Orchestrator(
request_async_queue=asyncio.Queue(),
output_async_queue=asyncio.Queue(),
rpc_async_queue=rpc_q,
stage_pools=_build_stage_pools([[stage0]]),
duplex_runtime_extension=MiniCPMO45DuplexRuntimeExtension(),
enable_duplex_control=True,
)
open_message = _duplex_open_message(
"sid-open-rollback",
runtime_config={
"duplex_stage_sampling_params": {
"0": {"stop_token_ids": 123},
}
},
)
await _handle_duplex(orchestrator, open_message)
assert rpc_q.get_nowait().ok is False
assert orchestrator.duplex_sessions.get(open_message.session_id) is None
assert orchestrator.request_states == {}
@pytest.mark.asyncio
async def test_duplex_running_counter_tracks_only_submitted_request() -> None:
stage0 = FakeStageClient(stage_type="llm", final_output=True)
rpc_q: asyncio.Queue = asyncio.Queue()
running_counter = FakeRunningCounter()
orchestrator = Orchestrator(
request_async_queue=asyncio.Queue(),
output_async_queue=asyncio.Queue(),
rpc_async_queue=rpc_q,
stage_pools=_build_stage_pools([[stage0]]),
duplex_runtime_extension=MiniCPMO45DuplexRuntimeExtension(),
enable_duplex_control=True,
running_counter=running_counter,
)
open_message = _duplex_open_message("sid-duplex-counter")
await _handle_duplex(orchestrator, open_message)
assert rpc_q.get_nowait().ok is True
assert running_counter.value == 0
await _handle_duplex(
orchestrator,
AppendDuplexInputMessage(
control_id="append-duplex-counter",
fence=open_message.fence,
session_id=open_message.session_id,
mode=DuplexInputMode.APPEND_AUDIO_CHUNK.value,
payload={"is_speech": True},
),
)
assert rpc_q.get_nowait().ok is True
assert running_counter.value == 1
await _handle_duplex(
orchestrator,
CloseDuplexSessionMessage(
control_id="close-duplex-counter",
fence=open_message.fence,
session_id=open_message.session_id,
),
)
assert rpc_q.get_nowait().ok is True
assert running_counter.value == 0
@pytest.mark.asyncio
async def test_duplex_failed_append_does_not_advance_sequence_or_fence() -> None:
class FailingPlanExtension(MiniCPMO45DuplexRuntimeExtension):
def plan_append(self, **kwargs):
raise RuntimeError("planned append failure")
stage0 = FakeStageClient(stage_type="llm", final_output=True)
rpc_q: asyncio.Queue = asyncio.Queue()
orchestrator = Orchestrator(
request_async_queue=asyncio.Queue(),
output_async_queue=asyncio.Queue(),
rpc_async_queue=rpc_q,
stage_pools=_build_stage_pools([[stage0]]),
duplex_runtime_extension=FailingPlanExtension(),
enable_duplex_control=True,
)
fence = DuplexFence("sid-append-rollback", turn_id=1)
session = orchestrator.duplex_sessions.open_session(
DuplexFence("sid-append-rollback"),
capabilities=DuplexRuntimeCapabilities(
input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK},
),
)
await _handle_duplex(
orchestrator,
AppendDuplexInputMessage(
control_id="append-rollback",
fence=fence,
session_id=fence.session_id,
mode=DuplexInputMode.APPEND_AUDIO_CHUNK.value,
payload={"is_speech": True},
),
)
result = rpc_q.get_nowait()
assert result.ok is False
assert session.fence == DuplexFence("sid-append-rollback")
assert session.input_seq == 0
assert session.input_turn_seq == 0
assert stage0.add_request_calls == []
@pytest.mark.asyncio
async def test_duplex_duplicate_append_operation_is_submitted_once() -> None:
stage0 = FakeStageClient(stage_type="llm", final_output=True)
rpc_q: asyncio.Queue = asyncio.Queue()
orchestrator = Orchestrator(
request_async_queue=asyncio.Queue(),
output_async_queue=asyncio.Queue(),
rpc_async_queue=rpc_q,
stage_pools=_build_stage_pools([[stage0]]),
duplex_runtime_extension=MiniCPMO45DuplexRuntimeExtension(),
enable_duplex_control=True,
)
fence = DuplexFence("sid-idempotent-append")
session = orchestrator.duplex_sessions.open_session(
fence,
capabilities=DuplexRuntimeCapabilities(
input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK},
),
)
for control_id in ("append-first", "append-timeout-retry"):
await _handle_duplex(
orchestrator,
AppendDuplexInputMessage(
control_id=control_id,
operation_id="physical-input-1",
fence=fence,
session_id=fence.session_id,
mode=DuplexInputMode.APPEND_AUDIO_CHUNK.value,
payload={"is_speech": True},
),
)
first = rpc_q.get_nowait()
retry = rpc_q.get_nowait()
assert first.ok is True
assert retry.ok is True
assert first.stage_results == retry.stage_results
assert len(stage0.add_request_calls) == 1
assert session.input_seq == 1
@pytest.mark.asyncio
async def test_duplex_barge_in_aborts_bound_stage_requests_before_releasing_fence() -> None:
stage0 = FakeStageClient(stage_type="llm", final_output=False)
stage1 = FakeStageClient(stage_type="llm", final_output=True)
stage_pools = _build_stage_pools(
[[stage0], [stage1]],
output_processors=[FakeOutputProcessor(), FakeOutputProcessor()],
stage_vllm_configs=[
SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)),
SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)),
],
)
rpc_q: asyncio.Queue = asyncio.Queue()
orchestrator = Orchestrator(
request_async_queue=asyncio.Queue(),
output_async_queue=asyncio.Queue(),
rpc_async_queue=rpc_q,
stage_pools=stage_pools,
duplex_runtime_extension=MiniCPMO45DuplexRuntimeExtension(),
enable_duplex_control=True,
)
session = orchestrator.duplex_sessions.open_session(
DuplexFence("sid-stage-signal"),
capabilities=DuplexRuntimeCapabilities(
input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK},
),
)
session.bind_stage_request(0, "req-stage0", fence=session.fence)
session.bind_stage_request(1, "req-stage1", fence=session.fence)
assert stage_pools[0].select_replica_id("req-stage0") == 0
assert stage_pools[1].select_replica_id("req-stage1") == 0
session.append_input(
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
fence=session.fence,
)
await _handle_duplex(
orchestrator,
SignalDuplexTurnMessage(
control_id="ctrl-signal",
fence=session.fence,
next_fence=DuplexFence("sid-stage-signal", epoch=1),
session_id="sid-stage-signal",
event="barge_in",
),
)
assert stage0.abort_calls == [["req-stage0"]]
assert stage1.abort_calls == [["req-stage1"]]
assert stage0.collective_rpc_calls == []
assert stage1.collective_rpc_calls == []
assert session.stage_bindings == {}
assert session.fence == DuplexFence("sid-stage-signal", epoch=1)
result = rpc_q.get_nowait()
assert result.ok is True
@pytest.mark.asyncio
async def test_duplex_late_barge_in_releases_only_cancelled_fence_bindings() -> None:
stage0 = FakeStageClient(stage_type="llm", final_output=False)
stage1 = FakeStageClient(stage_type="llm", final_output=True)
stage_pools = _build_stage_pools(
[[stage0], [stage1]],
output_processors=[FakeOutputProcessor(), FakeOutputProcessor()],
stage_vllm_configs=[
SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)),
SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)),
],
)
rpc_q: asyncio.Queue = asyncio.Queue()
orchestrator = Orchestrator(
request_async_queue=asyncio.Queue(),
output_async_queue=asyncio.Queue(),
rpc_async_queue=rpc_q,
stage_pools=stage_pools,
enable_duplex_control=True,
)
cancelled_fence = DuplexFence("sid-late-stage-signal")
session = orchestrator.duplex_sessions.open_session(
cancelled_fence,
capabilities=DuplexRuntimeCapabilities(
input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK},
),
)
session.bind_stage_request(0, "req-stage0", fence=cancelled_fence)
session.bind_stage_request(1, "req-stage1", fence=cancelled_fence)
assert stage_pools[0].select_replica_id("req-stage0") == 0
assert stage_pools[1].select_replica_id("req-stage1") == 0
current_fence = DuplexFence("sid-late-stage-signal", epoch=1)
session.accept_fence(current_fence)
await _handle_duplex(
orchestrator,
SignalDuplexTurnMessage(
control_id="ctrl-late-signal",
fence=cancelled_fence,
next_fence=current_fence,
session_id="sid-late-stage-signal",
event="barge_in",
),
)
assert stage0.abort_calls == [["req-stage0"]]
assert stage1.abort_calls == [["req-stage1"]]
assert session.stage_bindings == {}
assert session.fence == current_fence
result = rpc_q.get_nowait()
assert result.ok is True
@pytest.mark.asyncio
async def test_duplex_cancel_without_next_fence_is_rejected_without_releasing_bindings() -> None:
stage0 = FakeStageClient(stage_type="llm", final_output=True)
stage_pools = _build_stage_pools(
[[stage0]],
output_processors=[FakeOutputProcessor()],
stage_vllm_configs=[SimpleNamespace(model_config=SimpleNamespace(max_model_len=64))],
)
rpc_q: asyncio.Queue = asyncio.Queue()
orchestrator = Orchestrator(
request_async_queue=asyncio.Queue(),
output_async_queue=asyncio.Queue(),
rpc_async_queue=rpc_q,
stage_pools=stage_pools,
enable_duplex_control=True,
)
fence = DuplexFence("sid-cancel-contract")
session = orchestrator.duplex_sessions.open_session(fence)
session.bind_stage_request(0, "req-live", fence=fence)
await _handle_duplex(
orchestrator,
SignalDuplexTurnMessage(
control_id="cancel-without-next",
fence=fence,
session_id=fence.session_id,
event="input.cancel",
),
)
result = rpc_q.get_nowait()
assert result.ok is False
assert result.error_count == 1
assert "next_fence" in result.stage_results[0]["result"]["error"]
assert session.fence == fence
assert session.stage_request_ids() == ["req-live"]
assert stage0.abort_calls == []
@pytest.mark.asyncio
async def test_duplex_session_update_replaces_runtime_config() -> None:
rpc_q: asyncio.Queue = asyncio.Queue()
orchestrator = Orchestrator(
request_async_queue=asyncio.Queue(),
output_async_queue=asyncio.Queue(),
rpc_async_queue=rpc_q,
stage_pools=[],
enable_duplex_control=True,
)
fence = DuplexFence("sid-update-config")
session = orchestrator.duplex_sessions.open_session(
fence,
session_config={"temperature": 0.7},
)
await _handle_duplex(
orchestrator,
SignalDuplexTurnMessage(
control_id="update-config",
fence=fence,
session_id=fence.session_id,
event="session.update",
session_config={"temperature": 0.0, "instructions": "updated"},
),
)
result = rpc_q.get_nowait()
assert result.ok is True
assert session.session_config == {"temperature": 0.0, "instructions": "updated"}
@pytest.mark.asyncio
async def test_duplex_session_update_refreshes_next_append_sampling_params() -> None:
stage0 = FakeStageClient(stage_type="llm", final_output=True)
stage_pools = _build_stage_pools(
[[stage0]],
output_processors=[FakeOutputProcessor()],
stage_vllm_configs=[SimpleNamespace(model_config=SimpleNamespace(max_model_len=64))],
)
rpc_q: asyncio.Queue = asyncio.Queue()
orchestrator = Orchestrator(
request_async_queue=asyncio.Queue(),
output_async_queue=asyncio.Queue(),
rpc_async_queue=rpc_q,
stage_pools=stage_pools,
duplex_runtime_extension=MiniCPMO45DuplexRuntimeExtension(),
enable_duplex_control=True,
)
fence = DuplexFence("sid-update-policy")
session = orchestrator.duplex_sessions.open_session(
fence,
capabilities=DuplexRuntimeCapabilities(
input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK},
),
runtime_config={
"duplex_stage_max_tokens": {"0": 2},
"duplex_stage_sampling_params": {"0": {"stop_token_ids": [151645]}},
},
)
req_state = _duplex_request_state(orchestrator, session, stage_id=0)
assert req_state is not None
assert req_state.sampling_params_list[0].max_tokens == 2
await _handle_duplex(
orchestrator,
SignalDuplexTurnMessage(
control_id="update-policy",
fence=fence,
session_id=fence.session_id,
event="session.update",
runtime_config={
"duplex_stage_max_tokens": {"0": 7},
"duplex_stage_sampling_params": {"0": {"stop_token_ids": [151705]}},
},
),
)
assert rpc_q.get_nowait().ok is True
await _handle_duplex(
orchestrator,
AppendDuplexInputMessage(
control_id="append-updated-policy",
fence=fence,
session_id=fence.session_id,
mode=DuplexInputMode.APPEND_AUDIO_CHUNK.value,
payload={"is_speech": True},
),
)
assert rpc_q.get_nowait().ok is True
assert req_state.sampling_params_list[0].max_tokens == 7
assert req_state.sampling_params_list[0].stop_token_ids == [151705]
submitted_request = stage0.add_request_calls[0][0]
assert submitted_request.sampling_params.stop_token_ids == [151705]
assert submitted_request.sampling_params.max_tokens == 7
@pytest.mark.asyncio
async def test_duplex_invalid_session_update_preserves_previous_config() -> None:
stage0 = FakeStageClient(stage_type="llm", final_output=True)
rpc_q: asyncio.Queue = asyncio.Queue()
orchestrator = Orchestrator(
request_async_queue=asyncio.Queue(),
output_async_queue=asyncio.Queue(),
rpc_async_queue=rpc_q,
stage_pools=_build_stage_pools([[stage0]]),
duplex_runtime_extension=MiniCPMO45DuplexRuntimeExtension(),
enable_duplex_control=True,
)
fence = DuplexFence("sid-invalid-update")
original_runtime_config = {
"duplex_stage_sampling_params": {"0": {"stop_token_ids": [151645]}},
}
session = orchestrator.duplex_sessions.open_session(
fence,
runtime_config=original_runtime_config,
)
await _handle_duplex(
orchestrator,
SignalDuplexTurnMessage(
control_id="invalid-update",
fence=fence,
session_id=fence.session_id,
event="session.update",
runtime_config={
"duplex_stage_sampling_params": {"0": {"stop_token_ids": 123}},
},
),
)
result = rpc_q.get_nowait()
assert result.ok is False
assert session.runtime_config == original_runtime_config
assert session.config_generation == 0
@pytest.mark.asyncio
async def test_duplex_arbitrary_non_cancel_signal_is_rejected() -> None:
rpc_q: asyncio.Queue = asyncio.Queue()
orchestrator = Orchestrator(
request_async_queue=asyncio.Queue(),
output_async_queue=asyncio.Queue(),
rpc_async_queue=rpc_q,
stage_pools=[],
enable_duplex_control=True,
)
fence = DuplexFence("sid-unsupported-signal")
orchestrator.duplex_sessions.open_session(fence)
await _handle_duplex(
orchestrator,
SignalDuplexTurnMessage(
control_id="unsupported-signal",
fence=fence,
session_id=fence.session_id,
event="turn.end",
),
)
result = rpc_q.get_nowait()
assert result.ok is False
assert "unsupported duplex runtime signal" in result.stage_results[0]["result"]["error"]
@pytest.mark.asyncio
async def test_duplex_cancel_rejects_late_old_append_and_accepts_next_epoch() -> None:
stage0 = FakeStageClient(stage_type="llm", final_output=True)
stage_pools = _build_stage_pools(
[[stage0]],
output_processors=[FakeOutputProcessor()],
stage_vllm_configs=[SimpleNamespace(model_config=SimpleNamespace(max_model_len=64))],
)
rpc_q: asyncio.Queue = asyncio.Queue()
orchestrator = Orchestrator(
request_async_queue=asyncio.Queue(),
output_async_queue=asyncio.Queue(),
rpc_async_queue=rpc_q,
stage_pools=stage_pools,
duplex_runtime_extension=MiniCPMO45DuplexRuntimeExtension(),
enable_duplex_control=True,
)
cancelled_fence = DuplexFence("sid-cancel-late-append")
next_fence = DuplexFence("sid-cancel-late-append", epoch=1)
session = orchestrator.duplex_sessions.open_session(
cancelled_fence,
capabilities=DuplexRuntimeCapabilities(
input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK},
),
)
await _handle_duplex(
orchestrator,
SignalDuplexTurnMessage(
control_id="cancel-old-fence",
fence=cancelled_fence,
next_fence=next_fence,
session_id=session.session_id,
event="input.cancel",
),
)
cancel_result = rpc_q.get_nowait()
assert cancel_result.ok is True
assert session.fence == next_fence
await _handle_duplex(
orchestrator,
AppendDuplexInputMessage(
control_id="late-old-append",
fence=cancelled_fence,
session_id=session.session_id,
mode=DuplexInputMode.APPEND_AUDIO_CHUNK.value,
payload={"is_speech": True},
),
)
stale_result = rpc_q.get_nowait()
assert stale_result.ok is False
assert stage0.add_request_calls == []
assert session.stage_bindings == {}
await _handle_duplex(
orchestrator,
AppendDuplexInputMessage(
control_id="next-epoch-append",
fence=next_fence,
session_id=session.session_id,
mode=DuplexInputMode.APPEND_AUDIO_CHUNK.value,
payload={"is_speech": True},
),
)
next_result = rpc_q.get_nowait()
assert next_result.ok is True
assert len(stage0.add_request_calls) == 1
assert session.stage_bindings[0].fence == next_fence
@pytest.mark.asyncio
async def test_duplex_append_updates_bridge_turn_id_on_long_lived_stage0_request() -> None:
stage0 = FakeStageClient(stage_type="llm", final_output=True)
stage_pools = _build_stage_pools(
[[stage0]],
output_processors=[FakeOutputProcessor()],
stage_vllm_configs=[SimpleNamespace(model_config=SimpleNamespace(max_model_len=64))],
)
orchestrator = Orchestrator(
request_async_queue=asyncio.Queue(),
output_async_queue=asyncio.Queue(),
rpc_async_queue=asyncio.Queue(),
stage_pools=stage_pools,
duplex_runtime_extension=MiniCPMO45DuplexRuntimeExtension(),
enable_duplex_control=True,
)
session = orchestrator.duplex_sessions.open_session(
DuplexFence("sid-bridge-turn"),
capabilities=DuplexRuntimeCapabilities(
input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK},
),
session_config={
"voice": "test",
},
runtime_config={
"duplex_stage_sampling_params": {
"0": {"stop_token_ids": [151645]},
},
},
)
await _handle_duplex(
orchestrator,
AppendDuplexInputMessage(
control_id="append-1",
fence=DuplexFence("sid-bridge-turn"),
session_id="sid-bridge-turn",
mode=DuplexInputMode.APPEND_AUDIO_CHUNK.value,
payload={"is_speech": True},
),
)
req_state1 = _duplex_request_state(orchestrator, session, stage_id=0)
assert req_state1 is not None
duplex_state1 = req_state1.streaming.bridge_states["duplex"]
assert duplex_state1["session_id"] == "sid-bridge-turn"
assert duplex_state1["epoch"] == 0
assert duplex_state1["turn_id"] == 0
assert duplex_state1["session_config"] == session.session_config
assert req_state1.sampling_params_list[0].stop_token_ids == [151645]
submitted_request = stage0.add_request_calls[0][0]
assert submitted_request.sampling_params.stop_token_ids == [151645]
assert submitted_request.sampling_params.max_tokens == 1
await _handle_duplex(
orchestrator,
AppendDuplexInputMessage(
control_id="append-2",
fence=DuplexFence("sid-bridge-turn", turn_id=1, response_seq=1),
session_id="sid-bridge-turn",
mode=DuplexInputMode.APPEND_AUDIO_CHUNK.value,
payload={"is_speech": True, "new_user_turn": True},
),
)
req_state2 = _duplex_request_state(orchestrator, session, stage_id=0)
assert req_state2 is req_state1
duplex_state2 = req_state2.streaming.bridge_states["duplex"]
assert duplex_state2["turn_id"] == 1
assert duplex_state2["epoch"] == 0
expected_request_id = duplex_resource_request_id(DuplexFence("sid-bridge-turn"), "stage0")
assert [call[0].request_id for call in stage0.add_request_calls] == [
expected_request_id,
expected_request_id,
]
# ---------------------------------------------------------------------------
# Multi-replica tests
# ---------------------------------------------------------------------------
@@ -2641,61 +1855,6 @@ async def test_stage_pool_process_llm_raw_outputs_mutates_iteration_stats() -> N
assert iteration_stats.num_generation_tokens == 3
@pytest.mark.asyncio
async def test_duplex_reaper_loop_waits_between_ticks():
class _Plane:
def __init__(self) -> None:
self.calls = 0
async def reap_expired(self) -> int:
self.calls += 1
return 0
orchestrator = object.__new__(Orchestrator)
orchestrator.duplex_control_plane = _Plane()
orchestrator._duplex_reaper_interval_s = 0.01
orchestrator._shutdown_event = asyncio.Event()
task = asyncio.create_task(orchestrator._duplex_reaper_loop())
await asyncio.sleep(0.035)
orchestrator._shutdown_event.set()
await task
assert 2 <= orchestrator.duplex_control_plane.calls <= 5
@pytest.mark.asyncio
@pytest.mark.parametrize("first_cleanup_delay", [0.0, 0.05], ids=["immediate", "delayed"])
async def test_duplex_reaper_loop_survives_one_cleanup_failure(first_cleanup_delay: float) -> None:
recovered = asyncio.Event()
class _Plane:
def __init__(self) -> None:
self.calls = 0
async def reap_expired(self) -> int:
self.calls += 1
if self.calls == 1:
await asyncio.sleep(first_cleanup_delay)
raise RuntimeError("transient cleanup failure")
recovered.set()
return 0
orchestrator = object.__new__(Orchestrator)
orchestrator.duplex_control_plane = _Plane()
orchestrator._duplex_reaper_interval_s = 0.01
orchestrator._shutdown_event = asyncio.Event()
task = asyncio.create_task(orchestrator._duplex_reaper_loop())
try:
# Wait for recovery itself, including when cleanup exceeds the old 35 ms window.
await asyncio.wait_for(recovered.wait(), timeout=5.0)
assert orchestrator.duplex_control_plane.calls >= 2
finally:
orchestrator._shutdown_event.set()
await asyncio.wait_for(task, timeout=5.0)
@pytest.mark.asyncio
async def test_abort_retry_does_not_repeat_successful_stage_abort():
class _Pool:
@@ -2736,45 +1895,58 @@ async def test_abort_retry_does_not_repeat_successful_stage_abort():
@pytest.mark.asyncio
async def test_request_cleanup_failure_is_deferred_to_control_plane():
class _FailingPool:
async def abort_requests(self, request_ids: list[str]) -> None:
raise RuntimeError("stage abort failed")
async def test_duplex_session_request_error_finish_is_delivered_as_request_error() -> None:
"""A session-owned request the scheduler finished with FinishReason.ERROR
(e.g. its prompt could not grow past max_model_len) must reach the
session's consumer as a request-scoped error, not vanish: its terminal
outputs are not processed like other requests, and the output processor
may already have dropped the request state."""
output_queue: asyncio.Queue = asyncio.Queue()
orchestrator = Orchestrator(
request_async_queue=asyncio.Queue(),
output_async_queue=output_queue,
rpc_async_queue=asyncio.Queue(),
stage_pools=[],
)
duplex_state = OrchestratorRequestState(request_id="duplex-req", session_owned=True)
plain_state = OrchestratorRequestState(request_id="plain-req")
reason = "context_length_exceeded: streaming session prompt would grow to 8220 tokens, above max_model_len 8192"
def release_bindings(self, request_ids: list[str]) -> None:
pass
await orchestrator._report_duplex_session_request_error(
0,
0,
OmniEngineCoreOutput(
request_id="duplex-req", new_token_ids=[], finish_reason=FinishReason.ERROR, stop_reason=reason
),
duplex_state,
)
error = output_queue.get_nowait()
assert isinstance(error, ErrorMessage)
assert error.request_id == "duplex-req"
assert error.stage_id == 0
assert error.fatal is False
assert error.error == reason
class _Plane:
def __init__(self) -> None:
self.deferred: list[str] = []
self.finalized: list[str] = []
def close_sessions_for_request_ids(self, request_ids: list[str], **kwargs):
assert kwargs == {"abort": True, "cleanup_in_progress": True}
return {"sid-cleanup": ["req-a"]}
def defer_request_cleanups(self, session_ids: list[str]) -> None:
self.deferred.extend(session_ids)
def finalize_closed_sessions(self, session_ids: list[str]) -> None:
self.finalized.extend(session_ids)
from vllm_omni.engine.cfg_companion_tracker import CfgCompanionTracker
orchestrator = object.__new__(Orchestrator)
orchestrator.stage_pools = [_FailingPool()]
orchestrator.duplex_control_plane = _Plane()
orchestrator._pd_kv_params = {}
orchestrator.request_states = {}
orchestrator._running_counter = None
orchestrator._cfg_tracker = CfgCompanionTracker()
with pytest.raises(RuntimeError, match="stage abort failed"):
await orchestrator._cleanup_request_ids(
["req-a"],
abort=True,
close_duplex_sessions=True,
)
assert orchestrator.duplex_control_plane.deferred == ["sid-cleanup"]
assert orchestrator.duplex_control_plane.finalized == []
# An ordinary segment stop, a segment-finished error and a request that is
# not session-owned produce nothing.
await orchestrator._report_duplex_session_request_error(
0,
0,
OmniEngineCoreOutput(request_id="duplex-req", new_token_ids=[], finish_reason=FinishReason.STOP),
duplex_state,
)
await orchestrator._report_duplex_session_request_error(
0,
0,
OmniEngineCoreOutput(
request_id="duplex-req", new_token_ids=[], finish_reason=FinishReason.ERROR, is_segment_finished=True
),
duplex_state,
)
await orchestrator._report_duplex_session_request_error(
0,
0,
OmniEngineCoreOutput(request_id="plain-req", new_token_ids=[], finish_reason=FinishReason.ERROR),
plain_state,
)
assert output_queue.empty()
@@ -1054,7 +1054,7 @@ async def test_streaming_input_processor_client_error_does_not_forward_terminal_
final_stage_id=1,
)
state.streaming.enabled = True
assert state.duplex_identity is None
assert state.session_owned is False
orchestrator.request_states[state.request_id] = state
try:
await orchestrator._route_output(0, 0, _build_request_output(state.request_id), state, stage_metrics=None)
@@ -1074,34 +1074,14 @@ async def test_streaming_input_processor_client_error_does_not_forward_terminal_
q.close()
@pytest.mark.asyncio
async def test_duplex_input_processor_failure_is_request_scoped(orchestrator_factory, monkeypatch) -> None:
class FailingStage(FakeStageClient):
def process_engine_inputs(self, *_args, **_kwargs):
raise ValueError("No latent or hidden_states found in thinker output")
fixture = orchestrator_factory(
[FakeStageClient(stage_type="llm"), FailingStage(stage_type="llm", final_output=True)]
)
state = OrchestratorRequestState(
request_id="bad",
prompt=SimpleNamespace(request_id="bad", prompt_token_ids=[1]),
sampling_params_list=[_sampling_params(), _sampling_params()],
final_stage_id=1,
duplex_identity=SimpleNamespace(),
)
async def no_cleanup(*_args, **_kwargs):
pass
monkeypatch.setattr(fixture.orchestrator, "_cleanup_request_ids", no_cleanup)
try:
await fixture.orchestrator._forward_to_next_stage_unguarded("bad", 0, _build_request_output("raw"), state)
error = await _wait_for_error_message(fixture, request_id="bad")
assert error.fatal is False
finally:
fixture.request_sync_q.put_nowait(ShutdownRequestMessage())
fixture.thread.join(timeout=5)
# ``test_duplex_input_processor_failure_is_request_scoped`` lived here upstream,
# where one Orchestrator served both turn-based and duplex requests. Duplex
# request ownership now belongs to DuplexOrchestrator, and the base
# ``_handle_forward_failure`` deliberately absorbs nothing, so the equivalent
# assertion is
# ``tests/engine/test_duplex_orchestrator.py::test_forward_failure_closes_the_owning_session``
# -- which also checks the stage abort, the session teardown and the
# error-before-session.expired ordering.
@pytest.mark.asyncio
@@ -2,10 +2,13 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Streaming segment-boundary state must be keyed by stage.
``Orchestrator._orchestration_loop`` polls every stage into the same
``OrchestratorBase._orchestration_loop`` polls every stage into the same
``OrchestratorRequestState``. While the segment-boundary fields lived in a single
flat slot per request, whichever stage polled most recently overwrote the others,
and the per-stage consumers then read back a different stage's boundary.
The duplex consumer of the per-stage segment (``DuplexOrchestrator``'s output
context) is covered in ``test_duplex_orchestrator.py``.
"""
from __future__ import annotations
@@ -15,8 +18,6 @@ from dataclasses import dataclass, field
import pytest
from vllm.sampling_params import SamplingParams
from vllm_omni.engine.duplex.contracts import DuplexRequestIdentity
from vllm_omni.engine.duplex.messages import DuplexFence
from vllm_omni.engine.orchestrator import (
Orchestrator,
OrchestratorRequestState,
@@ -54,22 +55,19 @@ class _RecordingPool:
return _StageMetrics()
def _duplex_req_state(request_id: str = "req-duplex") -> OrchestratorRequestState:
def _streaming_req_state(request_id: str = "req-streaming") -> OrchestratorRequestState:
req_state = OrchestratorRequestState(
request_id=request_id,
sampling_params_list=[SamplingParams(max_tokens=1), SamplingParams(max_tokens=1)],
final_stage_id=TALKER_STAGE,
session_owned=True,
)
req_state.streaming.enabled = True
req_state.duplex_identity = DuplexRequestIdentity(
session_id="session-1",
fence=DuplexFence(session_id="session-1"),
)
return req_state
def test_duplex_output_context_reads_only_its_own_stage_segment() -> None:
req_state = _duplex_req_state()
def test_segment_accessor_reads_only_its_own_stage() -> None:
req_state = _streaming_req_state()
req_state.streaming.segments[THINKER_STAGE] = StreamingSegmentState(
finished=True,
token_ids=[11, 22],
@@ -78,26 +76,28 @@ def test_duplex_output_context_reads_only_its_own_stage_segment() -> None:
# The talker polls after the thinker and is still mid-segment.
req_state.streaming.segments[TALKER_STAGE] = StreamingSegmentState(finished=False)
thinker = Orchestrator._duplex_output_context(req_state, stage_id=THINKER_STAGE)
talker = Orchestrator._duplex_output_context(req_state, stage_id=TALKER_STAGE)
assert thinker is not None
assert talker is not None
thinker = req_state.streaming.segment(THINKER_STAGE)
talker = req_state.streaming.segment(TALKER_STAGE)
# These are the assertions that fail on the flat field: the talker is written
# second, so the thinker loses its boundary to whichever stage polled last.
assert thinker.segment_finished is True
assert thinker.segment_token_ids == (11, 22)
assert thinker.segment_output_metadata == {"stage": "thinker"}
assert thinker.finished is True
assert thinker.token_ids == [11, 22]
assert thinker.output_metadata == {"stage": "thinker"}
assert talker.segment_finished is False
assert talker.segment_token_ids == ()
assert talker.segment_output_metadata == {}
assert talker.finished is False
assert talker.token_ids == []
assert talker.output_metadata == {}
# A stage that never reported a boundary gets a fresh, unshared segment.
unreported = req_state.streaming.segment(2)
unreported.output_metadata["poisoned"] = True
assert req_state.streaming.segment(2).output_metadata == {}
@pytest.mark.asyncio
async def test_segment_boundary_on_one_stage_does_not_build_metrics_on_another() -> None:
req_state = _duplex_req_state()
req_state = _streaming_req_state()
req_state.streaming.segments[THINKER_STAGE] = StreamingSegmentState(finished=True)
talker_pool = _RecordingPool()
@@ -7,23 +7,17 @@ import asyncio
import queue
from types import SimpleNamespace
from typing import Any
from unittest.mock import ANY, AsyncMock, MagicMock
from unittest.mock import AsyncMock, MagicMock
import janus
import pytest
from vllm.outputs import CompletionOutput, RequestOutput
from vllm.sampling_params import SamplingParams
from vllm_omni.engine.duplex.contracts import (
DuplexStageRequestContext,
DuplexStageSubmission,
)
from vllm_omni.engine.duplex.messages import DuplexFence
from vllm_omni.engine.orchestrator import (
Orchestrator,
OrchestratorRequestState,
StreamingSegmentState,
_OrchestratorDuplexStagePort,
)
from vllm_omni.engine.stage_pool import StagePool
@@ -131,64 +125,6 @@ class FakePrewarmPool:
return self.stage_client if self.get_bound_replica_id(request_id) is not None else None
def _duplex_stage_port_submission():
stage_pools = []
for stage_id in range(3):
pool = SimpleNamespace(
stage_client=SimpleNamespace(default_sampling_params=SamplingParams(max_tokens=1)),
stage_vllm_config=SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)),
submit_initial=AsyncMock(return_value=stage_id + 10),
submit_update=AsyncMock(return_value=stage_id + 20),
)
stage_pools.append(pool)
request_states: dict[str, OrchestratorRequestState] = {}
prewarm = AsyncMock()
port = _OrchestratorDuplexStagePort(
stage_pools=stage_pools,
request_states=request_states,
running_counter=None,
cleanup_request_ids=AsyncMock(),
async_chunk=True,
prewarm_async_chunk_stages=prewarm,
)
context = DuplexStageRequestContext(
request_id="req-duplex",
session_id="session-duplex",
fence=DuplexFence("session-duplex"),
stage_id=0,
final_stage_id=2,
config_generation=0,
sampling_params=tuple(SamplingParams(max_tokens=1) for _ in range(3)),
)
port.ensure_request(context)
submission = DuplexStageSubmission(
context=context,
prompt={"prompt_token_ids": [1, 2]},
already_submitted=False,
)
return port, stage_pools, request_states, prewarm, submission
def test_duplex_bridge_state_catches_up_after_serving_turn_boundary() -> None:
port, _, request_states, _, submission = _duplex_stage_port_submission()
request_state = request_states[submission.context.request_id]
bridge_state = request_state.streaming.bridge_states["duplex"]
bridge_state["model_turn_id"] = 0
next_context = DuplexStageRequestContext(
request_id=submission.context.request_id,
session_id=submission.context.session_id,
fence=DuplexFence(submission.context.session_id, turn_id=1),
stage_id=submission.context.stage_id,
final_stage_id=submission.context.final_stage_id,
config_generation=submission.context.config_generation,
sampling_params=submission.context.sampling_params,
)
port.ensure_request(next_context)
assert bridge_state["model_turn_id"] == 1
def _request_output(request_id: str) -> RequestOutput:
completion = CompletionOutput(
index=0,
@@ -279,13 +215,12 @@ async def test_async_prewarm_skips_outgoing_only_stage(payload_sender_info) -> N
stage1.stage_client.get_payload_sender_info = MagicMock(return_value=payload_sender_info)
orchestrator.stage_pools = [stage0, stage1, stage2]
orchestrator._emit_tx_edge = lambda **_kwargs: None
orchestrator._record_duplex_stage_submission = MagicMock()
orchestrator._on_stage_submitted = MagicMock()
req_state = OrchestratorRequestState(
request_id="req-prewarm",
prompt={"prompt_token_ids": [1, 2]},
sampling_params_list=[SamplingParams(max_tokens=1) for _ in range(3)],
final_stage_id=2,
duplex_identity=SimpleNamespace(),
)
prewarmed = await orchestrator._prewarm_async_chunk_stages(
@@ -306,7 +241,7 @@ async def test_async_prewarm_skips_outgoing_only_stage(payload_sender_info) -> N
assert stage2.submitted[0].resumable is True
assert 1 not in req_state.stage_submit_ts
assert 2 in req_state.stage_submit_ts
orchestrator._record_duplex_stage_submission.assert_called_once_with(
orchestrator._on_stage_submitted.assert_called_once_with(
2,
"req-prewarm",
0,
@@ -314,42 +249,6 @@ async def test_async_prewarm_skips_outgoing_only_stage(payload_sender_info) -> N
)
@pytest.mark.asyncio
async def test_duplex_prewarm_runs_after_first_stage0_submission() -> None:
port, stage_pools, request_states, prewarm, submission = _duplex_stage_port_submission()
prewarm.return_value = True
result = await port.submit(submission)
assert result.stage_id == 0
stage_pools[0].submit_initial.assert_awaited_once()
prewarm.assert_awaited_once_with("req-duplex", ANY, request_states["req-duplex"])
@pytest.mark.asyncio
async def test_duplex_submit_bails_out_when_prewarm_failed_the_request() -> None:
"""A failed prewarm has already aborted the request and popped its state.
Returning a success result here would hand the control plane a replica for a
request that no longer exists, and the trailing bookkeeping would re-register
a running counter the cleanup just released -- a leak that never decrements.
"""
port, stage_pools, request_states, prewarm, submission = _duplex_stage_port_submission()
counter = MagicMock()
port._running_counter = counter
prewarm.return_value = False
request_state = request_states["req-duplex"]
with pytest.raises(RuntimeError, match="prewarm failed"):
await port.submit(submission)
prewarm.assert_awaited_once()
assert request_state.duplex_stage_fences == {}
assert request_state.stage_submit_ts == {}
assert request_state.running_counter_registered is False
counter.increment.assert_not_called()
@pytest.mark.asyncio
async def test_async_route_forwards_to_outgoing_only_stage() -> None:
orchestrator = object.__new__(Orchestrator)
@@ -0,0 +1,143 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Snapshot metadata must not accumulate as if it were generated content."""
import pytest
import torch
from vllm.outputs import PoolingRequestOutput
from vllm.sampling_params import RequestOutputKind
from vllm.v1.engine import FinishReason
from vllm_omni.outputs.mm_outputs import MultimodalPayload
from vllm_omni.outputs.output_modality import OutputModality
from vllm_omni.outputs.output_processor import MultimodalOutputProcessor, OmniRequestState
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
RATE_KEYS = ("sr", "sample_rate", "audio_sample_rate")
def _state(kind: RequestOutputKind) -> OmniRequestState:
return OmniRequestState(
request_id="audio",
external_req_id="audio",
parent_req=None,
request_index=0,
lora_request=None,
prompt=None,
prompt_token_ids=[0],
prompt_embeds=None,
logprobs_processor=None,
detokenizer=None,
max_tokens_param=None,
arrival_time=0.0,
queue=None,
log_stats=False,
stream_interval=1,
output_kind=kind,
)
@pytest.mark.parametrize("key", RATE_KEYS)
@pytest.mark.parametrize("representation", ["integer", "scalar_tensor", "vector_tensor", "metadata_tensor"])
def test_rate_is_latest_snapshot_before_consolidation(key, representation):
payload = MultimodalPayload()
for index, rate in enumerate((16000, 24000, 48000)):
value = (
rate if representation == "integer" else torch.tensor([rate] if representation == "vector_tensor" else rate)
)
incoming = (
MultimodalPayload(tensors={"audio": torch.full((2,), index)}, metadata={key: value})
if representation == "metadata_tensor"
else MultimodalPayload.from_dict({"audio": torch.full((2,), index), key: value})
)
assert incoming is not None
payload = payload.merged_with(incoming)
snapshot = payload[key]
assert not isinstance(snapshot, list)
assert int(snapshot) == rate
assert payload.get(key) is payload.to_dict()[key]
payload = payload.merged_with(MultimodalPayload(tensors={"audio": torch.full((2,), 3)}))
assert int(payload[key]) == 48000 # Missing metadata means retain, not clear.
payload.consolidate_tensors(OutputModality.AUDIO)
payload.consolidate_metadata()
torch.testing.assert_close(payload["audio"], torch.arange(4).repeat_interleave(2))
@pytest.mark.parametrize("key", RATE_KEYS)
@pytest.mark.parametrize("tensor_first", [False, True])
def test_rate_representation_change_removes_stale_partition(key, tensor_first):
first = torch.tensor(16000) if tensor_first else 16000
last = 24000 if tensor_first else torch.tensor(24000)
payload = MultimodalPayload.from_dict({key: first})
incoming = MultimodalPayload.from_dict({key: last})
assert payload is not None and incoming is not None
incoming_keys = (set(incoming.tensors), set(incoming.metadata))
payload = payload.merged_with(incoming)
assert int(payload[key]) == 24000
assert payload[key] is payload.to_dict()[key]
assert (key in payload.tensors) != (key in payload.metadata)
assert incoming[key] is last
assert incoming_keys == (set(incoming.tensors), set(incoming.metadata))
@pytest.mark.parametrize("key", RATE_KEYS)
def test_self_merge_retains_snapshot_and_content(key):
payload = MultimodalPayload.from_dict({key: torch.tensor(24000), "audio": torch.tensor([1.0, 2.0])})
assert payload is not None
payload = payload.merged_with(payload)
assert int(payload[key]) == 24000
payload.consolidate_tensors(OutputModality.AUDIO)
torch.testing.assert_close(payload["audio"], torch.tensor([1.0, 2.0, 1.0, 2.0]))
@pytest.mark.parametrize("key", RATE_KEYS)
@pytest.mark.parametrize("kind", [RequestOutputKind.DELTA, RequestOutputKind.CUMULATIVE])
def test_long_stream_keeps_rate_bounded_without_losing_audio(key, kind):
state = _state(kind)
for index in range(1000):
state.add_multimodal_tensor(
{"model_outputs": torch.full((3,), index / 1000), key: torch.tensor(24000)}, "audio"
)
output = state.make_request_output([], None, None, None)
assert output is not None and not isinstance(output, PoolingRequestOutput)
payload = output.outputs[0].multimodal_output
assert isinstance(payload[key], torch.Tensor) and payload[key].numel() == 1
audio = payload["audio"]
assert audio.numel() == (3 if kind == RequestOutputKind.DELTA else 3 * (index + 1))
torch.testing.assert_close(audio[-3:], torch.full((3,), index / 1000))
assert ("audio" in state.mm_accumulated) is (kind == RequestOutputKind.CUMULATIVE)
final = state.make_request_output([], None, FinishReason.STOP, None)
assert final is not None and final.finished
if kind == RequestOutputKind.DELTA:
assert "audio" not in final.outputs[0].multimodal_output
@pytest.mark.parametrize("pending", [0, 3])
def test_abort_flushes_only_pending_content_with_latest_rate(pending):
state = _state(RequestOutputKind.DELTA)
processor = MultimodalOutputProcessor(tokenizer=None, log_stats=False, output_modality=OutputModality.AUDIO)
processor.request_states[state.request_id] = state
processor.external_req_ids[state.external_req_id] = [state.request_id]
for rate in (16000, 24000):
state.add_multimodal_tensor({"model_outputs": torch.ones(3), "sr": torch.tensor(rate)}, "audio")
output = state.make_request_output([], None, None, None)
assert output is not None
if pending:
state.add_multimodal_tensor({"model_outputs": torch.ones(pending)}, "audio")
aborted, outputs = processor.abort_requests_collecting_outputs([state.request_id], internal=True)
assert aborted == [state.request_id] and len(outputs) == 1 and outputs[0].finished
payload = outputs[0].outputs[0].multimodal_output
assert int(payload["sr"]) == 24000
assert (payload["audio"].numel() if "audio" in payload else 0) == pending
assert state.request_id not in processor.request_states
def test_non_snapshot_tensors_still_accumulate_and_empty_merge_keeps_identity():
first = MultimodalPayload.from_dict({"latent": torch.tensor([1.0]), "audio": torch.tensor([2.0])})
second = MultimodalPayload.from_dict({"latent": torch.tensor([3.0]), "audio": torch.tensor([4.0])})
assert first is not None and second is not None
assert MultimodalPayload().merged_with(first) is first
merged = first.merged_with(second)
merged.consolidate_tensors(OutputModality.AUDIO)
torch.testing.assert_close(merged["latent"], torch.tensor([1.0, 3.0]))
torch.testing.assert_close(merged["audio"], torch.tensor([2.0, 4.0]))
+88
View File
@@ -41,6 +41,7 @@ _mm_mod = _load_module(
OutputModality = _om_mod.OutputModality
TensorAccumulationStrategy = _om_mod.TensorAccumulationStrategy
get_accumulation_strategy = _om_mod.get_accumulation_strategy
register_key_accumulation_strategy = _om_mod.register_key_accumulation_strategy
MultimodalPayload = _mm_mod.MultimodalPayload
MultimodalCompletionOutput = _mm_mod.MultimodalCompletionOutput
@@ -156,3 +157,90 @@ def test_output_modality_printed_examples(capsys):
captured = capsys.readouterr()
assert "OutputModality Parsing" in captured.out
assert "MultimodalPayload" in captured.out
def test_get_accumulation_strategy_key_override_precedence():
"""A registered per-key override wins over the modality-wide default;
any other key under the same modality keeps using that default."""
assert get_accumulation_strategy(OutputModality.AUDIO, "unregistered.key") == TensorAccumulationStrategy.CONCAT_LAST
register_key_accumulation_strategy("__test_override__.frames", TensorAccumulationStrategy.CONCAT_DIM0)
assert (
get_accumulation_strategy(OutputModality.AUDIO, "__test_override__.frames")
== TensorAccumulationStrategy.CONCAT_DIM0
)
# A plain waveform key under the same AUDIO modality is unaffected.
assert get_accumulation_strategy(OutputModality.AUDIO, "waveform") == TensorAccumulationStrategy.CONCAT_LAST
assert get_accumulation_strategy(OutputModality.AUDIO) == TensorAccumulationStrategy.CONCAT_LAST
def _talker_codec_frame_payload(audio_key: str, ref_key: str) -> MultimodalPayload:
"""Synthetic stand-in for a TTS talker's accumulated per-step output.
``audio_key`` grows one ``[1, num_codebooks]`` row per decode step on top
of a ``[5, num_codebooks]`` prefill block (Qwen3-TTS's ``codes.audio``);
``ref_key`` re-emits the same constant ``[100, num_codebooks]``
reference-context matrix unchanged at every step (Qwen3-TTS's
``codes.ref``). Neither is a waveform chunk.
"""
payload = MultimodalPayload()
payload.tensors[audio_key] = [
torch.zeros(5, 16),
torch.full((1, 16), 1.0),
torch.full((1, 16), 2.0),
torch.full((1, 16), 3.0),
]
ref_block = torch.arange(100 * 16, dtype=torch.float32).reshape(100, 16)
payload.tensors[ref_key] = [ref_block.clone(), ref_block.clone(), ref_block.clone()]
return payload
def test_consolidate_tensors_default_audio_strategy_corrupts_codec_frame_keys():
"""Reproduces both failure modes of forcing codec-frame keys through the
AUDIO modality's waveform-tuned default (CONCAT_LAST) with no per-key
override registered: the audio-frames key's dim-0 growth makes
CONCAT_LAST's dim=-1 concat raise (mismatched dim 0), which the
flatten-chunks fallback turns into a 1-D blob instead of the intended
``[8, 16]``; the ref-frames key's matching last dim lets CONCAT_LAST
"succeed" by silently concatenating three identical ``[100, 16]`` blocks
into ``[100, 48]`` instead of keeping a single copy.
"""
payload = _talker_codec_frame_payload("unregistered.codes.audio", "unregistered.codes.ref")
payload.consolidate_tensors(OutputModality.AUDIO)
assert tuple(payload.tensors["unregistered.codes.audio"].shape) != (8, 16)
assert tuple(payload.tensors["unregistered.codes.ref"].shape) == (100, 48)
def test_consolidate_tensors_with_qwen3_tts_key_overrides_is_correct():
"""With the per-key overrides Qwen3-TTS's pipeline module registers
(``codes.audio`` -> CONCAT_DIM0, ``codes.ref`` -> REPLACE), both keys
consolidate correctly under the AUDIO modality despite not being
waveform chunks."""
register_key_accumulation_strategy("codes.audio", TensorAccumulationStrategy.CONCAT_DIM0)
register_key_accumulation_strategy("codes.ref", TensorAccumulationStrategy.REPLACE)
payload = _talker_codec_frame_payload("codes.audio", "codes.ref")
expected_ref = payload.tensors["codes.ref"][0]
payload.consolidate_tensors(OutputModality.AUDIO)
assert tuple(payload.tensors["codes.audio"].shape) == (8, 16)
assert tuple(payload.tensors["codes.ref"].shape) == (100, 16)
assert torch.equal(payload.tensors["codes.ref"], expected_ref)
def test_consolidate_tensors_raises_with_key_name_instead_of_silently_keeping_last():
"""A concat failure under a non-CONCAT_LAST strategy must be surfaced
with the offending key name rather than resolved by silently keeping
only the last chunk. Previously the except-branch only special-cased
the literal key ``"audio"``, so any other mismatched key (e.g. a
codec-frame key routed to CONCAT_DIM0) was corrupted instead of
failing loudly.
"""
payload = MultimodalPayload()
payload.tensors["latent.frames"] = [torch.zeros(2, 8), torch.zeros(3, 9)]
with pytest.raises(RuntimeError, match="latent.frames"):
payload.consolidate_tensors(OutputModality.LATENT)
+11 -8
View File
@@ -390,7 +390,7 @@ class TestSingleStageModeDetection:
return_value=None,
)
mocker.patch(
"vllm_omni.engine.async_omni_engine.load_deploy_config",
"vllm_omni.engine.omni_engine_base.load_deploy_config",
return_value=SimpleNamespace(duplex_session=DuplexSessionRuntimeConfig()),
)
@@ -438,7 +438,9 @@ class TestSingleStageModeDetection:
)
engine = self._make_engine_no_thread(mocker, resolved_pipeline=pipeline)
StageConfigFactory.get_pipeline_config.assert_not_called()
StageConfigFactory.get_pipeline_config.assert_called_once_with(
model="fake-model", trust_remote_code=False, deploy_config_path=None
)
assert engine._event_driven_orch_default is expected
def test_stage_id_kwarg_promotes_to_single_stage_mode(self, mocker: MockerFixture):
@@ -467,11 +469,11 @@ class TestSingleStageModeDetection:
def test_deploy_config_loads_duplex_runtime_config(self, mocker: MockerFixture):
duplex_session = DuplexSessionRuntimeConfig(max_sessions=2)
get_pipeline_config = mocker.patch(
"vllm_omni.engine.async_omni_engine.StageConfigFactory.get_pipeline_config",
"vllm_omni.engine.omni_engine_base.StageConfigFactory.get_pipeline_config",
return_value=None,
)
load_deploy_config = mocker.patch(
"vllm_omni.engine.async_omni_engine.load_deploy_config",
"vllm_omni.engine.omni_engine_base.load_deploy_config",
return_value=SimpleNamespace(duplex_session=duplex_session),
)
@@ -488,7 +490,8 @@ class TestSingleStageModeDetection:
deploy_config_path="/fake/duplex.yaml",
)
load_deploy_config.assert_called_once_with("/fake/duplex.yaml")
assert engine.duplex_session_config is duplex_session
# The turn-based engine only keeps the resolved deploy profile for introspection.
assert engine.deploy_config.duplex_session is duplex_session
def test_auto_discovered_deploy_loads_duplex_runtime_config(
self,
@@ -497,11 +500,11 @@ class TestSingleStageModeDetection:
deploy_path = "/resolved/qwen3_omni_moe.yaml"
duplex_session = DuplexSessionRuntimeConfig(server_vad_model_path="/models/silero_vad.onnx")
mocker.patch(
"vllm_omni.engine.async_omni_engine.StageConfigFactory.get_pipeline_config",
"vllm_omni.engine.omni_engine_base.StageConfigFactory.get_pipeline_config",
return_value=None,
)
load_deploy_config = mocker.patch(
"vllm_omni.engine.async_omni_engine.load_deploy_config",
"vllm_omni.engine.omni_engine_base.load_deploy_config",
return_value=SimpleNamespace(duplex_session=duplex_session),
)
@@ -578,7 +581,7 @@ class TestEndpointRestrictionsTrustRemoteCode:
def _make_engine_no_thread(self, mocker: MockerFixture, **kwargs: Any) -> AsyncOmniEngine:
mocker.patch(
"vllm_omni.engine.async_omni_engine.load_deploy_config",
"vllm_omni.engine.omni_engine_base.load_deploy_config",
return_value=SimpleNamespace(duplex_session=DuplexSessionRuntimeConfig()),
)
mocker.patch.object(