Merge main and preserve event-driven defaults in shared engine bases
Signed-off-by: Sy03 <1370724210@qq.com>
This commit is contained in:
commit
d2bb944863
371 files changed
+43235
-38379
No files matched your search
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
] == []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
@@ -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}"
|
||||
@@ -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]))
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user