[Core][Frontend] Unified Full-duplex Framework (#7413)
Signed-off-by: chickeyton <ngton2014@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9005d78903
commit
99ff4f3070
182 files changed
+26951
-34743
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
|
||||
)
|
||||
Reference in new issue
Block a user