[Refactor][CI] Reorganize test tree, and move slow e2e to weekly (#4951)
Signed-off-by: wangyu <410167048@qq.com>
This commit is contained in:
1 parent
925347332f
commit
7c17111ff5
158 files changed
+3479
-4130
No files matched your search
@@ -0,0 +1,110 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.experimental.fullduplex.engine.duplex_control_client import (
|
||||
DuplexControlClient,
|
||||
DuplexControlRequestError,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.engine.lease import DuplexLeaseActivity
|
||||
from vllm_omni.experimental.fullduplex.engine.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,68 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Focused tests for typed deployment-only runtime configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.config.stage_config import (
|
||||
DuplexSessionRuntimeConfig,
|
||||
load_deploy_config,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_duplex_session_runtime_defaults_are_typed_and_immutable(tmp_path) -> None:
|
||||
deploy_path = tmp_path / "duplex.yaml"
|
||||
deploy_path.write_text("duplex_session: {}\nstages: []\n", encoding="utf-8")
|
||||
|
||||
deploy = load_deploy_config(deploy_path)
|
||||
|
||||
assert isinstance(deploy.duplex_session, DuplexSessionRuntimeConfig)
|
||||
assert deploy.duplex_session.idle_ttl_s == 300.0
|
||||
assert deploy.duplex_session.disconnect_grace_s == 30.0
|
||||
assert deploy.duplex_session.reaper_interval_s == 5.0
|
||||
assert deploy.duplex_session.resume_replay_ttl_s == 60.0
|
||||
assert deploy.duplex_session.resume_replay_max_bytes_per_session == 8 * 1024 * 1024
|
||||
assert deploy.duplex_session.max_pending_input_bytes_per_session == 16 * 1024 * 1024
|
||||
assert deploy.duplex_session.max_pending_turns_per_session == 4
|
||||
assert deploy.duplex_session.max_sessions == 1
|
||||
assert deploy.duplex_session.completed_append_cache_size == 256
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
deploy.duplex_session.idle_ttl_s = 1.0 # type: ignore[misc]
|
||||
|
||||
|
||||
def test_duplex_session_runtime_accepts_disabled_idle_expiry(tmp_path) -> None:
|
||||
deploy_path = tmp_path / "duplex.yaml"
|
||||
deploy_path.write_text("duplex_session:\n idle_ttl_s: null\nstages: []\n", encoding="utf-8")
|
||||
|
||||
deploy = load_deploy_config(deploy_path)
|
||||
|
||||
assert deploy.duplex_session.idle_ttl_s is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "value"),
|
||||
[
|
||||
("idle_ttl_s", 0),
|
||||
("idle_ttl_s", -1),
|
||||
("disconnect_grace_s", 0),
|
||||
("reaper_interval_s", -1),
|
||||
("resume_replay_ttl_s", 0),
|
||||
("resume_replay_max_bytes_per_session", -1),
|
||||
("max_pending_input_bytes_per_session", 0),
|
||||
("max_pending_turns_per_session", -1),
|
||||
("max_sessions", 0),
|
||||
("completed_append_cache_size", 0),
|
||||
],
|
||||
)
|
||||
def test_duplex_session_runtime_rejects_non_positive_values(tmp_path, name: str, value: int) -> None:
|
||||
deploy_path = tmp_path / "duplex.yaml"
|
||||
deploy_path.write_text(f"duplex_session:\n {name}: {value}\nstages: []\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match=rf"duplex_session\.{name} must be positive"):
|
||||
load_deploy_config(deploy_path)
|
||||
@@ -0,0 +1,14 @@
|
||||
import pytest
|
||||
|
||||
from vllm_omni.experimental.fullduplex.engine.intermediate import get_stream_request_key
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_get_stream_request_key_requires_stable_identifier():
|
||||
with pytest.raises(ValueError, match="stable request id"):
|
||||
get_stream_request_key({"ids": {"tts": [1, 2, 3]}})
|
||||
|
||||
|
||||
def test_get_stream_request_key_accepts_global_request_id():
|
||||
assert get_stream_request_key({"global_request_id": ["duplex-sid-stage0"]}) == "duplex-sid-stage0"
|
||||
@@ -0,0 +1,224 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.experimental.fullduplex.engine.duplex_runtime import DuplexInputMode
|
||||
from vllm_omni.experimental.fullduplex.engine.duplex_session import DuplexSessionRuntimeManager
|
||||
from vllm_omni.experimental.fullduplex.engine.lease import (
|
||||
DuplexLeaseActivity,
|
||||
DuplexLeaseConfig,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class FakeMonotonicClock:
|
||||
def __init__(self, value: float) -> None:
|
||||
self.value = value
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.value
|
||||
|
||||
def advance(self, seconds: float) -> None:
|
||||
self.value += seconds
|
||||
|
||||
|
||||
def _lease_config(*, idle_ttl_s: float | None = 300.0) -> DuplexLeaseConfig:
|
||||
return DuplexLeaseConfig(
|
||||
idle_ttl_s=idle_ttl_s,
|
||||
disconnect_grace_s=30.0,
|
||||
)
|
||||
|
||||
|
||||
def test_open_touch_detach_and_idle_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())
|
||||
|
||||
assert session.lease.last_activity == 100.0
|
||||
clock.advance(10.0)
|
||||
session.touch(fence, DuplexLeaseActivity.HEARTBEAT)
|
||||
session.detach(fence)
|
||||
clock.advance(29.0)
|
||||
assert session.lease.disconnect_grace_expired(clock()) is False
|
||||
assert manager.collect_expired() == []
|
||||
|
||||
clock.advance(272.0)
|
||||
expired = manager.collect_expired()
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_detach_grace_and_resume_advance_token_independent_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.detach(fence)
|
||||
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)
|
||||
|
||||
assert 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)
|
||||
|
||||
|
||||
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.begin_operation(fence, "append-1")
|
||||
clock.advance(10.0)
|
||||
assert manager.collect_expired() == []
|
||||
|
||||
session.end_operation(fence, "append-1")
|
||||
assert manager.collect_expired() == []
|
||||
clock.advance(6.0)
|
||||
assert [item.session_id for item in manager.collect_expired()] == ["sid-operation"]
|
||||
|
||||
|
||||
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)
|
||||
|
||||
clock.advance(6.0)
|
||||
session_b.touch(fence_b, 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_b.resource_request_ids() == ["req-b"]
|
||||
|
||||
|
||||
def test_close_and_reaper_select_exactly_one_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))
|
||||
clock.advance(2.0)
|
||||
|
||||
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() == []
|
||||
|
||||
|
||||
def test_stale_fence_cannot_touch_detach_or_resume_lease() -> None:
|
||||
clock = FakeMonotonicClock(0.0)
|
||||
manager = DuplexSessionRuntimeManager(clock=clock)
|
||||
current = DuplexFence("sid-fence", epoch=1)
|
||||
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)
|
||||
|
||||
|
||||
def test_disabled_idle_expiry_never_collects_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))
|
||||
|
||||
clock.advance(1_000_000.0)
|
||||
|
||||
assert manager.collect_expired() == []
|
||||
|
||||
|
||||
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.detach(fence)
|
||||
clock.advance(29.0)
|
||||
assert manager.collect_expired() == []
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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:
|
||||
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]
|
||||
@@ -0,0 +1,480 @@
|
||||
from dataclasses import FrozenInstanceError
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from vllm.sampling_params import SamplingParams
|
||||
|
||||
from vllm_omni.experimental.fullduplex.engine import duplex_runtime
|
||||
from vllm_omni.experimental.fullduplex.engine.contracts import (
|
||||
duplex_data_plane_request_info,
|
||||
duplex_resource_request_belongs_to_session,
|
||||
duplex_resource_request_id,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.engine.duplex_runtime import (
|
||||
DuplexInputMode,
|
||||
DuplexOutputAction,
|
||||
DuplexOutputDecision,
|
||||
DuplexRuntimeCapabilities,
|
||||
DuplexSessionRuntimeManager,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
|
||||
from vllm_omni.experimental.fullduplex.minicpmo45.runtime import (
|
||||
MiniCPMO45DuplexRuntimeExtension,
|
||||
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
|
||||
Whitespace-only changes.
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.experimental.fullduplex.openai.commit_policy import (
|
||||
CommitAction,
|
||||
CommitSnapshot,
|
||||
decide_commit_action,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("case", "snapshot", "expected"),
|
||||
[
|
||||
(
|
||||
"first idle commit",
|
||||
CommitSnapshot(
|
||||
auto_responds=True,
|
||||
speech_since_commit=True,
|
||||
active_response_id=None,
|
||||
overlap_speech_ms=0,
|
||||
native_response_in_progress=False,
|
||||
playback_active=False,
|
||||
),
|
||||
CommitAction.START_AUTO_RESPONSE,
|
||||
),
|
||||
(
|
||||
"native auto-response remains model-owned during playback",
|
||||
CommitSnapshot(
|
||||
auto_responds=True,
|
||||
speech_since_commit=True,
|
||||
active_response_id=None,
|
||||
overlap_speech_ms=800,
|
||||
native_response_in_progress=True,
|
||||
playback_active=True,
|
||||
),
|
||||
CommitAction.START_AUTO_RESPONSE,
|
||||
),
|
||||
(
|
||||
"native auto-response commit without speech",
|
||||
CommitSnapshot(
|
||||
auto_responds=True,
|
||||
speech_since_commit=False,
|
||||
active_response_id=None,
|
||||
overlap_speech_ms=0,
|
||||
native_response_in_progress=True,
|
||||
playback_active=False,
|
||||
),
|
||||
CommitAction.COMMIT_ONLY,
|
||||
),
|
||||
(
|
||||
"non-auto response overlap",
|
||||
CommitSnapshot(
|
||||
auto_responds=False,
|
||||
speech_since_commit=True,
|
||||
active_response_id=None,
|
||||
overlap_speech_ms=800,
|
||||
native_response_in_progress=True,
|
||||
playback_active=False,
|
||||
),
|
||||
CommitAction.DEFER_ACTIVE_RESPONSE,
|
||||
),
|
||||
(
|
||||
"non-auto playback overlap",
|
||||
CommitSnapshot(
|
||||
auto_responds=False,
|
||||
speech_since_commit=True,
|
||||
active_response_id=None,
|
||||
overlap_speech_ms=800,
|
||||
native_response_in_progress=False,
|
||||
playback_active=True,
|
||||
),
|
||||
CommitAction.DEFER_ACTIVE_RESPONSE,
|
||||
),
|
||||
(
|
||||
"non-auto commit without overlap",
|
||||
CommitSnapshot(
|
||||
auto_responds=False,
|
||||
speech_since_commit=True,
|
||||
active_response_id=None,
|
||||
overlap_speech_ms=0,
|
||||
native_response_in_progress=False,
|
||||
playback_active=False,
|
||||
),
|
||||
CommitAction.COMMIT_ONLY,
|
||||
),
|
||||
],
|
||||
ids=lambda value: value if isinstance(value, str) else None,
|
||||
)
|
||||
def test_decide_commit_action_covers_realtime_lifecycle_sequences(
|
||||
case: str,
|
||||
snapshot: CommitSnapshot,
|
||||
expected: CommitAction,
|
||||
) -> None:
|
||||
del case
|
||||
|
||||
assert decide_commit_action(snapshot) is expected
|
||||
@@ -0,0 +1,179 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import base64
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from vllm_omni.experimental.fullduplex.minicpmo45.input import MiniCPMO45PcmAppendBuffer
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def pcm_payload(samples: int, *, speech: bool = True) -> dict[str, object]:
|
||||
audio = np.ones(samples, dtype=np.float32).tobytes()
|
||||
return {
|
||||
"type": "audio",
|
||||
"audio": base64.b64encode(audio).decode("ascii"),
|
||||
"format": "pcm_f32le",
|
||||
"sample_rate_hz": 16_000,
|
||||
"is_speech": speech,
|
||||
}
|
||||
|
||||
|
||||
def test_commit_does_not_add_silence_after_incremental_audio_was_drained():
|
||||
buffer = MiniCPMO45PcmAppendBuffer()
|
||||
|
||||
emitted = buffer.append(pcm_payload(16_000), chunk_period_ms=1_000)
|
||||
committed = buffer.commit(chunk_period_ms=1_000)
|
||||
|
||||
assert emitted is not None
|
||||
assert not buffer.has_pending()
|
||||
assert committed is None
|
||||
|
||||
|
||||
def test_append_emits_one_model_unit_when_multiple_units_are_buffered():
|
||||
buffer = MiniCPMO45PcmAppendBuffer()
|
||||
|
||||
emitted = buffer.append(pcm_payload(32_000), chunk_period_ms=1_000)
|
||||
|
||||
assert emitted is not None
|
||||
assert len(base64.b64decode(emitted["audio"])) == 16_000 * 4
|
||||
assert buffer.pending_byte_count == 16_000 * 4
|
||||
|
||||
|
||||
def test_speech_marker_does_not_leak_across_irregular_chunk_boundaries():
|
||||
buffer = MiniCPMO45PcmAppendBuffer()
|
||||
|
||||
assert buffer.append(pcm_payload(15_000), chunk_period_ms=1_000) is None
|
||||
speech_unit = buffer.append(pcm_payload(2_000, speech=False), chunk_period_ms=1_000)
|
||||
silence_unit = buffer.append(pcm_payload(15_000, speech=False), chunk_period_ms=1_000)
|
||||
|
||||
assert speech_unit is not None
|
||||
assert speech_unit["is_speech"] is True
|
||||
assert silence_unit is not None
|
||||
assert silence_unit["is_speech"] is False
|
||||
|
||||
|
||||
def test_force_listen_marker_does_not_leak_across_irregular_chunk_boundaries():
|
||||
buffer = MiniCPMO45PcmAppendBuffer()
|
||||
forced = pcm_payload(15_000, speech=False)
|
||||
forced["force_listen"] = True
|
||||
|
||||
assert buffer.append(forced, chunk_period_ms=1_000) is None
|
||||
forced_unit = buffer.append(pcm_payload(2_000, speech=False), chunk_period_ms=1_000)
|
||||
unforced_unit = buffer.append(pcm_payload(15_000, speech=False), chunk_period_ms=1_000)
|
||||
|
||||
assert forced_unit is not None
|
||||
assert forced_unit["force_listen"] is True
|
||||
assert unforced_unit is not None
|
||||
assert unforced_unit["force_listen"] is False
|
||||
|
||||
|
||||
def test_pcm_append_rollback_restores_per_span_speech_markers():
|
||||
buffer = MiniCPMO45PcmAppendBuffer()
|
||||
assert buffer.append(pcm_payload(15_000), chunk_period_ms=1_000) is None
|
||||
reservation = buffer.prepare_append(
|
||||
pcm_payload(2_000, speech=False),
|
||||
operation_id="mixed-unit",
|
||||
chunk_period_ms=1_000,
|
||||
)
|
||||
|
||||
assert reservation is not None
|
||||
reservation.rollback()
|
||||
first = buffer.append(pcm_payload(15_000, speech=False), chunk_period_ms=1_000)
|
||||
second = buffer.flush(chunk_period_ms=1_000)
|
||||
|
||||
assert first is not None
|
||||
assert first["is_speech"] is True
|
||||
assert second is not None
|
||||
assert second["is_speech"] is False
|
||||
|
||||
|
||||
def test_commit_without_speech_does_not_synthesize_terminal_audio():
|
||||
buffer = MiniCPMO45PcmAppendBuffer()
|
||||
buffer.append(pcm_payload(8_000, speech=False), chunk_period_ms=1_000)
|
||||
|
||||
committed = buffer.commit(chunk_period_ms=1_000)
|
||||
|
||||
assert committed is None
|
||||
|
||||
|
||||
def test_commit_resets_cumulative_turn_accounting():
|
||||
buffer = MiniCPMO45PcmAppendBuffer()
|
||||
buffer.append(pcm_payload(16_000), chunk_period_ms=1_000)
|
||||
buffer.commit(chunk_period_ms=1_000)
|
||||
|
||||
empty = buffer.commit(chunk_period_ms=1_000)
|
||||
|
||||
assert empty is None
|
||||
|
||||
|
||||
def test_pcm_append_reservation_rollback_restores_emitted_audio():
|
||||
buffer = MiniCPMO45PcmAppendBuffer()
|
||||
original = pcm_payload(16_000)
|
||||
|
||||
reservation = buffer.prepare_append(
|
||||
original,
|
||||
operation_id="append-1",
|
||||
chunk_period_ms=1_000,
|
||||
)
|
||||
|
||||
assert reservation is not None
|
||||
assert not buffer.has_pending()
|
||||
reservation.rollback()
|
||||
assert buffer.has_pending()
|
||||
|
||||
retried = buffer.flush(chunk_period_ms=1_000)
|
||||
assert retried is not None
|
||||
assert base64.b64decode(retried["audio"]) == base64.b64decode(original["audio"])
|
||||
|
||||
|
||||
def test_pcm_commit_keeps_prior_append_reservation_active():
|
||||
buffer = MiniCPMO45PcmAppendBuffer()
|
||||
append_reservation = buffer.prepare_append(
|
||||
pcm_payload(16_000),
|
||||
operation_id="append-before-commit",
|
||||
chunk_period_ms=1_000,
|
||||
)
|
||||
|
||||
commit_reservation = buffer.prepare_commit(
|
||||
operation_id="commit-after-append",
|
||||
chunk_period_ms=1_000,
|
||||
)
|
||||
|
||||
assert append_reservation is not None
|
||||
assert append_reservation.active
|
||||
assert commit_reservation.payload is None
|
||||
append_reservation.commit()
|
||||
commit_reservation.commit()
|
||||
|
||||
|
||||
def test_pcm_commit_reservation_rollback_restores_residual_audio():
|
||||
buffer = MiniCPMO45PcmAppendBuffer()
|
||||
original = pcm_payload(8_000)
|
||||
assert (
|
||||
buffer.prepare_append(
|
||||
original,
|
||||
operation_id="buffer-half-chunk",
|
||||
chunk_period_ms=1_000,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
reservation = buffer.prepare_commit(
|
||||
operation_id="final-half-chunk",
|
||||
chunk_period_ms=1_000,
|
||||
)
|
||||
|
||||
assert reservation.payload is not None
|
||||
assert reservation.payload["final"] is True
|
||||
reservation.rollback()
|
||||
|
||||
retried = buffer.prepare_commit(
|
||||
operation_id="retry-final-half-chunk",
|
||||
chunk_period_ms=1_000,
|
||||
)
|
||||
assert retried.payload is not None
|
||||
assert base64.b64decode(retried.payload["audio"]) == (base64.b64decode(original["audio"]) + b"\x00" * (8_000 * 4))
|
||||
@@ -0,0 +1,54 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import base64
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from vllm_omni.experimental.fullduplex.openai.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,139 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.experimental.fullduplex.openai.runtime_adapter import (
|
||||
validate_serving_runtime_adapter,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"module_name",
|
||||
[
|
||||
"vllm_omni.experimental.fullduplex.openai.session_runner",
|
||||
"vllm_omni.experimental.fullduplex.openai.runtime_bridge",
|
||||
"vllm_omni.experimental.fullduplex.openai.serving",
|
||||
],
|
||||
)
|
||||
def test_generic_openai_runtime_import_does_not_load_minicpmo45(module_name: str) -> None:
|
||||
repo_root = Path(__file__).resolve().parents[3]
|
||||
script = f"""
|
||||
import sys
|
||||
|
||||
import {module_name}
|
||||
|
||||
loaded = sorted(
|
||||
name
|
||||
for name in sys.modules
|
||||
if name == "vllm_omni.experimental.fullduplex.minicpmo45"
|
||||
or name.startswith("vllm_omni.experimental.fullduplex.minicpmo45.")
|
||||
)
|
||||
if loaded:
|
||||
raise SystemExit("generic OpenAI runtime loaded MiniCPM-o: " + ", ".join(loaded))
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
cwd=repo_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_generic_handler_requires_explicit_serving_runtime_adapter() -> None:
|
||||
repo_root = Path(__file__).resolve().parents[3]
|
||||
script = """
|
||||
from types import SimpleNamespace
|
||||
|
||||
from vllm_omni.experimental.fullduplex.openai.serving import OmniDuplexSessionHandler
|
||||
|
||||
chat_service = SimpleNamespace(engine_client=SimpleNamespace())
|
||||
try:
|
||||
OmniDuplexSessionHandler(chat_service=chat_service)
|
||||
except ValueError as exc:
|
||||
if "serving runtime adapter" not in str(exc).lower():
|
||||
raise
|
||||
else:
|
||||
raise SystemExit("generic handler silently selected a model serving adapter")
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
cwd=repo_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def _valid_runtime_adapter() -> SimpleNamespace:
|
||||
data_plane = SimpleNamespace(
|
||||
begin_request=lambda request_id: None,
|
||||
is_terminal=lambda request_id: False,
|
||||
mark_terminal=lambda request_id: None,
|
||||
close_stream=lambda request_id: None,
|
||||
close_session=lambda session_id, **kwargs: None,
|
||||
project=lambda result, **kwargs: (),
|
||||
)
|
||||
return SimpleNamespace(
|
||||
adapter_id="test-adapter",
|
||||
session_states={},
|
||||
data_plane=data_plane,
|
||||
clean_response_done_prefix="clean",
|
||||
interrupted_tts_prefix="interrupted",
|
||||
private_runtime_config_keys=frozenset({"private_key"}),
|
||||
create_session_state=lambda: object(),
|
||||
session_state=lambda session_id: object(),
|
||||
remove_session_state=lambda session_id: None,
|
||||
is_enabled=lambda config: True,
|
||||
capabilities=lambda **kwargs: object(),
|
||||
validate_client_extra_body=lambda extra_body: None,
|
||||
prepare_runtime_config=lambda config, **kwargs: {},
|
||||
runtime_config_for_update=lambda config, current: {},
|
||||
data_plane_context=lambda **kwargs: object(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("attribute", "invalid_value"),
|
||||
[
|
||||
("session_states", None),
|
||||
("clean_response_done_prefix", None),
|
||||
("interrupted_tts_prefix", None),
|
||||
("private_runtime_config_keys", {"private_key"}),
|
||||
],
|
||||
)
|
||||
def test_runtime_adapter_validator_rejects_invalid_protocol_attributes(
|
||||
attribute: str,
|
||||
invalid_value: object,
|
||||
) -> None:
|
||||
adapter = _valid_runtime_adapter()
|
||||
setattr(adapter, attribute, invalid_value)
|
||||
|
||||
with pytest.raises(TypeError, match=attribute):
|
||||
validate_serving_runtime_adapter(adapter)
|
||||
|
||||
|
||||
def test_runtime_adapter_validator_rejects_incomplete_data_plane() -> None:
|
||||
adapter = _valid_runtime_adapter()
|
||||
adapter.data_plane.close_session = None
|
||||
|
||||
with pytest.raises(TypeError, match="data_plane.*close_session"):
|
||||
validate_serving_runtime_adapter(adapter)
|
||||
@@ -0,0 +1,145 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.experimental.fullduplex.openai.websocket import (
|
||||
DuplexWebSocketActor,
|
||||
normalize_duplex_input_event,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class FakeWebSocket:
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[dict[str, object]] = []
|
||||
|
||||
async def send_json(self, payload: dict[str, object]) -> None:
|
||||
self.sent.append(dict(payload))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("event", "expected"),
|
||||
[
|
||||
({"type": "signal_turn", "event": "barge_in"}, {"type": "turn.signal", "event": "barge_in"}),
|
||||
({"type": "close_session"}, {"type": "session.close"}),
|
||||
({"type": "audio.playback_ack", "played_ms": 1}, {"type": "playback.ack", "played_ms": 1}),
|
||||
({"type": "input_text.append", "text": "a"}, {"type": "input.text.append", "text": "a"}),
|
||||
({"type": "push_text", "text": "b"}, {"type": "input.text.append", "text": "b"}),
|
||||
(
|
||||
{"type": "input.audio.append", "audio": "wav"},
|
||||
{"type": "input_audio_buffer.append", "audio": "wav", "format": "wav"},
|
||||
),
|
||||
(
|
||||
{"type": "push_chunk", "audio": "wav", "format": "pcm_f32le"},
|
||||
{"type": "input_audio_buffer.append", "audio": "wav", "format": "pcm_f32le"},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_input_aliases_normalize_once_at_mailbox_boundary(event, expected):
|
||||
assert normalize_duplex_input_event(event) == expected
|
||||
|
||||
|
||||
def test_actor_uses_one_fifo_mailbox_for_inbound_events():
|
||||
actor = DuplexWebSocketActor(FakeWebSocket())
|
||||
|
||||
assert isinstance(actor.mailbox, asyncio.Queue)
|
||||
assert not isinstance(actor.mailbox, asyncio.PriorityQueue)
|
||||
assert not hasattr(actor, "control_queue")
|
||||
assert not hasattr(actor, "input_queue")
|
||||
assert not hasattr(actor, "event_queue")
|
||||
assert not hasattr(actor, "lifecycle_state")
|
||||
assert not hasattr(actor, "last_response_id")
|
||||
assert not hasattr(actor, "overlap_speech_ms")
|
||||
assert not hasattr(actor, "transition")
|
||||
assert not hasattr(actor, "output_generation_in_flight")
|
||||
assert not hasattr(actor, "session")
|
||||
assert not hasattr(actor, "runtime_opened")
|
||||
assert not hasattr(actor, "runtime_closed")
|
||||
assert not hasattr(actor, "drain_input_queue")
|
||||
assert not hasattr(actor, "control_events_seen")
|
||||
assert not hasattr(actor, "input_events_seen")
|
||||
assert not hasattr(actor, "cancel_count")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_control_event_does_not_overtake_earlier_audio_input():
|
||||
actor = DuplexWebSocketActor(FakeWebSocket())
|
||||
await actor.enqueue_event({"type": "input_audio_buffer.append", "audio": "pcm"})
|
||||
await actor.enqueue_event({"type": "response.cancel"})
|
||||
|
||||
assert await actor.next_event() == {"type": "input_audio_buffer.append", "audio": "pcm"}
|
||||
assert await actor.next_event() == {"type": "response.cancel"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("event_type", ["input.cancel", "session.close", "close_session"])
|
||||
async def test_terminal_control_preserves_wire_order(event_type: str):
|
||||
actor = DuplexWebSocketActor(FakeWebSocket())
|
||||
await actor.enqueue_event({"type": "input_audio_buffer.append", "audio": "pcm"})
|
||||
await actor.enqueue_event({"type": event_type})
|
||||
|
||||
assert await actor.next_event() == {"type": "input_audio_buffer.append", "audio": "pcm"}
|
||||
assert await actor.next_event() == {"type": event_type}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mailbox_delivers_every_enqueued_event_exactly_once():
|
||||
actor = DuplexWebSocketActor(FakeWebSocket())
|
||||
events = [
|
||||
{"type": "input_audio_buffer.append", "audio": str(index)}
|
||||
if index % 2
|
||||
else {"type": "response.created", "response_id": f"resp-{index}"}
|
||||
for index in range(20)
|
||||
]
|
||||
|
||||
await asyncio.gather(*(actor.enqueue_event(dict(event)) for event in events))
|
||||
received = [await actor.next_event() for _ in events]
|
||||
|
||||
assert sorted(received, key=repr) == sorted(events, key=repr)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_writer_is_single_owner_of_websocket_send():
|
||||
websocket = FakeWebSocket()
|
||||
actor = DuplexWebSocketActor(websocket)
|
||||
writer = asyncio.create_task(actor.writer_loop())
|
||||
|
||||
await actor.send_json({"type": "one"})
|
||||
await actor.send_json({"type": "two"})
|
||||
await actor.close_writer()
|
||||
await writer
|
||||
|
||||
assert websocket.sent == [{"type": "one"}, {"type": "two"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_fence_payload_is_dropped_before_websocket_send():
|
||||
websocket = FakeWebSocket()
|
||||
actor = DuplexWebSocketActor(websocket, current_epoch=lambda: 2)
|
||||
writer = asyncio.create_task(actor.writer_loop())
|
||||
|
||||
await actor.send_json({"type": "response.audio.delta", "epoch": 1})
|
||||
await actor.send_json({"type": "response.audio.delta", "epoch": 2})
|
||||
await actor.close_writer()
|
||||
await writer
|
||||
|
||||
assert websocket.sent == [{"type": "response.audio.delta", "epoch": 2}]
|
||||
assert actor.stale_output_dropped == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_writer_does_not_revoke_accepted_terminal_after_close_starts():
|
||||
websocket = FakeWebSocket()
|
||||
actor = DuplexWebSocketActor(websocket, current_epoch=lambda: 1)
|
||||
await actor.send_json({"type": "response.done", "epoch": 1, "response_id": "resp-1"})
|
||||
actor.closing = True
|
||||
await actor.close_writer()
|
||||
|
||||
await actor.writer_loop()
|
||||
|
||||
assert websocket.sent == [{"type": "response.done", "epoch": 1, "response_id": "resp-1"}]
|
||||
assert actor.stale_output_dropped == 0
|
||||
@@ -0,0 +1,240 @@
|
||||
import base64
|
||||
import wave
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.experimental.fullduplex.client import (
|
||||
RealtimeDuplexClient,
|
||||
RealtimeEventCollector,
|
||||
build_realtime_url,
|
||||
read_pcm16_wav,
|
||||
write_pcm16_wav,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_realtime_client_builds_explicit_native_duplex_url():
|
||||
url = build_realtime_url(
|
||||
"ws://localhost:8099/v1/realtime?custom=1",
|
||||
"openbmb/MiniCPM-o-4_5",
|
||||
session_id="session-a",
|
||||
)
|
||||
|
||||
query = parse_qs(urlsplit(url).query)
|
||||
assert query == {
|
||||
"custom": ["1"],
|
||||
"duplex": ["1"],
|
||||
"model": ["openbmb/MiniCPM-o-4_5"],
|
||||
"minicpmo45_native_duplex": ["1"],
|
||||
"session_id": ["session-a"],
|
||||
}
|
||||
|
||||
|
||||
def test_realtime_client_builds_resume_only_url_when_autostart_disabled():
|
||||
url = build_realtime_url(
|
||||
"ws://localhost:8099/v1/realtime?duplex=1",
|
||||
"openbmb/MiniCPM-o-4_5",
|
||||
autostart=False,
|
||||
)
|
||||
|
||||
query = parse_qs(urlsplit(url).query)
|
||||
assert query["autostart"] == ["0"]
|
||||
assert query["minicpmo45_native_duplex"] == ["1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_client_configure_omits_ref_audio_by_default():
|
||||
class Client(RealtimeDuplexClient):
|
||||
def __init__(self):
|
||||
super().__init__("ws://unused")
|
||||
self.sent = []
|
||||
|
||||
async def send(self, event):
|
||||
self.sent.append(event)
|
||||
self.events.add({"type": "session.created"})
|
||||
|
||||
client = Client()
|
||||
|
||||
await client.configure("openbmb/MiniCPM-o-4_5", timeout_s=1)
|
||||
|
||||
session = client.sent[0]["session"]
|
||||
assert "ref_audio" not in session
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_client_configure_sends_explicit_ref_audio():
|
||||
class Client(RealtimeDuplexClient):
|
||||
def __init__(self):
|
||||
super().__init__("ws://unused")
|
||||
self.sent = []
|
||||
|
||||
async def send(self, event):
|
||||
self.sent.append(event)
|
||||
self.events.add({"type": "session.created"})
|
||||
|
||||
client = Client()
|
||||
|
||||
await client.configure(
|
||||
"openbmb/MiniCPM-o-4_5",
|
||||
ref_audio="data:audio/wav;base64,AAAA",
|
||||
timeout_s=1,
|
||||
)
|
||||
|
||||
session = client.sent[0]["session"]
|
||||
assert session["ref_audio"] == "data:audio/wav;base64,AAAA"
|
||||
|
||||
|
||||
def test_realtime_event_collector_partitions_audio_by_response():
|
||||
collector = RealtimeEventCollector()
|
||||
collector.add({"type": "response.created", "response": {"id": "resp-a"}})
|
||||
collector.add(
|
||||
{
|
||||
"type": "response.audio.delta",
|
||||
"response_id": "resp-a",
|
||||
"delta": base64.b64encode(b"audio-a").decode("ascii"),
|
||||
"sample_rate_hz": 16_000,
|
||||
}
|
||||
)
|
||||
|
||||
assert collector.response_ids == ["resp-a"]
|
||||
assert collector.audio_bytes("resp-a") == b"audio-a"
|
||||
assert collector.output_sample_rate_hz == 16_000
|
||||
assert collector.first_received_at("response.created") is not None
|
||||
assert collector.last_received_at("response.audio.delta") is not None
|
||||
|
||||
|
||||
def test_realtime_event_collector_reports_engine_token_and_audio_intervals():
|
||||
collector = RealtimeEventCollector()
|
||||
collector.add(
|
||||
{"type": "response.created", "response": {"id": "resp-a"}},
|
||||
received_at_s=10.0,
|
||||
)
|
||||
stage_metrics = {
|
||||
"0": {
|
||||
"num_tokens_out": 4,
|
||||
"vllm_ttft_ms": 120.0,
|
||||
"vllm_tpot_ms": 15.0,
|
||||
"vllm_itl_ms": 14.0,
|
||||
"vllm_itls_ms": [10.0, 14.0, 18.0],
|
||||
}
|
||||
}
|
||||
for received_at_s, cumulative_audio_ms in ((10.2, 80), (10.25, 160), (10.36, 240)):
|
||||
collector.add(
|
||||
{
|
||||
"type": "response.audio.delta",
|
||||
"response_id": "resp-a",
|
||||
"delta": base64.b64encode(b"audio").decode("ascii"),
|
||||
"sample_rate_hz": 16_000,
|
||||
"metadata": {
|
||||
"audio_duration_ms": cumulative_audio_ms,
|
||||
"vllm_omni": {"stage_metrics": stage_metrics},
|
||||
},
|
||||
},
|
||||
received_at_s=received_at_s,
|
||||
)
|
||||
|
||||
timing = collector.timing_summary(
|
||||
after_s=10.0,
|
||||
input_committed_at_s=9.9,
|
||||
response_id="resp-a",
|
||||
)
|
||||
|
||||
assert timing["stage0_tokens"] == {
|
||||
"source": "engine_stage_metrics",
|
||||
"output_token_count": 4,
|
||||
"ttft_ms": 120.0,
|
||||
"tpot_ms": 15.0,
|
||||
"inter_token_interval_ms": {
|
||||
"count": 3,
|
||||
"mean": 14.0,
|
||||
"p50": 14.0,
|
||||
"p95": 18.0,
|
||||
"max": 18.0,
|
||||
},
|
||||
}
|
||||
assert timing["audio_output"] == {
|
||||
"source": "client_monotonic_receive",
|
||||
"chunk_count": 3,
|
||||
"response_created_to_first_audio_ms": 200.0,
|
||||
"commit_to_first_audio_ms": 300.0,
|
||||
"inter_chunk_interval_ms": {
|
||||
"count": 2,
|
||||
"mean": 80.0,
|
||||
"p50": 50.0,
|
||||
"p95": 110.0,
|
||||
"max": 110.0,
|
||||
},
|
||||
"chunk_duration_ms": {
|
||||
"count": 3,
|
||||
"mean": 80.0,
|
||||
"p50": 80.0,
|
||||
"p95": 80.0,
|
||||
"max": 80.0,
|
||||
},
|
||||
"max_chunk_gap_ms": 110.0,
|
||||
}
|
||||
|
||||
|
||||
def test_response_timing_ignores_unowned_session_level_metrics():
|
||||
collector = RealtimeEventCollector()
|
||||
collector.add(
|
||||
{"type": "response.created", "response": {"id": "resp-a"}},
|
||||
received_at_s=10.0,
|
||||
)
|
||||
collector.add(
|
||||
{
|
||||
"type": "response.audio.delta",
|
||||
"response_id": "resp-a",
|
||||
"delta": base64.b64encode(b"audio").decode("ascii"),
|
||||
"metadata": {
|
||||
"vllm_omni": {
|
||||
"stage_metrics": {
|
||||
"0": {
|
||||
"num_tokens_out": 20,
|
||||
"vllm_ttft_ms": 157.0,
|
||||
"vllm_tpot_ms": 16.0,
|
||||
"vllm_itls_ms": [15.0, 17.0],
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
received_at_s=10.2,
|
||||
)
|
||||
collector.add(
|
||||
{
|
||||
"type": "response.listen",
|
||||
"metadata": {
|
||||
"vllm_omni": {
|
||||
"stage_metrics": {
|
||||
"0": {
|
||||
"num_tokens_out": 2,
|
||||
"vllm_ttft_ms": 106.0,
|
||||
"vllm_tpot_ms": 0.0,
|
||||
"vllm_itls_ms": [],
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
received_at_s=10.3,
|
||||
)
|
||||
|
||||
timing = collector.timing_summary(after_s=10.0, response_id="resp-a")
|
||||
|
||||
assert timing["stage0_tokens"]["output_token_count"] == 20
|
||||
assert timing["stage0_tokens"]["ttft_ms"] == 157.0
|
||||
|
||||
|
||||
def test_realtime_client_pcm16_wav_round_trip(tmp_path):
|
||||
path = tmp_path / "audio.wav"
|
||||
pcm16 = b"\x01\x00\x02\x00"
|
||||
|
||||
write_pcm16_wav(path, pcm16, sample_rate_hz=16_000)
|
||||
|
||||
with wave.open(str(path), "rb") as wav_file:
|
||||
assert wav_file.getnchannels() == 1
|
||||
assert wav_file.getframerate() == 16_000
|
||||
assert read_pcm16_wav(path) == pcm16
|
||||
@@ -0,0 +1,110 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.experimental.fullduplex.joyvl.decision.output_parser import parse_action
|
||||
from vllm_omni.experimental.fullduplex.joyvl.memory.brain import InteractionBrain
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class _FakeSummarizer:
|
||||
def __init__(self):
|
||||
self.chunks = 0
|
||||
|
||||
async def summarize_chunk(self, chunk_index, frame_range, frames):
|
||||
self.chunks += 1
|
||||
return f"summary-of-chunk-{chunk_index}"
|
||||
|
||||
async def compress_to_long_term(self, mids):
|
||||
return f"compressed({len(mids)})"
|
||||
|
||||
|
||||
class _FakeDelegation:
|
||||
def __init__(self):
|
||||
self._ready = {}
|
||||
|
||||
async def submit(self, question, note, frames):
|
||||
self._ready["t1"] = question
|
||||
return "t1"
|
||||
|
||||
async def poll(self, task_id):
|
||||
from vllm_omni.experimental.fullduplex.joyvl.bridges.delegation import DelegationResult
|
||||
|
||||
return DelegationResult(task_id, "ready", digest=f"answer to {self._ready[task_id]}")
|
||||
|
||||
|
||||
def test_query_freshness_and_archive_on_change():
|
||||
b = InteractionBrain()
|
||||
assert b.update_query("count bottles") is True
|
||||
assert b.update_query("count bottles") is False
|
||||
b.record_response("1 bottle")
|
||||
assert b.update_query("now describe") is True
|
||||
assert b.memory.qa_history[-1].query == "count bottles"
|
||||
assert b.memory.qa_history[-1].responses == [("0.0 seconds", "1 bottle")]
|
||||
|
||||
|
||||
def test_query_in_frame_on_arrival_then_prefix_on_later_chunks():
|
||||
b = InteractionBrain(frame_seconds=1.0)
|
||||
b.tick(3)
|
||||
b.update_query("alert me if a fire breaks out")
|
||||
assert b.query_in_current_chunk is True
|
||||
assert "alert me if a fire breaks out" not in b.build_prefix()
|
||||
b.record_response("a fire is breaking out")
|
||||
b.close_chunk()
|
||||
assert b.query_in_current_chunk is False
|
||||
prefix = b.build_prefix()
|
||||
assert "alert me if a fire breaks out" in prefix
|
||||
assert "a fire is breaking out" in prefix
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_archives_qa_and_summarizes():
|
||||
summ = _FakeSummarizer()
|
||||
b = InteractionBrain(summarizer=summ, chunk_frames=3, long_term_every_n_chunks=2, frame_seconds=1.0)
|
||||
b.update_query("watch the room")
|
||||
b.tick(3)
|
||||
assert b.should_flush() is True
|
||||
b.record_response("a person enters")
|
||||
await b.flush([("0.0s", "u0"), ("2.0s", "u2")])
|
||||
assert summ.chunks == 1
|
||||
assert b.chunk_index == 2
|
||||
assert b.should_flush() is False
|
||||
assert b.memory.qa_history[-1].responses == [("2.0 seconds", "a person enters")]
|
||||
assert len(b.memory.mid_term_summaries) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_long_term_compression_after_n_chunks():
|
||||
summ = _FakeSummarizer()
|
||||
b = InteractionBrain(summarizer=summ, chunk_frames=1, long_term_every_n_chunks=2, frame_seconds=1.0)
|
||||
for _ in range(2):
|
||||
await b.flush([("0.0s", "u")])
|
||||
assert b.memory.long_term_memory.startswith("compressed(")
|
||||
assert b.memory.mid_term_summaries == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_long_term_window_caps_blocks():
|
||||
# Long-term memory must not grow unbounded over hours: oldest blocks are dropped
|
||||
# once the sliding window is full (reference window = 15; here 2 for the test).
|
||||
summ = _FakeSummarizer()
|
||||
b = InteractionBrain(
|
||||
summarizer=summ, chunk_frames=1, long_term_every_n_chunks=1, long_term_window=2, frame_seconds=1.0
|
||||
)
|
||||
for _ in range(4):
|
||||
await b.flush([("0.0s", "u")])
|
||||
assert len(b.memory.long_term_blocks) == 2 # capped, not 4
|
||||
assert b.memory.long_term_memory.count("compressed(") == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delegation_submit_and_fold():
|
||||
b = InteractionBrain(delegation=_FakeDelegation())
|
||||
action = parse_action("</response> hold on <delegation> solve this")
|
||||
info = await b.submit_delegation(action, [])
|
||||
assert info["status"] == "submitted"
|
||||
folded = await b.fold_delegations()
|
||||
assert folded["status"] == "ready"
|
||||
assert b.memory.qa_history[-1].responses[0][1] == "answer to solve this"
|
||||
@@ -0,0 +1,282 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.experimental.fullduplex.joyvl.bridges.delegation import (
|
||||
DelegationResult,
|
||||
ImageEditDelegationBridge,
|
||||
ImageGenDelegationBridge,
|
||||
OpenAIDelegationBridge,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.joyvl.decision.policy import JoyVLPolicy, sample_frames
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_sample_frames_keeps_recent():
|
||||
assert sample_frames(["a", "b"], 4) == ["a", "b"]
|
||||
out = sample_frames([str(i) for i in range(10)], 4)
|
||||
assert out[-1] == "9" and len(out) == 4
|
||||
|
||||
|
||||
def test_build_messages_stable_head_and_query():
|
||||
p = JoyVLPolicy(num_frames=4)
|
||||
p.tick(2)
|
||||
p.set_query("count the bottles")
|
||||
msgs, user = p.build_messages([{"type": "image_url", "image_url": {"url": "x"}}])
|
||||
assert msgs[0]["role"] == "system" and "</silence>" in msgs[0]["content"]
|
||||
|
||||
assert any("count the bottles" in part.get("text", "") for part in user["content"])
|
||||
|
||||
|
||||
def test_commit_records_only_spoken():
|
||||
p = JoyVLPolicy()
|
||||
p.set_query("q")
|
||||
assert p.commit("</silence>").action.value == "silence"
|
||||
assert p.brain.response_records == []
|
||||
assert p.commit("</response> hello").action.value == "response"
|
||||
assert p.brain.response_records == [("0.0 seconds", "hello")]
|
||||
|
||||
|
||||
def test_commit_dedup_default_is_exact_match_only():
|
||||
p = JoyVLPolicy() # threshold 1.0 == reference behavior
|
||||
assert p.commit("</response> a red ball appears").action.value == "response"
|
||||
assert p.commit("</response> a red ball appears").action.value == "silence" # exact repeat dropped
|
||||
assert p.commit("</response> a red ball is appearing").action.value == "response" # near-dup kept
|
||||
|
||||
|
||||
def test_commit_dedup_threshold_drops_near_duplicates():
|
||||
p = JoyVLPolicy(response_dedup_threshold=0.8)
|
||||
assert p.commit("</response> a red ball appears").action.value == "response"
|
||||
assert p.commit("</response> a red ball appears!").action.value == "silence" # near-dup dropped
|
||||
assert p.commit("</response> the sky is now blue").action.value == "response" # distinct kept
|
||||
|
||||
|
||||
def test_commit_dedup_rearms_after_silence():
|
||||
p = JoyVLPolicy(response_dedup_threshold=0.85)
|
||||
assert p.commit("</response> someone raises a hand").action.value == "response"
|
||||
# no gap: an immediate repeat is still suppressed
|
||||
assert p.commit("</response> someone raises a hand").action.value == "silence"
|
||||
# a genuine silent tick (hand lowered) re-arms; the recurrence speaks again
|
||||
assert p.commit("</silence>").action.value == "silence"
|
||||
assert p.commit("</response> someone raises a hand").action.value == "response"
|
||||
|
||||
|
||||
class _FakeBrain(OpenAIDelegationBridge):
|
||||
"""Override only the network call so submit/poll plumbing is exercised for real."""
|
||||
|
||||
def __init__(self, answer, gate=None):
|
||||
super().__init__("http://unused/v1", "background-model")
|
||||
self._answer = answer
|
||||
self._gate = gate
|
||||
|
||||
async def _complete(self, messages):
|
||||
if self._gate is not None:
|
||||
await self._gate.wait()
|
||||
return self._answer
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_delegation_bridge_is_nonblocking_and_resolves():
|
||||
gate = asyncio.Event()
|
||||
bridge = _FakeBrain("the bottle is on the left shelf", gate=gate)
|
||||
frames = [("0.0s", "data:image/jpeg;base64,AAA")]
|
||||
task_id = await bridge.submit("where is the bottle?", "let me check", frames)
|
||||
|
||||
# poll must not block on the slow background brain
|
||||
assert (await bridge.poll(task_id)).status == "pending"
|
||||
|
||||
gate.set()
|
||||
await asyncio.sleep(0)
|
||||
for _ in range(10):
|
||||
result = await bridge.poll(task_id)
|
||||
if result.status != "pending":
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
assert result.is_ready
|
||||
assert "bottle is on the left shelf" in result.digest
|
||||
assert (await bridge.poll("missing")).status == "error"
|
||||
|
||||
|
||||
class _FakeImageBrain(ImageGenDelegationBridge):
|
||||
async def _call_images(self, payload):
|
||||
# echo the prompt back so the test can assert it was passed through
|
||||
return {"data": [{"b64_json": "QUJD"}]} # "ABC"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_delegation_bridge_returns_media_not_text():
|
||||
bridge = _FakeImageBrain("http://unused/v1")
|
||||
task_id = await bridge.submit("draw a red dragon", "", [])
|
||||
for _ in range(10):
|
||||
result = await bridge.poll(task_id)
|
||||
if result.status != "pending":
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
assert result.is_ready
|
||||
# the image rides in media as a data URL; digest is only a short text placeholder
|
||||
assert result.media == "data:image/png;base64,QUJD"
|
||||
assert "QUJD" not in result.digest and "red dragon" in result.digest
|
||||
|
||||
|
||||
class _FakeEditBrain(ImageEditDelegationBridge):
|
||||
def __init__(self, base_url, model):
|
||||
super().__init__(base_url, model)
|
||||
self.seen_frame = None
|
||||
|
||||
async def _call_chat(self, frame, instruction):
|
||||
self.seen_frame = frame
|
||||
return {"choices": [{"message": {"content": [{"image_url": {"url": "data:image/png;base64,Q0FSVA=="}}]}}]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_edit_delegation_conditions_on_frame_and_returns_media():
|
||||
bridge = _FakeEditBrain("http://unused/v1", "Qwen-Image-Edit")
|
||||
frames = [("0.0s", "data:image/jpeg;base64,OLD"), ("1.0s", "data:image/jpeg;base64,NOW")]
|
||||
task_id = await bridge.submit("把画面变成卡通风格", "", frames)
|
||||
for _ in range(10):
|
||||
result = await bridge.poll(task_id)
|
||||
if result.status != "pending":
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
assert result.is_ready
|
||||
assert bridge.seen_frame == "data:image/jpeg;base64,NOW" # latest frame, not the old one
|
||||
assert result.media == "data:image/png;base64,Q0FSVA=="
|
||||
assert result.media not in result.digest # image rides in media, not context
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_edit_delegation_errors_without_frame():
|
||||
bridge = _FakeEditBrain("http://unused/v1", "Qwen-Image-Edit")
|
||||
task_id = await bridge.submit("cartoonify", "", [])
|
||||
for _ in range(10):
|
||||
result = await bridge.poll(task_id)
|
||||
if result.status != "pending":
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
assert result.status == "error"
|
||||
|
||||
|
||||
class _NamedBridge:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
async def submit(self, question, note, frames):
|
||||
return f"{self.name}-1"
|
||||
|
||||
async def poll(self, task_id):
|
||||
return DelegationResult(task_id, "ready", digest=self.name)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routing_delegation_dispatches_by_request():
|
||||
from vllm_omni.experimental.fullduplex.joyvl.bridges.delegation import RoutingDelegationBridge
|
||||
|
||||
chat, image, edit = _NamedBridge("chat"), _NamedBridge("image"), _NamedBridge("edit")
|
||||
r = RoutingDelegationBridge(chat=chat, image=image, edit=edit)
|
||||
|
||||
async def route(q):
|
||||
tid = await r.submit(q, "", [])
|
||||
return (await r.poll(tid)).digest
|
||||
|
||||
assert await route("把你看到的画面变成卡通风格") == "edit" # restyle current view
|
||||
assert await route("画一只红色的龙") == "image" # generate a new image
|
||||
assert await route("这道数学题的答案是什么") == "chat" # hard question -> chat brain
|
||||
|
||||
|
||||
class _Delegation:
|
||||
async def submit(self, question, note, frames):
|
||||
return "t1"
|
||||
|
||||
async def poll(self, task_id):
|
||||
return DelegationResult(task_id, "ready", digest="background answer")
|
||||
|
||||
|
||||
def test_build_delegation_off_unless_backend_configured():
|
||||
from vllm_omni.experimental.fullduplex.joyvl.serving.config import InteractionConfig
|
||||
from vllm_omni.experimental.fullduplex.joyvl.serving.server import SessionManager
|
||||
|
||||
# No backend URL -> delegation disabled (None), NOT a silent stub that folds fake answers.
|
||||
assert SessionManager._build_delegation(InteractionConfig()) is None
|
||||
assert SessionManager._build_delegation(InteractionConfig(delegation_kind="router")) is None
|
||||
# Stub only on explicit opt-in.
|
||||
assert SessionManager._build_delegation(InteractionConfig(delegation_kind="stub")) is not None
|
||||
# A real chat backend -> a live bridge.
|
||||
cfg = InteractionConfig(delegation_backend_url="http://x/v1", delegation_model="m")
|
||||
assert SessionManager._build_delegation(cfg) is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delegation_cancel_drops_task():
|
||||
from vllm_omni.experimental.fullduplex.joyvl.bridges.delegation import StubDelegationBridge
|
||||
|
||||
b = StubDelegationBridge(ready_after_ticks=1)
|
||||
task_id = await b.submit("q", "", [])
|
||||
b.cancel(task_id)
|
||||
# after cancel the task is gone -> poll reports it as unknown rather than leaking
|
||||
assert (await b.poll(task_id)).status == "error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delegation_aclose_clears_pending():
|
||||
from vllm_omni.experimental.fullduplex.joyvl.bridges.delegation import StubDelegationBridge
|
||||
|
||||
b = StubDelegationBridge(ready_after_ticks=5)
|
||||
task_id = await b.submit("q", "", [])
|
||||
await b.aclose()
|
||||
assert (await b.poll(task_id)).status == "error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_reset_cancels_and_awaits_consolidation():
|
||||
from vllm_omni.experimental.fullduplex.joyvl.serving.config import InteractionConfig
|
||||
from vllm_omni.experimental.fullduplex.joyvl.serving.session import InteractionSession
|
||||
|
||||
class _Backend:
|
||||
async def generate(self, *a, **k):
|
||||
return "</silence>", None
|
||||
|
||||
async def aclose(self):
|
||||
pass
|
||||
|
||||
class _Summarizer:
|
||||
async def summarize_chunk(self, *a, **k):
|
||||
await asyncio.sleep(100) # hang so the consolidation task is in-flight
|
||||
return "x"
|
||||
|
||||
async def compress_to_long_term(self, *a, **k):
|
||||
return ""
|
||||
|
||||
async def aclose(self):
|
||||
pass
|
||||
|
||||
sess = InteractionSession("s", InteractionConfig(), _Backend(), summarizer=_Summarizer())
|
||||
sess._spawn_consolidation(1, [("0.0s", "u")])
|
||||
assert len(sess._consolidating) == 1
|
||||
await sess.reset() # must cancel AND await the in-flight task, not just clear the set
|
||||
assert len(sess._consolidating) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_aclose_closes_without_error():
|
||||
from vllm_omni.experimental.fullduplex.joyvl.serving.config import InteractionConfig
|
||||
from vllm_omni.experimental.fullduplex.joyvl.serving.server import SessionManager
|
||||
|
||||
mgr = SessionManager(InteractionConfig(enable_memory=False, enable_delegation=False))
|
||||
await mgr.aclose()
|
||||
await mgr.aclose() # idempotent
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delegation_submit_and_fold():
|
||||
p = JoyVLPolicy(delegation=_Delegation())
|
||||
action = p.commit("</response> hold on <delegation> hard question")
|
||||
assert action.action.value == "delegate"
|
||||
info = await p.submit_if_delegate(action)
|
||||
assert info["status"] == "submitted"
|
||||
folded = await p.fold_delegations()
|
||||
assert folded["status"] == "ready"
|
||||
assert p.brain.memory.qa_history[-1].responses[0][1] == "background answer"
|
||||
@@ -0,0 +1,172 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.experimental.fullduplex.core import protocol as ev
|
||||
from vllm_omni.experimental.fullduplex.core.adapter import DuplexCapability, OutputChunk
|
||||
from vllm_omni.experimental.fullduplex.core.runtime import DuplexRuntime
|
||||
from vllm_omni.experimental.fullduplex.core.session import DuplexSession, DuplexSessionConfig
|
||||
from vllm_omni.experimental.fullduplex.joyvl.adapter import JoyVLDuplexAdapter
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
async def _feed(events):
|
||||
for e in events:
|
||||
yield e
|
||||
|
||||
|
||||
def _collector():
|
||||
out: list[dict] = []
|
||||
|
||||
async def emit(event: dict) -> None:
|
||||
out.append(event)
|
||||
|
||||
return out, emit
|
||||
|
||||
|
||||
class _FakeAdapter:
|
||||
def __init__(self, chunks, barge_after=None):
|
||||
self._chunks = chunks
|
||||
self._barge_after = barge_after
|
||||
|
||||
def capabilities(self):
|
||||
return DuplexCapability(frozenset({"text"}), frozenset({"text"}), proactive=False)
|
||||
|
||||
async def on_input(self, session, modality, data):
|
||||
pass
|
||||
|
||||
def should_respond(self, session):
|
||||
return True
|
||||
|
||||
async def respond(self, session) -> AsyncIterator[OutputChunk]:
|
||||
for i, c in enumerate(self._chunks):
|
||||
if self._barge_after is not None and i == self._barge_after:
|
||||
session.barge_in()
|
||||
yield OutputChunk("text", c)
|
||||
|
||||
async def on_barge_in(self, session):
|
||||
pass
|
||||
|
||||
async def on_playback_ack(self, session, cursor):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_basic_response():
|
||||
session = DuplexSession("s", DuplexSessionConfig(output_modalities=("text",)))
|
||||
rt = DuplexRuntime(session, _FakeAdapter(["a", "b"]))
|
||||
out, emit = _collector()
|
||||
await rt.run(_feed([{"type": ev.INPUT_COMMIT}, {"type": ev.CLOSE}]), emit)
|
||||
types = [e["type"] for e in out]
|
||||
assert types == [ev.RESPONSE_CREATED, ev.RESPONSE_DELTA, ev.RESPONSE_DELTA, ev.RESPONSE_DONE]
|
||||
assert [e["data"] for e in out if e["type"] == ev.RESPONSE_DELTA] == ["a", "b"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_barge_in_drops_stale_output():
|
||||
session = DuplexSession("s")
|
||||
rt = DuplexRuntime(session, _FakeAdapter(["a", "b", "c"], barge_after=1))
|
||||
out, emit = _collector()
|
||||
await rt.run(_feed([{"type": ev.INPUT_COMMIT}, {"type": ev.CLOSE}]), emit)
|
||||
data = [e["data"] for e in out if e["type"] == ev.RESPONSE_DELTA]
|
||||
assert data == ["a"]
|
||||
assert ev.RESPONSE_DONE not in [e["type"] for e in out]
|
||||
|
||||
|
||||
class _SlowAdapter:
|
||||
def capabilities(self):
|
||||
return DuplexCapability(frozenset({"text"}), frozenset({"text"}), proactive=False)
|
||||
|
||||
async def on_input(self, session, modality, data):
|
||||
pass
|
||||
|
||||
def should_respond(self, session):
|
||||
return True
|
||||
|
||||
async def respond(self, session) -> AsyncIterator[OutputChunk]:
|
||||
for c in ["a", "b", "c", "d", "e"]:
|
||||
await asyncio.sleep(0.02)
|
||||
yield OutputChunk("text", c)
|
||||
|
||||
async def on_barge_in(self, session):
|
||||
pass
|
||||
|
||||
async def on_playback_ack(self, session, cursor):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_cancel_event_interrupts_active_response():
|
||||
session = DuplexSession("s")
|
||||
rt = DuplexRuntime(session, _SlowAdapter())
|
||||
out, emit = _collector()
|
||||
|
||||
async def feed():
|
||||
yield {"type": ev.INPUT_COMMIT}
|
||||
await asyncio.sleep(0.03)
|
||||
yield {"type": ev.RESPONSE_CANCEL}
|
||||
yield {"type": ev.CLOSE}
|
||||
|
||||
await rt.run(feed(), emit)
|
||||
deltas = [e["data"] for e in out if e["type"] == ev.RESPONSE_DELTA]
|
||||
types = [e["type"] for e in out]
|
||||
assert len(deltas) < 5
|
||||
assert ev.RESPONSE_CANCELLED in types
|
||||
assert ev.RESPONSE_DONE not in types
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_new_response_supersedes_inflight_without_blocking():
|
||||
# A second response trigger arriving while the first is still streaming must
|
||||
# supersede it (barge-in) rather than block the input loop until it finishes.
|
||||
session = DuplexSession("s")
|
||||
rt = DuplexRuntime(session, _SlowAdapter())
|
||||
out, emit = _collector()
|
||||
|
||||
async def feed():
|
||||
yield {"type": ev.INPUT_COMMIT}
|
||||
await asyncio.sleep(0.03) # let the first response emit ~one chunk
|
||||
yield {"type": ev.INPUT_COMMIT} # supersedes the in-flight first response
|
||||
await asyncio.sleep(0.2) # let the second response finish
|
||||
yield {"type": ev.CLOSE}
|
||||
|
||||
await rt.run(feed(), emit)
|
||||
created = [e for e in out if e["type"] == ev.RESPONSE_CREATED]
|
||||
done = [e for e in out if e["type"] == ev.RESPONSE_DONE]
|
||||
# two responses created; only the latest one completes (the first was cancelled)
|
||||
assert len(created) == 2
|
||||
assert len(done) == 1
|
||||
assert done[0]["response_index"] == created[-1]["response_index"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_joyvl_adapter_speaks_then_silences():
|
||||
replies = iter(["</response> a fire is breaking out", "</silence>"])
|
||||
|
||||
async def fake_generate(messages):
|
||||
return next(replies)
|
||||
|
||||
adapter = JoyVLDuplexAdapter(fake_generate, num_frames=4)
|
||||
cfg = DuplexSessionConfig(input_modalities=("video", "text"), output_modalities=("text",), proactive=True)
|
||||
rt = DuplexRuntime(DuplexSession("s", cfg), adapter)
|
||||
out, emit = _collector()
|
||||
await rt.run(
|
||||
_feed(
|
||||
[
|
||||
{"type": ev.INPUT_APPEND, "modality": "text", "data": "alert me if a fire breaks out"},
|
||||
{"type": ev.INPUT_APPEND, "modality": "video", "data": "data:image/jpeg;base64,AAA"},
|
||||
{"type": ev.INPUT_APPEND, "modality": "video", "data": "data:image/jpeg;base64,BBB"},
|
||||
{"type": ev.CLOSE},
|
||||
]
|
||||
),
|
||||
emit,
|
||||
)
|
||||
deltas = [e["data"] for e in out if e["type"] == ev.RESPONSE_DELTA]
|
||||
assert "a fire is breaking out" in deltas
|
||||
|
||||
assert deltas.count("a fire is breaking out") == 1
|
||||
Reference in new issue
Block a user