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

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

No files matched your search

@@ -5,7 +5,7 @@ from __future__ import annotations
import pytest
from vllm_omni.entrypoints.duplex.commit_policy import (
from vllm_omni.engine.duplex.session.commit_policy import (
CommitAction,
CommitSnapshot,
decide_commit_action,
@@ -24,7 +24,7 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
speech_since_commit=True,
active_response_id=None,
overlap_speech_ms=0,
native_response_in_progress=False,
response_in_progress=False,
playback_active=False,
),
CommitAction.START_AUTO_RESPONSE,
@@ -36,7 +36,7 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
speech_since_commit=True,
active_response_id=None,
overlap_speech_ms=800,
native_response_in_progress=True,
response_in_progress=True,
playback_active=True,
),
CommitAction.START_AUTO_RESPONSE,
@@ -48,7 +48,7 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
speech_since_commit=False,
active_response_id=None,
overlap_speech_ms=0,
native_response_in_progress=True,
response_in_progress=True,
playback_active=False,
),
CommitAction.COMMIT_ONLY,
@@ -60,7 +60,7 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
speech_since_commit=True,
active_response_id=None,
overlap_speech_ms=800,
native_response_in_progress=True,
response_in_progress=True,
playback_active=False,
),
CommitAction.DEFER_ACTIVE_RESPONSE,
@@ -72,7 +72,7 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
speech_since_commit=True,
active_response_id=None,
overlap_speech_ms=800,
native_response_in_progress=False,
response_in_progress=False,
playback_active=True,
),
CommitAction.DEFER_ACTIVE_RESPONSE,
@@ -84,7 +84,7 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
speech_since_commit=True,
active_response_id=None,
overlap_speech_ms=0,
native_response_in_progress=False,
response_in_progress=False,
playback_active=False,
),
CommitAction.COMMIT_ONLY,
@@ -0,0 +1,78 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""The cumulative Stage1 audio cursor.
Stage1 hands the data plane a snapshot of every sample it has generated for the
request so far, and only the new tail may reach the client. The cursor that
makes that possible is the one piece of per-request state whose loss is silent
and expensive: rewinding it re-sends the whole session as a single delta.
"""
from __future__ import annotations
import numpy as np
import pytest
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.data_plane import (
MiniCPMO45DataPlaneSession,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
REQUEST_ID = "duplex-s.c2Vzc2lvbg==.e.0.r.stage0"
def _session() -> MiniCPMO45DataPlaneSession:
return MiniCPMO45DataPlaneSession(lambda *args, **kwargs: "")
def _snapshot(num_samples: int) -> np.ndarray:
return np.arange(num_samples, dtype=np.float32)
def _sliced_len(session: MiniCPMO45DataPlaneSession, num_samples: int) -> int:
sliced = session.slice_cumulative_audio(REQUEST_ID, _snapshot(num_samples))
if sliced is None:
return 0
return int(np.asarray(sliced).reshape(-1).size)
def test_a_growing_snapshot_yields_only_the_new_tail() -> None:
session = _session()
assert _sliced_len(session, 24000) == 24000
assert _sliced_len(session, 48000) == 24000
assert _sliced_len(session, 48000) == 0 # unchanged snapshot emits nothing
def test_a_placeholder_unit_does_not_rewind_the_cursor() -> None:
"""The failure behind a 300s benchmark case producing 1373s of audio.
A continuation unit arrives as a one-sample snapshot. Taking that as "the
producer restarted" drops the cursor to 1, so the next real snapshot is
sliced from the start and the client is handed the entire session again.
"""
session = _session()
assert _sliced_len(session, 637440) == 637440
assert _sliced_len(session, 1) == 1
assert _sliced_len(session, 1) == 1
assert _sliced_len(session, 1) == 1
# 649920 - 637440, not 649920 - 1.
assert _sliced_len(session, 649920) == 12480
def test_a_genuinely_restarted_producer_is_still_picked_up() -> None:
"""Two consecutive below-cursor snapshots that grow are a new buffer."""
session = _session()
assert _sliced_len(session, 48000) == 48000
assert _sliced_len(session, 1000) == 1000 # head of the new buffer
assert _sliced_len(session, 3000) == 2000 # its tail, not 3000 - 48000
assert _sliced_len(session, 5000) == 2000
def test_close_stream_releases_the_cursor_for_the_next_stream() -> None:
session = _session()
assert _sliced_len(session, 48000) == 48000
session.close_stream(REQUEST_ID)
assert _sliced_len(session, 24000) == 24000
@@ -55,20 +55,18 @@ class TestRegistryDeclaration:
assert pipeline.model_type == _PIPELINE_KEY
assert pipeline.model_arch == "MiniCPMO45OmniForConditionalGeneration"
def test_native_duplex_control_is_explicit_without_a_fixed_session_cap(self) -> None:
def test_duplex_plugin_is_declared_without_a_fixed_session_cap(self) -> None:
pipeline = OMNI_PIPELINES[_PIPELINE_KEY]
assert pipeline.duplex_control_enabled is True
assert pipeline.duplex_serving_adapter == (
"vllm_omni.model_executor.models.minicpmo_4_5.duplex.serving_adapter.MiniCPMO45ServingRuntimeAdapter"
assert pipeline.duplex_plugin == (
"vllm_omni.model_executor.models.minicpmo_4_5.duplex.plugin.MiniCPMO45DuplexPlugin"
)
assert not hasattr(pipeline, "max_native_duplex_sessions")
def test_ordinary_pipeline_defaults_to_no_duplex_control(self) -> None:
def test_ordinary_pipeline_declares_no_duplex_plugin(self) -> None:
pipeline = PipelineConfig(
model_type="ordinary", stages=(StagePipelineConfig(stage_id=0, model_stage="a", final_output=True),)
)
assert pipeline.duplex_control_enabled is False
assert pipeline.duplex_serving_adapter is None
assert pipeline.duplex_plugin is None
assert not hasattr(pipeline, "max_native_duplex_sessions")
@@ -0,0 +1,20 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Nemotron VoiceChat duplex is not on the duplex plugin framework yet.
RFC vllm-omni#7181 splits the port: this PR lands the unified framework with
MiniCPM-o 4.5, and the follow-up PR moves Nemotron VoiceChat onto
``DuplexModelPlugin``. Until then the modules under test still import the
pre-framework duplex runtime / serving adapter that this PR removed, so
collecting them would fail at import time and abort the whole pytest session.
The tests themselves are unchanged and come back with the port.
Listed per file rather than globbed, so the coverage loss is visible and shrinks
file by file as the port lands. ``test_input.py`` imports nothing that was
removed and keeps running.
"""
#: Both import ``DuplexFence`` from ``engine.duplex.messages``, which no longer
#: exports it; ``test_data_plane`` also imports ``engine.duplex.runtime``.
collect_ignore = ["test_data_plane.py", "test_runtime_contract.py"]
@@ -7,9 +7,9 @@ from types import SimpleNamespace
import numpy as np
import pytest
from vllm_omni.engine.duplex.runtime import duplex_resource_request_id
from vllm_omni.engine.duplex.messages import DuplexFence
from vllm_omni.engine.duplex.runtime import duplex_resource_request_id
from vllm_omni.model_executor.models.nemotron_voicechat.duplex.data_plane import (
NemotronVoiceChatDataPlaneContext,
NemotronVoiceChatDataPlaneSession,
@@ -0,0 +1,21 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""PersonaPlex duplex is not on the duplex plugin framework yet.
RFC vllm-omni#7181 splits the port: this PR lands the unified framework with
MiniCPM-o 4.5, and the follow-up PR moves PersonaPlex onto ``DuplexModelPlugin``.
Until then the modules under test still import the pre-framework duplex
runtime / serving adapter that this PR removed, so collecting them would fail
at import time and abort the whole pytest session. The tests themselves are
unchanged and come back with the port.
Listed per file rather than globbed, so the coverage loss is visible and shrinks
file by file as the port lands.
"""
#: ``test_unified_runtime`` imports ``engine.duplex.runtime`` and
#: ``entrypoints.duplex.runtime_adapter`` directly. ``test_stage0_runtime`` fails
#: transitively: ``personaplex.duplex.runtime_extension`` imports ``DuplexFence``
#: from ``engine.duplex.messages``, which no longer exports it.
collect_ignore = ["test_stage0_runtime.py", "test_unified_runtime.py"]
@@ -8,6 +8,12 @@ from types import SimpleNamespace
import numpy as np
import pytest
from vllm.sampling_params import SamplingParams
from vllm_omni.engine.duplex.runtime import (
DuplexInputMode,
)
from vllm_omni.entrypoints.duplex.runtime_adapter import (
ServingRuntimeConfigError,
)
from tests.e2e.online_serving import personaplex_realtime_duplex as e2e_driver
from vllm_omni.config.stage_config import (
@@ -15,12 +21,6 @@ from vllm_omni.config.stage_config import (
merge_pipeline_deploy,
)
from vllm_omni.engine.duplex.messages import DuplexFence
from vllm_omni.engine.duplex.runtime import (
DuplexInputMode,
)
from vllm_omni.entrypoints.duplex.runtime_adapter import (
ServingRuntimeConfigError,
)
from vllm_omni.model_executor.models.personaplex.duplex.config import DEFAULT_PERSONA
from vllm_omni.model_executor.models.personaplex.duplex.data_plane import (
PersonaPlexDataPlaneContext,