[Bugfix][MiniCPM-o] Prevent native duplex Stage-1 prompt overflow

Signed-off-by: Sy03 <1370724210@qq.com>
This commit is contained in:
Sy03 committed 2026-08-24 05:13:31 +08:00
1 parent 1c917b2751
commit 5edee1393a
32 files changed
+1591 -99

No files matched your search

+1
View File
@@ -166,6 +166,7 @@ steps:
- tests/e2e/online_serving/test_minicpmo_realtime_duplex_drivers.py
- tests/e2e/online_serving/helpers/minicpmo_realtime_duplex_scenarios.py
- tests/e2e/online_serving/run_minicpmo_realtime_duplex_multi_session.py
- tests/e2e/online_serving/run_minicpmo_realtime_duplex_server_vad.py
- tests/e2e/online_serving/run_minicpmo_realtime_duplex_soft_interrupt.py
- vllm_omni/deploy/minicpmo_4_5.yaml
- tests/e2e/online_serving/helpers/minicpmo_4_5_duplex.py
+1
View File
@@ -229,6 +229,7 @@ steps:
- tests/e2e/online_serving/test_minicpmo_realtime_duplex_drivers.py
- tests/e2e/online_serving/helpers/minicpmo_realtime_duplex_scenarios.py
- tests/e2e/online_serving/run_minicpmo_realtime_duplex_multi_session.py
- tests/e2e/online_serving/run_minicpmo_realtime_duplex_server_vad.py
- tests/e2e/online_serving/run_minicpmo_realtime_duplex_soft_interrupt.py
- vllm_omni/deploy/minicpmo_4_5.yaml
- tests/e2e/online_serving/helpers/minicpmo_4_5_duplex.py
@@ -388,7 +388,7 @@ def parse_args() -> argparse.Namespace:
"--temperature",
type=float,
default=None,
help="Stage0 sampling temperature; response-required soft-interrupt uses 0.0.",
help="Stage0 sampling temperature; omitted to preserve the model default.",
)
parser.add_argument("--no-realtime-pacing", action="store_true")
parser.add_argument("--require-audio", action="store_true")
+5
View File
@@ -74,6 +74,7 @@ dev = [
"kernels==0.14.1", # for accuracy tests comparison with diffusers output (0.15.0 incompatible with diffusers 0.38.0)
"s3tokenizer==0.3.0", # for tts test
"step-audio2==1.0.0", # Step-Audio2 / MiniCPM-o 4.5 Token2Wav (flashcosyvoice / hyperpyyaml)
"silero-vad==6.1", # Realtime server-VAD duplex E2E
]
demo = [
@@ -83,6 +84,10 @@ demo = [
"cn2an>=0.5.22", # Chinese number normalization for TTS (e.g. "1次" -> "一次")
]
server-vad = [
"silero-vad==6.1",
]
forced-aligner = [
"qwen-asr>=0.0.6", # faithful Japanese/Korean word segmentation for the TTS forced aligner
]
@@ -0,0 +1,115 @@
"""Tests for ``OmniARScheduler._drop_aborted_queued_requests``.
``schedule()`` sweeps ``FINISHED_ABORTED`` requests out of the queues before
handing control to upstream, because upstream ``Scheduler.schedule()`` raises
``RuntimeError: Invalid request status: FINISHED_ABORTED`` for any request it
admits in a finished state -- which kills the stage's engine core, not just
the request.
Regression for the sweep missing ``skipped_waiting``, the third queue
upstream admits from. An aborted duplex session parked there was re-selected
by ``_select_waiting_queue_for_scheduling`` on a later tick and crashed the
stage.
"""
from __future__ import annotations
import pytest
from vllm.v1.core.sched.request_queue import SchedulingPolicy, create_request_queue
from vllm.v1.request import RequestStatus
from vllm_omni.core.sched.omni_ar_scheduler import OmniARScheduler
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
_POLICIES = [
pytest.param(SchedulingPolicy.FCFS, id="fcfs"),
pytest.param(SchedulingPolicy.PRIORITY, id="priority"),
]
class _StubRequest:
"""Minimal ``Request`` stub with the surface the sweep exercises.
Carries the ``(priority, arrival_time)`` ordering ``PriorityRequestQueue``
relies on so the same stub works under both scheduling policies.
"""
def __init__(self, request_id: str, status: RequestStatus, arrival_time: float = 0.0) -> None:
self.request_id = request_id
self.status = status
self.priority = 0
self.arrival_time = arrival_time
def __lt__(self, other: _StubRequest) -> bool:
return (self.priority, self.arrival_time) < (other.priority, other.arrival_time)
def _make_scheduler(policy: SchedulingPolicy, *, waiting=(), skipped_waiting=(), running=()):
"""Bare scheduler with just the surface the sweep reads."""
scheduler = OmniARScheduler.__new__(OmniARScheduler)
scheduler.waiting = create_request_queue(policy)
for req in waiting:
scheduler.waiting.add_request(req)
scheduler.skipped_waiting = create_request_queue(policy)
for req in skipped_waiting:
scheduler.skipped_waiting.add_request(req)
scheduler.running = list(running)
return scheduler
@pytest.mark.parametrize("policy", _POLICIES)
def test_sweep_drops_aborted_request_from_skipped_waiting(policy: SchedulingPolicy) -> None:
"""The sweep must cover ``skipped_waiting``.
Upstream ``schedule()`` admits from ``skipped_waiting`` as readily as from
``waiting``, so an aborted request left there is re-selected and raises.
"""
aborted = _StubRequest("req-aborted", RequestStatus.FINISHED_ABORTED)
scheduler = _make_scheduler(policy, skipped_waiting=[aborted])
scheduler._drop_aborted_queued_requests()
assert list(scheduler.skipped_waiting) == []
@pytest.mark.parametrize("policy", _POLICIES)
def test_sweep_drops_aborted_requests_from_waiting_and_running(policy: SchedulingPolicy) -> None:
"""``waiting`` and ``running`` stay covered alongside the new queue."""
aborted_waiting = _StubRequest("req-waiting", RequestStatus.FINISHED_ABORTED)
aborted_running = _StubRequest("req-running", RequestStatus.FINISHED_ABORTED)
scheduler = _make_scheduler(
policy,
waiting=[aborted_waiting],
running=[aborted_running],
)
scheduler._drop_aborted_queued_requests()
assert list(scheduler.waiting) == []
assert scheduler.running == []
@pytest.mark.parametrize("policy", _POLICIES)
def test_sweep_leaves_live_requests_intact(policy: SchedulingPolicy) -> None:
"""The sweep must not over-sweep.
A request parked in ``skipped_waiting`` on a blocked waiting status is one
upstream still intends to schedule; only the aborted entry may go.
"""
blocked = _StubRequest("req-blocked", RequestStatus.WAITING_FOR_STREAMING_REQ, arrival_time=0.0)
aborted = _StubRequest("req-aborted", RequestStatus.FINISHED_ABORTED, arrival_time=1.0)
live_waiting = _StubRequest("req-live-waiting", RequestStatus.WAITING)
live_running = _StubRequest("req-live-running", RequestStatus.RUNNING)
scheduler = _make_scheduler(
policy,
waiting=[live_waiting],
skipped_waiting=[blocked, aborted],
running=[live_running],
)
scheduler._drop_aborted_queued_requests()
assert list(scheduler.skipped_waiting) == [blocked]
assert list(scheduler.waiting) == [live_waiting]
assert scheduler.running == [live_running]
@@ -28,7 +28,9 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _make_scheduler(*, stage_id: int = 0) -> OmniARScheduler:
sched = OmniARScheduler.__new__(OmniARScheduler)
sched._new_prompt_len_snapshot = {}
sched.vllm_config = SimpleNamespace(model_config=SimpleNamespace(stage_id=stage_id))
sched.vllm_config = SimpleNamespace(
model_config=SimpleNamespace(stage_id=stage_id, max_model_len=None),
)
sched.num_waiting_for_streaming_input = 0
sched.log_stats = False
sched.chunk_transfer_adapter = None
@@ -210,6 +212,81 @@ def test_running_decode_step_without_inter_stage_payload_does_not_raise() -> Non
sched.chunk_transfer_adapter.save_async.assert_not_called()
def test_stale_async_frame_is_dropped_before_output_processing() -> None:
session = _make_request()
session.status = RequestStatus.RUNNING
session.num_in_flight_tokens = 2
session.num_computed_tokens = session.num_tokens + 1
session.num_output_placeholders = 1
session.async_tokens_to_discard = 1
session.sampling_params = SimpleNamespace(num_logprobs=1)
num_computed_tokens = session.num_computed_tokens
num_output_placeholders = session.num_output_placeholders
sched = MagicMock()
sched.requests = {session.request_id: session}
sched.perf_metrics = None
sched.structured_output_manager.should_advance.return_value = False
def discard_stale_output(request: Request, token_ids: list[int]) -> tuple[list[int], bool]:
request.async_tokens_to_discard = 0
return token_ids, False
sched._update_request_with_output.side_effect = discard_stale_output
sched._process_kv_transfer_trigger.return_value = False
sched.chunk_transfer_adapter = MagicMock()
sched.running = [session]
sched.waiting_for_transfer_free = set()
sched.transfer_triggered_requests = set()
sched.active_kv_transfers = set()
sched.pending_stop_after_extraction = set()
sched.connector = None
sched.kv_cache_manager.take_events.return_value = None
sched.finished_req_ids_dict = {}
sched.make_stats.return_value = None
scheduler_output = MagicMock(spec=SchedulerOutput)
scheduler_output.num_scheduled_tokens = {session.request_id: 1}
scheduler_output.scheduled_spec_decode_tokens = {}
scheduler_output.num_invalid_spec_tokens = 0
model_runner_output = MagicMock(spec=ModelRunnerOutput)
model_runner_output.sampled_token_ids = [[42]]
model_runner_output.logprobs = None
model_runner_output.prompt_logprobs_dict = {}
model_runner_output.pooler_output = None
model_runner_output.multimodal_outputs = None
model_runner_output.inter_stage_outputs = [{"hidden": object()}]
model_runner_output.num_nans_in_logits = None
model_runner_output.kv_connector_output = None
model_runner_output.cudagraph_stats = None
model_runner_output.req_id_to_index = {session.request_id: 0}
model_runner_output.routed_experts = None
OmniARScheduler.update_from_output(sched, scheduler_output, model_runner_output)
assert session.async_tokens_to_discard == 0
assert session.status == RequestStatus.RUNNING
assert session.num_computed_tokens == num_computed_tokens
assert session.num_output_placeholders == num_output_placeholders
sched.chunk_transfer_adapter.save_async.assert_not_called()
session.sampling_params = SamplingParams(max_tokens=8)
next_payload = {"hidden": object()}
sched._update_request_with_output.side_effect = None
sched._update_request_with_output.return_value = ([43], False)
model_runner_output.sampled_token_ids = [[43]]
model_runner_output.inter_stage_outputs = [next_payload]
OmniARScheduler.update_from_output(sched, scheduler_output, model_runner_output)
sched.chunk_transfer_adapter.save_async.assert_called_once_with(
next_payload,
session,
False,
)
def test_stage0_streaming_update_discards_outstanding_async_placeholder_token() -> None:
sched = _make_scheduler(stage_id=0)
session = _make_request()
@@ -354,3 +431,34 @@ def test_ready_async_chunk_prompt_replacement_releases_stale_kv_once() -> None:
assert sched.chunk_transfer_adapter.replaced_streaming_prompt_ids == set()
assert sched.chunk_transfer_adapter.requests_with_ready_chunks == {session.request_id}
assert sched.chunk_transfer_adapter.requests_num_chunks_sent == {}
def test_native_duplex_prompt_reset_prevents_stage1_context_overflow() -> None:
sched = _make_scheduler(stage_id=1)
sched.vllm_config.model_config.max_model_len = 4096
sched.chunk_transfer_adapter = SimpleNamespace(
receives_chunks=False,
segment_finished_requests=set(),
)
session = _make_request()
session.status = RequestStatus.WAITING_FOR_STREAMING_REQ
session.resumable = True
session.prompt_token_ids = [0] * 3
session._all_token_ids.clear()
session._all_token_ids.extend([0] * 4095)
session._output_token_ids.extend([1] * 4080)
session.num_prompt_tokens = 3
session.num_computed_tokens = 4095
update = _make_update([0] * 16)
update.model_intermediate_buffer = {"native_duplex": True}
sched._update_request_as_session(session, update)
assert session.prompt_token_ids == [0] * 16
assert list(session._all_token_ids) == [0] * 16
assert session._output_token_ids == []
assert session.num_prompt_tokens == 16
assert session.num_computed_tokens == 0
assert session.model_intermediate_buffer == update.model_intermediate_buffer
assert session.status == RequestStatus.WAITING
@@ -76,6 +76,9 @@ def _make_scheduler(
else:
hf_config.voxcpm2_runtime_config = runtime_config
sched.vllm_config = SimpleNamespace(model_config=model_config)
# Upstream ``Scheduler.__init__`` always creates ``skipped_waiting``; the
# abort sweep at the top of ``schedule()`` reads it.
sched.skipped_waiting = _MockQueue()
return sched
@@ -0,0 +1,254 @@
"""Tests for the ``num_waiting_for_streaming_input`` resync placed in
``OmniARScheduler.finish_requests`` / ``OmniGenerationScheduler.finish_requests``
and in ``OmniARScheduler.schedule``.
Upstream keeps that counter incrementally (``+1`` when a resumable request
parks in a waiting queue as ``WAITING_FOR_STREAMING_REQ``, ``-1`` when the
next update arrives or the request is finished) and
``Scheduler.get_num_unfinished_requests`` *subtracts* it from the waiting
queues. Omni rewrites ``request.status`` outside both hooks -- the
chunk-transfer adapter's park/restore and
``_realign_request_status_to_queues`` -- so a counted request can leave
``WAITING_FOR_STREAMING_REQ`` without the matching decrement and inflate the
counter for the lifetime of the process.
An inflated counter does not degrade throughput, it hangs the stage:
``EngineCore.has_work()`` reads false while a live request sits in
``waiting``, so the engine blocks in ``input_queue.get()`` and never calls
``schedule()`` again. Regression for the Qwen3-Omni duplex symptom where the
server answered the first conversation after a boot and was silent from the
second on while still reporting healthy.
"""
from __future__ import annotations
import pytest
from vllm.v1.request import RequestStatus
import vllm_omni.core.sched.omni_ar_scheduler as ar_sched_mod
import vllm_omni.core.sched.omni_generation_scheduler as gen_sched_mod
from vllm_omni.core.sched.omni_ar_scheduler import OmniARScheduler
from vllm_omni.core.sched.omni_generation_scheduler import OmniGenerationScheduler
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class _StubRequest:
def __init__(self, request_id: str, status: RequestStatus) -> None:
self.request_id = request_id
self.status = status
def is_finished(self) -> bool:
return RequestStatus.is_finished(self.status)
_SCHEDULER_PARAMS = [
pytest.param(OmniARScheduler, ar_sched_mod, id="ar"),
pytest.param(OmniGenerationScheduler, gen_sched_mod, id="generation"),
]
def _make_scheduler(scheduler_cls, *, requests, running, waiting, counter, skipped=None):
scheduler = scheduler_cls.__new__(scheduler_cls)
scheduler.chunk_transfer_adapter = None
scheduler.input_coordinator = None
scheduler.requests = requests
scheduler.running = running
scheduler.waiting = waiting
scheduler.skipped_waiting = skipped if skipped is not None else []
scheduler.num_waiting_for_streaming_input = counter
return scheduler
def _upstream_num_unfinished(scheduler) -> int:
"""Mirror of ``Scheduler.get_num_unfinished_requests`` arithmetic."""
num_waiting = (
len(scheduler.waiting) + len(scheduler.skipped_waiting) - scheduler.num_waiting_for_streaming_input
)
return num_waiting + len(scheduler.running)
@pytest.mark.parametrize(("scheduler_cls", "scheduler_mod"), _SCHEDULER_PARAMS)
def test_finish_requests_clears_counter_left_by_a_stomped_status(
monkeypatch: pytest.MonkeyPatch,
scheduler_cls,
scheduler_mod,
) -> None:
"""The abort case that wedged the talker.
The request was counted while parked for streaming input, then had its
status rewritten to ``RUNNING`` by omni's queue surgery, so upstream's
``finish_requests`` took the running branch and never decremented. With
no parked requests left, the counter must land at 0.
"""
aborted = _StubRequest("req-aborted", RequestStatus.RUNNING)
scheduler = _make_scheduler(
scheduler_cls,
requests={}, # upstream already dropped it
running=[],
waiting=[],
counter=1,
)
monkeypatch.setattr(
scheduler_mod.VLLMScheduler,
"finish_requests",
lambda self, request_ids, finished_status: [aborted],
)
scheduler_cls.finish_requests(scheduler, [aborted.request_id], RequestStatus.FINISHED_ABORTED)
assert scheduler.num_waiting_for_streaming_input == 0
@pytest.mark.parametrize(("scheduler_cls", "scheduler_mod"), _SCHEDULER_PARAMS)
def test_next_request_is_visible_as_work_after_the_abort(
monkeypatch: pytest.MonkeyPatch,
scheduler_cls,
scheduler_mod,
) -> None:
"""The consequence, stated as the engine sees it.
A fresh request queued behind the leak must read as unfinished work.
Without the resync this is ``(1 + 0 - 1) + 0 == 0`` and the engine parks
in ``input_queue.get()`` with a live request in ``waiting``.
"""
aborted = _StubRequest("req-aborted", RequestStatus.RUNNING)
incoming = _StubRequest("req-next-session", RequestStatus.WAITING)
scheduler = _make_scheduler(
scheduler_cls,
requests={incoming.request_id: incoming},
running=[],
waiting=[incoming],
counter=1,
)
monkeypatch.setattr(
scheduler_mod.VLLMScheduler,
"finish_requests",
lambda self, request_ids, finished_status: [aborted],
)
scheduler_cls.finish_requests(scheduler, [aborted.request_id], RequestStatus.FINISHED_ABORTED)
assert _upstream_num_unfinished(scheduler) == 1
@pytest.mark.parametrize(("scheduler_cls", "scheduler_mod"), _SCHEDULER_PARAMS)
def test_finish_requests_keeps_counting_a_genuinely_parked_request(
monkeypatch: pytest.MonkeyPatch,
scheduler_cls,
scheduler_mod,
) -> None:
"""The resync must not over-correct.
A request still parked as ``WAITING_FOR_STREAMING_REQ`` in ``waiting`` is
exactly what the counter is for -- it must stay counted, so the engine
does not spin on a request that has no input to process.
"""
parked = _StubRequest("req-parked", RequestStatus.WAITING_FOR_STREAMING_REQ)
leaving = _StubRequest("req-leaving", RequestStatus.FINISHED_STOPPED)
scheduler = _make_scheduler(
scheduler_cls,
requests={parked.request_id: parked},
running=[],
waiting=[parked],
counter=1,
)
monkeypatch.setattr(
scheduler_mod.VLLMScheduler,
"finish_requests",
lambda self, request_ids, finished_status: [leaving],
)
scheduler_cls.finish_requests(scheduler, [leaving.request_id], RequestStatus.FINISHED_STOPPED)
assert scheduler.num_waiting_for_streaming_input == 1
assert _upstream_num_unfinished(scheduler) == 0
def test_resync_counts_parked_requests_in_skipped_waiting() -> None:
"""``get_num_unfinished_requests`` sums ``waiting`` and ``skipped_waiting``,
so the resync has to look at both or it under-counts and reintroduces the
spin the counter exists to prevent."""
parked = _StubRequest("req-skipped", RequestStatus.WAITING_FOR_STREAMING_REQ)
scheduler = _make_scheduler(
OmniARScheduler,
requests={parked.request_id: parked},
running=[],
waiting=[],
counter=0,
skipped=[parked],
)
scheduler._resync_streaming_input_counter()
assert scheduler.num_waiting_for_streaming_input == 1
def test_resync_is_a_no_op_when_upstream_accounting_is_correct() -> None:
"""In the healthy case the derived value equals upstream's incremental one,
so the helper must leave it alone rather than fight it."""
parked = _StubRequest("req-parked", RequestStatus.WAITING_FOR_STREAMING_REQ)
running = _StubRequest("req-running", RequestStatus.RUNNING)
scheduler = _make_scheduler(
OmniARScheduler,
requests={r.request_id: r for r in (parked, running)},
running=[running],
waiting=[parked],
counter=1,
)
scheduler._resync_streaming_input_counter()
assert scheduler.num_waiting_for_streaming_input == 1
def test_resync_tolerates_a_scheduler_without_the_counter() -> None:
"""Guard for vLLM versions that predate ``num_waiting_for_streaming_input``:
the helper must no-op rather than invent the attribute."""
scheduler = OmniARScheduler.__new__(OmniARScheduler)
scheduler.waiting = []
scheduler.skipped_waiting = []
scheduler._resync_streaming_input_counter()
assert not hasattr(scheduler, "num_waiting_for_streaming_input")
def test_ar_schedule_resyncs_before_delegating_upstream(monkeypatch: pytest.MonkeyPatch) -> None:
"""Pins placement in ``schedule``: the chunk-transfer adapter rewrites
status during ``process_pending_chunks``, so the resync has to run after
that and before upstream schedules, while ``self.waiting`` is still the
real queue."""
parked = _StubRequest("req-resumed", RequestStatus.RUNNING)
scheduler = OmniARScheduler.__new__(OmniARScheduler)
scheduler.chunk_transfer_adapter = None
scheduler.input_coordinator = None
scheduler.requests = {parked.request_id: parked}
scheduler.running = [parked]
scheduler.waiting = []
scheduler.skipped_waiting = []
scheduler.num_waiting_for_streaming_input = 1 # leaked by the status rewrite
scheduler.policy = "fcfs"
seen: dict[str, int] = {}
monkeypatch.setattr(ar_sched_mod.OmniSchedulerMixin, "_consume_pending_connector_output", lambda self, model_mode: None)
monkeypatch.setattr(ar_sched_mod.OmniSchedulerMixin, "_process_pending_input_timeouts", lambda self: None)
monkeypatch.setattr(OmniARScheduler, "_should_defer_waiting_admission", lambda self: False)
def fake_schedule(self, throttle_prefills=False):
seen["counter"] = self.num_waiting_for_streaming_input
raise _StopScheduleError
class _StopScheduleError(Exception):
pass
monkeypatch.setattr(ar_sched_mod.VLLMScheduler, "schedule", fake_schedule)
with pytest.raises(_StopScheduleError):
OmniARScheduler.schedule(scheduler)
assert seen["counter"] == 0
@@ -546,7 +546,7 @@ def test_personaplex_sender_cleanup_drops_delayed_frame_state(build_adapter):
assert second.codes is None
def test_save_async_skips_stale_resumable_chunk_until_dedup_is_reset(build_adapter):
def test_save_async_skips_stale_resumable_chunk_within_segment(build_adapter):
adapter, _ = build_adapter(stage_id=1)
request = _req("req-stream", RequestStatus.WAITING, external_req_id="ext-stream")
request.resumable = True
@@ -565,6 +565,32 @@ def test_save_async_skips_stale_resumable_chunk_until_dedup_is_reset(build_adapt
assert adapter.requests_num_chunks_sent["ext-stream"] == 0
def test_save_async_drops_late_previous_segment_after_boundary_reset(build_adapter):
adapter, _ = build_adapter(stage_id=1)
request = _req("req-stream", RequestStatus.WAITING, external_req_id="ext-stream")
request.resumable = True
request._omni_segment_generation = 0
adapter.requests_num_chunks_sent["ext-stream"] = 111
adapter.save_async(multimodal_output=None, request=request, is_segment_finished=True)
late = _req("req-stream-late", RequestStatus.WAITING, external_req_id="ext-stream")
late.resumable = True
late.num_computed_tokens = 111
late._omni_segment_generation = 0
adapter.save_async(multimodal_output=None, request=late, is_segment_finished=False)
next_segment = _req("req-stream-next", RequestStatus.WAITING, external_req_id="ext-stream")
next_segment.resumable = True
next_segment.num_computed_tokens = 3
next_segment._omni_segment_generation = 1
adapter.save_async(multimodal_output=None, request=next_segment, is_segment_finished=False)
assert len(adapter._pending_save_reqs) == 2
assert adapter.requests_num_chunks_sent["ext-stream"] == 3
assert adapter._pending_save_reqs[-1]["request"] is next_segment
def test_send_single_request_cleans_up_after_finished_payload(build_adapter, monkeypatch):
adapter, _ = build_adapter(stage_id=1)
request = _req("req-finished", RequestStatus.FINISHED_STOPPED, external_req_id="ext-finished")
@@ -0,0 +1,107 @@
"""MiniCPM-o server-VAD hard-interruption acceptance driver."""
from __future__ import annotations
import base64
from pathlib import Path
from vllm_omni.experimental.fullduplex.client import (
PCM16_BYTES_PER_SAMPLE,
PCM16_SAMPLE_RATE,
RealtimeDuplexClient,
RealtimeEventCollector,
build_realtime_url,
read_pcm16_wav,
wait_for,
)
def _events(events: list[dict[str, object]], response_id: str, kind: str) -> list[dict[str, object]]:
return [
event
for event in events
if event.get("type") == kind and RealtimeEventCollector.response_id(event) == response_id
]
async def run_server_vad_interrupt(args) -> dict[str, object]:
initial = read_pcm16_wav(Path(args.input_wav))
interrupt = read_pcm16_wav(Path(args.interrupt_wav))
interrupt = interrupt[: PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE * 4700 // 1000]
session_id = f"server-vad-hard-interrupt-{id(args)}"
client = RealtimeDuplexClient(build_realtime_url(args.url, args.model, autostart=False, session_id=session_id))
try:
await client.__aenter__()
await client.configure(
args.model,
ref_audio="data:audio/wav;base64," + base64.b64encode(Path(args.ref_audio).read_bytes()).decode(),
session_id=session_id,
turn_detection={
"type": "server_vad",
"interrupt_response": True,
},
timeout_s=args.timeout_s,
)
await client.stream_pcm16(initial + bytes(16_000 * 2 * 1600 // 1000), chunk_ms=200, realtime=True)
await client.commit()
await wait_for(
lambda: any(e.get("type") == "response.created" for e in client.events.events),
timeout_s=args.timeout_s,
label="response.created",
)
target = next(e for e in client.events.events if e.get("type") == "response.created")
target_id = RealtimeEventCollector.response_id(target)
assert target_id
await wait_for(
lambda: _events(client.events.events, target_id, "response.audio.delta"),
timeout_s=args.timeout_s,
label="response.audio.delta",
)
cursor = len(client.events.events)
await client.stream_pcm16(interrupt + bytes(16_000 * 2 * 800 // 1000), chunk_ms=args.chunk_ms, realtime=True)
await wait_for(
lambda: _events(client.events.events, target_id, "response.done"),
timeout_s=args.timeout_s,
label="cancelled response.done",
)
await client.commit()
await wait_for(
lambda: any(
e.get("type") == "response.done" and RealtimeEventCollector.response_id(e) != target_id
for e in client.events.events[cursor:]
),
timeout_s=args.timeout_s,
label="follow-up response.done",
)
events = client.events.events
finally:
try:
await client.close_session(timeout_s=args.timeout_s)
finally:
await client.__aexit__(None, None, None)
done = _events(events, target_id, "response.done")
done_index = events.index(done[0]) if len(done) == 1 else len(events)
stale = (
any(
e.get("type") == "response.audio.delta" and RealtimeEventCollector.response_id(e) == target_id
for e in events[done_index + 1 :]
)
if len(done) == 1
else False
)
followup_audio = any(
e.get("type") == "response.audio.delta" and RealtimeEventCollector.response_id(e) != target_id
for e in events[cursor:]
)
ok = (
len(done) == 1
and isinstance(done[0].get("response"), dict)
and done[0]["response"].get("status") == "cancelled"
and done[0]["response"].get("status_details", {}).get("reason") == "turn_detected"
and not stale
and any(e.get("type") == "input_audio_buffer.committed" for e in events[cursor:])
and followup_audio
and not any(e.get("type") == "error" for e in events)
)
return {"ok": ok}
@@ -21,6 +21,7 @@ from tests.e2e.online_serving.helpers.minicpmo_4_5_duplex import (
from tests.e2e.online_serving.run_minicpmo_realtime_duplex_multi_session import (
run_lifecycle_probes,
)
from tests.e2e.online_serving.run_minicpmo_realtime_duplex_server_vad import run_server_vad_interrupt
from tests.e2e.online_serving.run_minicpmo_realtime_duplex_soft_interrupt import (
run_soft_interrupt,
)
@@ -88,3 +89,22 @@ def test_duplex_soft_interrupt(omni_server, model_prefix: str, tmp_path: Path) -
assert result["response_lifecycle_ok"] is True
assert result["response_audio_contract_ok"] is True
assert result["followup_response_transcript_ok"] is True
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
def test_duplex_server_vad_hard_interrupt(omni_server, model_prefix: str) -> None:
result = asyncio.run(
run_server_vad_interrupt(
SimpleNamespace(
url=realtime_url(omni_server),
model=omni_server.model,
input_wav=str(validated_input_wav()),
interrupt_wav=str(validated_soft_interrupt_wav()),
ref_audio=str(resolve_ref_audio(model_prefix)),
chunk_ms=200,
timeout_s=180.0,
)
)
)
assert result["ok"] is True, json.dumps(result, ensure_ascii=False, indent=2)
+2 -1
View File
@@ -30,6 +30,7 @@ from vllm_omni.engine.messages import (
from vllm_omni.engine.orchestrator import (
Orchestrator,
OrchestratorRequestState,
StreamingSegmentState,
_build_terminal_empty_output,
)
from vllm_omni.engine.stage_pool import StagePool
@@ -1951,7 +1952,7 @@ async def test_resumable_segment_boundary_builds_stage_metrics() -> None:
final_stage_id=0,
)
req_state.streaming.enabled = True
req_state.streaming.segment_finished = True
req_state.streaming.segments[0] = StreamingSegmentState(finished=True)
req_state.stage_submit_ts[0] = time.time()
orchestrator.request_states = {"req-stream": req_state}
orchestrator.stage_pools = [pool]
@@ -880,6 +880,39 @@ async def test_diffusion_client_error_output_propagates_status_code(orchestrator
orchestrator_fixture.thread.join(timeout=5)
@pytest.mark.asyncio
async def test_duplex_input_processor_failure_is_request_scoped(orchestrator_factory, monkeypatch) -> None:
class FailingStage(FakeStageClient):
def process_engine_inputs(self, source_outputs, prompt=None, streaming_context=None):
del source_outputs, prompt, streaming_context
raise ValueError("No latent or hidden_states found in thinker output")
fixture = orchestrator_factory(
[FakeStageClient(stage_type="llm"), FailingStage(stage_type="llm", final_output=True)]
)
state = OrchestratorRequestState(
request_id="bad",
prompt=SimpleNamespace(request_id="bad", prompt_token_ids=[1]),
sampling_params_list=[_sampling_params(), _sampling_params()],
final_stage_id=1,
duplex_identity=SimpleNamespace(),
)
async def no_cleanup(*_args: object, **_kwargs: object) -> None:
return None
monkeypatch.setattr(fixture.orchestrator, "_cleanup_request_ids", no_cleanup)
try:
await fixture.orchestrator._forward_to_next_stage_unguarded(
"bad", 0, _build_request_output("raw", finished=True), state
)
error = await _wait_for_error_message(fixture, request_id="bad")
assert error.fatal is False
finally:
fixture.request_sync_q.put_nowait(ShutdownRequestMessage())
fixture.thread.join(timeout=5)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("status_code", "error_type"),
@@ -0,0 +1,130 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Streaming segment-boundary state must be keyed by stage.
``Orchestrator._orchestration_loop`` polls every stage into the same
``OrchestratorRequestState``. While the segment-boundary fields lived in a single
flat slot per request, whichever stage polled most recently overwrote the others,
and the per-stage consumers then read back a different stage's boundary.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import pytest
from vllm.sampling_params import SamplingParams
from vllm_omni.engine.orchestrator import (
Orchestrator,
OrchestratorRequestState,
StreamingSegmentState,
)
from vllm_omni.experimental.fullduplex.engine.contracts import DuplexRequestIdentity
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
THINKER_STAGE = 0
TALKER_STAGE = 1
@dataclass
class _StageMetrics:
pipeline_timings: dict[str, float] = field(default_factory=dict)
@dataclass
class _StageOutput:
"""Stand-in for a processed stage output, with the fields the router reads."""
request_id: str
finished: bool = False
error: object | None = None
class _RecordingPool:
"""Stage pool that records the requests it was asked to build metrics for."""
def __init__(self) -> None:
self.built_for: list[str] = []
def build_stage_metrics(self, outputs: list[_StageOutput], **_kwargs: object) -> _StageMetrics:
self.built_for.extend(output.request_id for output in outputs)
return _StageMetrics()
def _duplex_req_state(request_id: str = "req-duplex") -> OrchestratorRequestState:
req_state = OrchestratorRequestState(
request_id=request_id,
sampling_params_list=[SamplingParams(max_tokens=1), SamplingParams(max_tokens=1)],
final_stage_id=TALKER_STAGE,
)
req_state.streaming.enabled = True
req_state.duplex_identity = DuplexRequestIdentity(
session_id="session-1",
fence=DuplexFence(session_id="session-1"),
)
return req_state
def test_duplex_output_context_reads_only_its_own_stage_segment() -> None:
req_state = _duplex_req_state()
req_state.streaming.segments[THINKER_STAGE] = StreamingSegmentState(
finished=True,
token_ids=[11, 22],
output_metadata={"stage": "thinker"},
)
# The talker polls after the thinker and is still mid-segment.
req_state.streaming.segments[TALKER_STAGE] = StreamingSegmentState(finished=False)
thinker = Orchestrator._duplex_output_context(req_state, stage_id=THINKER_STAGE)
talker = Orchestrator._duplex_output_context(req_state, stage_id=TALKER_STAGE)
assert thinker is not None
assert talker is not None
# These are the assertions that fail on the flat field: the talker is written
# second, so the thinker loses its boundary to whichever stage polled last.
assert thinker.segment_finished is True
assert thinker.segment_token_ids == (11, 22)
assert thinker.segment_output_metadata == {"stage": "thinker"}
assert talker.segment_finished is False
assert talker.segment_token_ids == ()
assert talker.segment_output_metadata == {}
@pytest.mark.asyncio
async def test_segment_boundary_on_one_stage_does_not_build_metrics_on_another() -> None:
req_state = _duplex_req_state()
req_state.streaming.segments[THINKER_STAGE] = StreamingSegmentState(finished=True)
talker_pool = _RecordingPool()
orchestrator = object.__new__(Orchestrator)
orchestrator.request_states = {req_state.request_id: req_state}
orchestrator.stage_pools = [_RecordingPool(), talker_pool]
routed: list[_StageMetrics | None] = []
async def record_route(
_stage: int,
_replica: int,
_output: _StageOutput,
_state: OrchestratorRequestState,
metrics: _StageMetrics | None,
) -> None:
routed.append(metrics)
orchestrator._route_output = record_route
# The talker's own output is neither finished nor at a segment boundary, so
# it must not be treated as one just because the thinker reached one.
await orchestrator._handle_processed_outputs(
TALKER_STAGE,
0,
[_StageOutput(request_id=req_state.request_id)],
)
assert talker_pool.built_for == []
assert routed == [None]
@@ -17,6 +17,7 @@ from vllm.sampling_params import SamplingParams
from vllm_omni.engine.orchestrator import (
Orchestrator,
OrchestratorRequestState,
StreamingSegmentState,
_OrchestratorDuplexStagePort,
)
from vllm_omni.engine.stage_pool import StagePool
@@ -358,7 +359,7 @@ async def test_streaming_segment_does_not_complete_final_output_stage() -> None:
final_output_stage_ids={0},
)
req_state.streaming.enabled = True
req_state.streaming.segment_finished = True
req_state.streaming.segments[0] = StreamingSegmentState(finished=True)
output = SimpleNamespace(
request_id=req_state.request_id,
finished=True,
@@ -452,7 +452,7 @@ def test_minicpmo_native_capabilities_separate_model_state_from_core_kv_lease():
assert caps["supports_core_resumable_request"] is True
assert caps["supports_stage_connector_handoff"] is True
assert caps["supports_audio_truncate"] is True
assert caps["supports_barge_in"] is False
assert caps["supports_barge_in"] is True
assert caps["target_barge_in_latency_ms"] is None
assert caps["supports_multi_session"] is True
assert caps["supports_multi_session_same_replica"] is True
@@ -652,7 +652,7 @@ async def test_native_realtime_protocol_preserves_input_turn_policy_hints():
@pytest.mark.asyncio
async def test_native_realtime_protocol_rejects_unimplemented_server_vad():
async def test_native_realtime_protocol_rejects_server_vad_without_interrupt():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol.bind_sender(ws.send_json)
@@ -675,7 +675,10 @@ async def test_native_realtime_protocol_rejects_unimplemented_server_vad():
error = ws.sent[-1]
assert error["type"] == "error"
assert error["error"]["code"] == "unsupported_turn_detection"
assert "server_vad" in error["error"]["message"]
assert "interrupt_response=false" in error["error"]["message"]
assert "greater than 0.15" in str(
protocol._validate_realtime_turn_detection({"turn_detection": {"type": "server_vad", "threshold": 0.15}})
)
@pytest.mark.asyncio
@@ -699,7 +702,7 @@ async def test_native_realtime_protocol_accepts_disabled_turn_detection():
@pytest.mark.asyncio
async def test_native_realtime_protocol_rejects_nested_vad_even_when_top_level_is_disabled():
async def test_native_realtime_protocol_prefers_nested_vad_over_top_level():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol.bind_sender(ws.send_json)
@@ -719,8 +722,18 @@ async def test_native_realtime_protocol_rejects_nested_vad_even_when_top_level_i
}
)
assert translated is None
assert ws.sent[-1]["error"]["code"] == "unsupported_turn_detection"
assert translated["session"]["extra_body"]["realtime_session_payload"]["overlap_policy"] == "barge_in_on_speech"
@pytest.mark.asyncio
async def test_realtime_vad_candidate_waits_for_session_ack():
protocol = NativeRealtimeSessionProtocol(TimedWebSocket()) # type: ignore[arg-type]
event = {"type": "session.update", "model": "test-model", "turn_detection": {"type": "server_vad"}}
for outbound, enabled in (({"type": "error"}, False), ({"type": "session.updated", "session": {}}, True)):
await protocol._to_duplex_event(event)
protocol.encode_outbound_event(outbound)
assert (protocol._server_vad is not None) is enabled
def test_native_duplex_handler_has_no_fixed_session_admission_cap():
@@ -5674,7 +5687,7 @@ async def test_minicpmo_native_duplex_rejects_ref_audio_path():
assert engine.opened == []
def test_minicpmo_native_duplex_explicit_barge_in_request_is_deferred_as_listen():
def test_minicpmo_native_duplex_explicit_barge_in_request_interrupts():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
@@ -5701,12 +5714,10 @@ def test_minicpmo_native_duplex_explicit_barge_in_request_is_deferred_as_listen(
)
assert decision == {
"action": "listen",
"reason": "barge_in_unsupported",
"action": "barge_in",
"reason": "client_force_barge_in",
"duration_ms": 1000,
"overlap_speech_ms": 1000,
"buffer_audio": True,
"defer_runtime_append": True,
}
@@ -5718,7 +5729,7 @@ def test_minicpmo_native_duplex_explicit_barge_in_request_is_deferred_as_listen(
{"type": "turn.signal", "event": "barge_in"},
],
)
async def test_minicpmo_native_duplex_rejects_unadvertised_barge_in_control(
async def test_minicpmo_native_duplex_accepts_explicit_barge_in_control(
barge_in_event: dict[str, object],
):
engine = FakeEngineClient()
@@ -5734,9 +5745,8 @@ async def test_minicpmo_native_duplex_rejects_unadvertised_barge_in_control(
await handler.handle_session(ws)
error = next(message for message in ws.sent if message.get("code") == "barge_in_unsupported")
assert error["session_id"] == "sid-native-barge-control-disabled"
assert ("sid-native-barge-control-disabled", "barge_in") not in engine.signals
assert not any(message.get("code") == "barge_in_unsupported" for message in ws.sent)
assert ("sid-native-barge-control-disabled", "barge_in") in engine.signals
@pytest.mark.asyncio
+39 -4
View File
@@ -218,16 +218,21 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
if stop_after_transfer and req_id in self.requests_needing_kv_transfer:
self.pending_stop_after_extraction.add(req_id)
def _drop_aborted_queued_requests(self) -> None:
for queue in (self.waiting, self.skipped_waiting):
aborted = [req for req in queue if req.status == RequestStatus.FINISHED_ABORTED]
if aborted:
queue.remove_requests(aborted)
self.running[:] = [req for req in self.running if req.status != RequestStatus.FINISHED_ABORTED]
def schedule(self, throttle_prefills: bool = False) -> SchedulerOutput:
# Remove FINISHED_ABORTED requests before the upstream scheduler sees
# them. Upstream vllm raises RuntimeError on this status; omni allows
# async abort (e.g. client disconnect during TTS streaming) to leave
# requests in the waiting/running queues temporarily.
for queue in (self.waiting, self.running):
for req in list(queue):
if getattr(req, "status", None) == RequestStatus.FINISHED_ABORTED:
queue.remove(req)
self._drop_aborted_queued_requests()
self._process_pending_omni_inputs(model_mode="ar")
self._resync_streaming_input_counter()
original_waiting = None
if self._should_defer_waiting_admission():
@@ -354,6 +359,11 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
req_index = model_runner_output.req_id_to_index[req_id]
generated_token_ids = sampled_token_ids[req_index] if sampled_token_ids else []
if generated_token_ids and getattr(request, "async_tokens_to_discard", 0) > 0:
self._update_request_with_output(request, generated_token_ids)
continue
status_before_stop = request.status
new_logprobs = None
logprob_validation_failed = False
@@ -651,6 +661,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
if self.chunk_transfer_adapter and self.chunk_transfer_adapter.receives_chunks:
self.chunk_transfer_adapter.requests_num_chunks_sent.pop(session.external_req_id, None)
if stage_id != 0:
session._omni_segment_generation = int(getattr(session, "_omni_segment_generation", 0) or 0) + 1
# Downstream async-chunk stages receive real payloads from the
# connector. This update only resumes polling for the next segment.
self.chunk_transfer_adapter.segment_finished_requests.discard(session.request_id)
@@ -682,6 +693,30 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
self._release_replaced_streaming_prompt_cache(session)
self._replace_streaming_session(session, update)
return
native_duplex_update = any(
isinstance(info, dict) and info.get("native_duplex") is True for info in update_infos
)
if native_duplex_update:
max_model_len = int(getattr(self.vllm_config.model_config, "max_model_len", 0) or 0)
current_prompt_len = len(getattr(session, "prompt_token_ids", None) or ())
computed_tokens = int(getattr(session, "num_computed_tokens", 0) or 0)
kept_output_len = max(0, computed_tokens - current_prompt_len)
next_prompt_len = len(update.prompt_token_ids or ())
candidate_len = current_prompt_len + kept_output_len + next_prompt_len
# Keep one context position available for the next sampled codec token.
if max_model_len > 0 and candidate_len >= max_model_len:
logger.info(
"Resetting native duplex Stage-1 prompt before context overflow: "
"req=%s current=%s kept_output=%s next=%s max_model_len=%s",
session.request_id,
current_prompt_len,
kept_output_len,
next_prompt_len,
max_model_len,
)
self._replace_streaming_session(session, update)
return
session._omni_segment_generation = int(getattr(session, "_omni_segment_generation", 0) or 0) + 1
super()._update_request_as_session(session, update)
if hasattr(update, "model_intermediate_buffer"):
session.model_intermediate_buffer = update.model_intermediate_buffer
@@ -146,6 +146,7 @@ class OmniSchedulerMixin:
def _replace_streaming_session(self, session: Request, update: StreamingUpdate) -> None:
"""Replace a downstream stage's placeholder with its next payload."""
session._omni_segment_generation = int(getattr(session, "_omni_segment_generation", 0) or 0) + 1
adapter = getattr(self, "chunk_transfer_adapter", None)
if adapter is not None:
adapter.segment_finished_requests.discard(session.request_id)
@@ -645,6 +646,7 @@ class OmniSchedulerMixin:
)
finished = super().finish_requests(request_ids, finished_status)
self._purge_finished_from_running(target_request_ids)
self._resync_streaming_input_counter()
for request in finished:
self._free_input_coordinator_request(request.request_id)
@@ -810,3 +812,18 @@ class OmniSchedulerMixin:
return resumable_segment_stop and req.request_id not in target_request_ids
self.running[:] = [req for req in self.running if keep_running(req)]
def _resync_streaming_input_counter(self) -> None:
counter = getattr(self, "num_waiting_for_streaming_input", None)
if counter is None:
return
parked = sum(
1
for queue in (getattr(self, "waiting", None), getattr(self, "skipped_waiting", None))
if queue
for request in queue
if request.status == RequestStatus.WAITING_FOR_STREAMING_REQ
)
if parked != counter:
logger.debug("[Omni] resynced streaming-input counter %s -> %s", counter, parked)
self.num_waiting_for_streaming_input = parked
@@ -95,6 +95,9 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
# been read").
self._held_non_active: deque[Any] = deque()
self.requests_num_chunks_sent: dict[str, int] = defaultdict(int)
# Boundary tasks advance this before the background sender runs, so a
# late old-segment frame cannot restore the previous watermark.
self._segment_generation: dict[str, int] = defaultdict(int)
self._pending_streaming_prefills: dict[str, dict] = {}
# Monotonic timestamp of when each request last began waiting for a
# chunk, refreshed every time one arrives. Read by
@@ -188,11 +191,31 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
is_segment_finished: whether the segment of request is finished
"""
is_finished = request.is_finished() and not request.resumable
if not hasattr(self, "_segment_generation"):
self._segment_generation = defaultdict(int)
external_req_id = request.external_req_id
raw_generation = getattr(request, "_omni_segment_generation", 0)
try:
generation = int(raw_generation)
except (TypeError, ValueError):
generation = 0
expected_generation = self._segment_generation.get(external_req_id, generation)
if generation < expected_generation:
logger.warning(
"Skip late save_async for request %s, segment_generation=%s, expected=%s",
external_req_id,
generation,
expected_generation,
)
return
self._segment_generation[external_req_id] = generation
confirmed_num_computed_tokens = self._confirmed_num_computed_tokens(request)
# If the request is preempted, skip the already saved chunks.
if confirmed_num_computed_tokens < self.requests_num_chunks_sent.get(request.external_req_id, 0):
if not is_segment_finished and confirmed_num_computed_tokens < self.requests_num_chunks_sent.get(
request.external_req_id, 0
):
logger.warning(
f"Enqueue save_async for request {request.external_req_id}, "
f"request.num_computed_tokens={request.num_computed_tokens}, "
@@ -201,13 +224,18 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
)
return
self.requests_num_chunks_sent[request.external_req_id] = confirmed_num_computed_tokens
task = {
"multimodal_output": multimodal_output,
"request": request,
"is_finished": is_finished,
"is_segment_finished": is_segment_finished,
"segment_generation": generation,
}
if is_segment_finished:
self._segment_generation[external_req_id] = generation + 1
self.requests_num_chunks_sent.pop(external_req_id, None)
else:
self.requests_num_chunks_sent[external_req_id] = confirmed_num_computed_tokens
self._pending_save_reqs.append(task)
with self._save_cond:
self._save_cond.notify()
@@ -425,7 +453,6 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
if is_segment_finished:
self.code_prompt_token_ids.pop(external_req_id, None)
self.requests_num_chunks_sent.pop(external_req_id, None)
self.ramp_chunk_count.pop(external_req_id, None)
cached_ic = getattr(self, "_cached_ic", None)
if cached_ic is not None:
@@ -501,6 +528,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
self.request_payload.pop(external_req_id, None)
self.code_prompt_token_ids.pop(external_req_id, None)
self.requests_num_chunks_sent.pop(external_req_id, None)
self._segment_generation.pop(external_req_id, None)
self.ramp_chunk_count.pop(external_req_id, None)
self._pending_streaming_prefills.pop(external_req_id, None)
+83 -32
View File
@@ -199,17 +199,37 @@ class OrchestratorRequestState:
@dataclass
class StreamingInputState:
# Flag of streaming input request
enabled: bool = False
class StreamingSegmentState:
"""Streaming segment-boundary state for one stage of a request.
Every stage of a multi-stage pipeline reaches its own segment boundaries
independently, so this is tracked per stage rather than per request.
"""
# Flag of segment of streaming input finished
segment_finished: bool = False
finished: bool = False
# Tokens from the current raw segment boundary. The vLLM output processor
# does not guarantee that EngineCoreOutput.new_token_ids survives on the
# processed RequestOutput used by the routing layer.
segment_token_ids: list[int] = field(default_factory=list)
segment_output_metadata: dict[str, Any] = field(default_factory=dict)
# Streaming update prompt length
token_ids: list[int] = field(default_factory=list)
output_metadata: dict[str, Any] = field(default_factory=dict)
@dataclass
class StreamingInputState:
# Flag of streaming input request
enabled: bool = False
# Keyed by stage. ``_orchestration_loop`` records a boundary per stage poll
# into this one ``req_state``, while every consumer reads it for a specific
# ``stage_id``, so a flat slot is only correct as long as each read happens in
# the same iteration as its own stage's write. It is not for a stage that
# never records one (the ``poll_diffusion_output`` branch reaches
# ``_handle_processed_outputs`` without the raw-output loop) or for any
# consumer reading outside the writing iteration.
segments: dict[int, StreamingSegmentState] = field(default_factory=dict)
# Streaming update prompt length. NOT stage-keyed: its only consumer reads it
# through the streaming context on the stage-input-bridge path, not by stage
# id. Do not assume the rest of this struct is stage-safe.
new_prompt_len_snapshot: int | None = None
# Model/bridge-specific runtime states (e.g., thinker->talker)
bridge_states: dict[str, Any] = field(default_factory=dict)
@@ -217,6 +237,14 @@ class StreamingInputState:
# while the downstream input processor consumes upstream token output.
source_token_decoder: Callable[..., str] | None = None
def segment(self, stage_id: int | None) -> StreamingSegmentState:
"""Return ``stage_id``'s segment state, or a fresh empty one if unreported.
Fresh rather than shared: ``output_metadata`` is handed out by reference.
"""
segment = self.segments.get(stage_id)
return segment if segment is not None else StreamingSegmentState()
class _OrchestratorDuplexStagePort:
"""Adapts generic stage pools to the model-neutral duplex control plane."""
@@ -966,17 +994,18 @@ class Orchestrator:
req_state = self.request_states.get(getattr(eco, "request_id", None))
if req_state is None or not req_state.streaming.enabled:
continue
req_state.streaming.segment_finished = bool(getattr(eco, "is_segment_finished", False))
req_state.streaming.segment_token_ids = (
self._coerce_int_list(getattr(eco, "new_token_ids", None))
if req_state.streaming.segment_finished
else []
)
segment_finished = bool(getattr(eco, "is_segment_finished", False))
raw_mm = self._completion_multimodal_output(eco, None)
req_state.streaming.segment_output_metadata = (
dict(raw_mm)
if req_state.streaming.segment_finished and isinstance(raw_mm, dict)
else {}
req_state.streaming.segments[stage_id] = StreamingSegmentState(
finished=segment_finished,
token_ids=(
self._coerce_int_list(getattr(eco, "new_token_ids", None))
if segment_finished
else []
),
output_metadata=(
dict(raw_mm) if segment_finished and isinstance(raw_mm, dict) else {}
),
)
req_state.streaming.new_prompt_len_snapshot = getattr(
eco,
@@ -1044,7 +1073,7 @@ class Orchestrator:
continue
stage_metrics = None
segment_finished = req_state.streaming.enabled and req_state.streaming.segment_finished
segment_finished = req_state.streaming.enabled and req_state.streaming.segment(stage_id).finished
if output.finished or segment_finished:
stage_metrics = pool.build_stage_metrics(
[output],
@@ -1378,6 +1407,7 @@ class Orchestrator:
req_id = output.request_id
finished = output.finished
submit_ts = req_state.stage_submit_ts.get(stage_id)
segment_finished = req_state.streaming.enabled and req_state.streaming.segment(stage_id).finished
# CFG companion: stash output so parent can bundle [parent, *companions]
# into source_outputs for the bridge (e.g. thinker2imagegen).
if finished and self._cfg_tracker.is_companion(req_id):
@@ -1387,11 +1417,7 @@ class Orchestrator:
return
request_finished = False
if (
finished
and self.stage_pools[stage_id].final_output
and not (req_state.streaming.enabled and req_state.streaming.segment_finished)
):
if finished and self.stage_pools[stage_id].final_output and not segment_finished:
req_state.finished_final_output_stage_ids.add(stage_id)
final_output_stage_ids = req_state.final_output_stage_ids or {req_state.final_stage_id}
request_finished = final_output_stage_ids.issubset(req_state.finished_final_output_stage_ids)
@@ -1403,7 +1429,7 @@ class Orchestrator:
# filter out again (the official implementation returns exactly one
# result per audio chunk).
is_duplex_stage0_segment = (
stage_id == 0 and self._is_duplex_session_request(req_state) and req_state.streaming.segment_finished
stage_id == 0 and self._is_duplex_session_request(req_state) and req_state.streaming.segment(0).finished
)
if self.stage_pools[stage_id].final_output and not is_duplex_stage0_segment:
await self.output_async_queue.put(
@@ -1415,7 +1441,10 @@ class Orchestrator:
metrics=stage_metrics,
finished=(
request_finished
or (self._is_duplex_session_request(req_state) and req_state.streaming.segment_finished)
or (
self._is_duplex_session_request(req_state)
and req_state.streaming.segment(stage_id).finished
)
),
stage_submit_ts=submit_ts,
)
@@ -1450,7 +1479,7 @@ class Orchestrator:
return
if (
(finished or (req_state.streaming.enabled and req_state.streaming.segment_finished))
(finished or segment_finished)
and stage_id < req_state.final_stage_id
and (not self.async_chunk or not self._stage_receives_async_chunks(stage_id + 1))
and (not self._next_stage_already_submitted(stage_id, req_state) or req_state.streaming.enabled)
@@ -1589,7 +1618,9 @@ class Orchestrator:
def _duplex_output_context(
req_state: OrchestratorRequestState,
*,
stage_id: int | None = None,
# Required: segment state is stage-keyed, so a caller that omitted this
# would silently read "no boundary" rather than the intended stage's.
stage_id: int | None,
) -> DuplexOutputContext | None:
identity = req_state.duplex_identity
if identity is None:
@@ -1600,15 +1631,16 @@ class Orchestrator:
)
fence = req_state.duplex_stage_fences.get(stage_id, identity.fence) if stage_id is not None else identity.fence
segment = req_state.streaming.segment(stage_id)
return DuplexOutputContext(
identity=DuplexRequestIdentity(
session_id=identity.session_id,
fence=fence,
),
final_stage_id=req_state.final_stage_id,
segment_finished=req_state.streaming.enabled and req_state.streaming.segment_finished,
segment_token_ids=tuple(req_state.streaming.segment_token_ids),
segment_output_metadata=req_state.streaming.segment_output_metadata,
segment_finished=req_state.streaming.enabled and segment.finished,
segment_token_ids=tuple(segment.token_ids),
segment_output_metadata=segment.output_metadata,
)
@staticmethod
@@ -2144,13 +2176,32 @@ class Orchestrator:
req_state.prompt,
streaming_context=req_state.streaming,
)
except Exception:
except Exception as exc:
logger.exception(
"[Orchestrator] req=%s process_engine_inputs FAILED for stage-%s",
req_id,
next_logical,
)
raise
if not self._is_duplex_session_request(req_state):
# Preserve the established failure semantics for ordinary
# pipelines. This request-scoped recovery exists specifically
# for resumable full-duplex handoffs, where one malformed unit
# must not terminate the shared session orchestrator.
raise
await self.output_async_queue.put(
ErrorMessage(
request_id=req_id,
stage_id=next_logical,
error=(f"Stage-{next_logical} input processor failed: {type(exc).__name__}: {exc}"),
error_type=type(exc).__name__,
)
)
await self._cleanup_request_ids(
[req_id, *self._cfg_tracker.cleanup_parent(req_id)],
abort=True,
close_duplex_sessions=True,
)
return
finally:
req_state.streaming.source_token_decoder = previous_decoder
+7 -8
View File
@@ -58,13 +58,13 @@ The checkpoint keeps these verified contracts:
- scheduler data-plane append over a resumable request;
- stale epoch/turn/response fencing;
- `/v1/duplex` and OpenAI Realtime projection;
- optional OpenAI `server_vad` turn detection backed by per-session Silero VAD;
- existing JoyVL behavior.
The checkpoint does not claim:
- scheduler-native KV append;
- deterministic VAD-triggered interruption (the browser intentionally does not
run VAD; MiniCPM owns listen/speak decisions at model-unit boundaries);
- an acoustic-onset-to-cancel latency target across arbitrary client chunk sizes;
- production multi-session admission, fairness, capacity, or failure recovery;
- bounded long-session KV;
- video input or audio/video synchronization.
@@ -640,9 +640,8 @@ multi-turn runtime evidence.
During auto-response overlap, `preserve_realtime_input` distinguishes "do not
append this silent chunk to the native buffer" from "clear the open Realtime
item". Silent overlap no longer discards earlier user PCM. This is an input
ownership correction. It does not add a VAD policy: overlap is admitted to
Stage0 and the model decides whether to listen or speak.
item". Silent overlap no longer discards earlier user PCM. A separate explicit
`server_vad` policy is documented in the Realtime contract above.
The first chunk of one overlapping input item also reserves its target model
turn. A later Realtime commit uses that reserved identity even if response EOS
@@ -1024,6 +1023,6 @@ Passing this checkpoint supports the statement:
> Single-session, model-owned MiniCPM-o 4.5 native duplex is reviewable on the
> validated H20 configuration.
It does not support claims for deterministic VAD-triggered interruption,
multi-session production concurrency, bounded long-session KV,
scheduler-native append, or video input.
It does not support claims for an acoustic-onset barge-in latency target across
arbitrary client chunk sizes, multi-session production concurrency, bounded
long-session KV, scheduler-native append, or video input.
+7 -2
View File
@@ -417,6 +417,7 @@ class RealtimeDuplexClient:
auto_response: bool = True,
temperature: float | None = None,
extra_body: dict[str, object] | None = None,
turn_detection: dict[str, object] | None = None,
session_id: str | None = None,
timeout_s: float = 20.0,
) -> None:
@@ -436,8 +437,12 @@ class RealtimeDuplexClient:
"modalities": ["audio", "text"],
"input_audio_format": "pcm16",
"output_audio_format": output_audio_format,
"turn_detection": None,
"overlap_policy": "listen_only",
"turn_detection": dict(turn_detection) if turn_detection is not None else None,
"overlap_policy": (
"barge_in_on_speech"
if turn_detection is not None and turn_detection.get("interrupt_response", True) is True
else "listen_only"
),
"playback_commit_policy": "ack_only",
"extra_body": session_extra_body,
}
@@ -9,7 +9,6 @@ from uuid import uuid4
class DuplexOverlapPolicy(str, Enum):
AUTO = "auto"
LISTEN_ONLY = "listen_only"
BARGE_IN_ON_SPEECH = "barge_in_on_speech"
@@ -101,7 +100,7 @@ class DuplexCapabilities:
supports_multi_session = max_sessions > 1
return cls(
supports_model_native_turn_policy=True,
supports_barge_in=False,
supports_barge_in=True,
supports_input_append=True,
supports_replace_latest_chunk=False,
supports_reencode_context=False,
@@ -129,6 +128,9 @@ class DuplexCapabilities:
signal_sources=["model_native", "client_event", "server_policy"],
stage_handoff_transport="scheduler_data_plane",
chunk_period_ms=1000,
# Barge-in is now supported, but the wire chunk duration is
# client-controlled and no hardware E2E latency has been recorded.
# Do not advertise an invented target until that measurement exists.
target_barge_in_latency_ms=None,
)
@@ -15,6 +15,13 @@ from vllm_omni.experimental.fullduplex.openai.realtime_state import (
REALTIME_INPUT_AUDIO_FORMATS,
REALTIME_OUTPUT_AUDIO_FORMATS,
)
from vllm_omni.experimental.fullduplex.openai.vad import (
SILERO_VAD_MIN_THRESHOLD,
ServerVADUnavailableError,
SileroStreamingVAD,
SileroVADConfig,
StreamingVADResult,
)
class RealtimeInputTranslator:
@@ -89,6 +96,7 @@ class RealtimeInputTranslator:
)
)
return None
self._prepare_realtime_turn_detection_update(session_payload)
self._apply_realtime_session_defaults(session_payload)
session_payload.update(self._realtime_overlap_fields(session_payload))
if not self._opened:
@@ -275,16 +283,62 @@ class RealtimeInputTranslator:
)
)
return None
looks_like_speech = self._input_looks_like_speech(event, audio=audio, fmt=fmt)
vad_result: StreamingVADResult | None = None
if isinstance(self._server_vad, SileroStreamingVAD):
try:
vad_result = self._server_vad.process_base64(
audio,
fmt=fmt,
sample_rate_hz=sample_rate_hz,
)
except ServerVADUnavailableError as exc:
await self._send_realtime_payload(
self._realtime_error_payload(
"server_vad_unavailable",
str(exc),
event_id=event.get("event_id"),
param="turn_detection",
)
)
return None
except ValueError as exc:
await self._send_realtime_payload(
self._realtime_error_payload(
"bad_audio",
str(exc),
event_id=event.get("event_id"),
param="audio",
)
)
return None
looks_like_speech = (
vad_result.is_speech
if vad_result is not None
else self._input_looks_like_speech(event, audio=audio, fmt=fmt)
)
self._input_audio_buffer_has_audio = self._input_audio_buffer_has_audio or (
looks_like_speech and isinstance(audio, str) and bool(audio)
)
self._input_audio_buffer_had_non_speech = self._input_audio_buffer_had_non_speech or (
not looks_like_speech and isinstance(audio, str) and bool(audio)
)
if looks_like_speech:
if vad_result is not None and vad_result.speech_started:
started_event = dict(event)
if vad_result.speech_start_ms is not None:
started_event["audio_start_ms"] = vad_result.speech_start_ms
await self._emit_input_speech_started(started_event)
elif vad_result is None and looks_like_speech:
await self._emit_input_speech_started(event)
if looks_like_speech:
self._remember_input_transcript_hint(event)
if vad_result is not None and vad_result.speech_stopped:
stopped_event = dict(event)
if vad_result.speech_end_ms is not None:
stopped_event["audio_end_ms"] = vad_result.speech_end_ms
await self._emit_input_speech_stopped(
stopped_event,
item_id=self._active_input_item_id or f"item_{uuid4().hex}",
)
payload = {
"type": "input_audio_buffer.append",
"audio": audio,
@@ -306,14 +360,20 @@ class RealtimeInputTranslator:
return None
payload["video_frames"] = [frame for frame in video_frames if isinstance(frame, str) and frame]
self._copy_realtime_input_hints(event, payload)
if not looks_like_speech:
payload["is_speech"] = False
payload["is_speech"] = looks_like_speech
if vad_result is not None:
payload["vad"] = vad_result.as_hint()
if self._server_vad_interrupts_response() and (vad_result.speech_active or vad_result.speech_started):
# Hold the MiniCPM stream in listen mode for the remainder
# of this utterance after the one-shot onset cancellation.
payload["force_listen"] = True
return payload
if event_type == "input_audio_buffer.commit":
if not self._input_audio_buffer_has_audio:
if self._input_audio_buffer_had_non_speech:
self._input_audio_buffer_had_non_speech = False
self._active_input_item_id = None
self._reset_server_vad()
return {
"type": "input_audio_buffer.commit",
"final": event.get("final", True),
@@ -335,6 +395,7 @@ class RealtimeInputTranslator:
self._active_input_item_id = None
self._input_audio_buffer_has_audio = False
self._input_audio_buffer_had_non_speech = False
self._reset_server_vad()
payload = {
"type": "input_audio_buffer.commit",
"final": event.get("final", True),
@@ -350,6 +411,7 @@ class RealtimeInputTranslator:
self._input_audio_buffer_has_audio = False
self._input_audio_buffer_had_non_speech = False
self._input_audio_buffer_transcript_parts.clear()
self._reset_server_vad()
return {"type": "input_audio_buffer.clear", "reason": event_type}
if event_type == "output_audio_buffer.clear":
payload = {"type": "output_audio_buffer.clear", "reason": event_type}
@@ -991,8 +1053,8 @@ class RealtimeInputTranslator:
return "video_frames entries must be JPEG or PNG images"
return None
@staticmethod
def _validate_realtime_turn_detection(session_payload: dict[str, object]) -> str | None:
@classmethod
def _validate_realtime_turn_detection(cls, session_payload: dict[str, object]) -> str | None:
configured_values: list[tuple[str, object]] = []
if "turn_detection" in session_payload:
configured_values.append(("turn_detection", session_payload["turn_detection"]))
@@ -1004,13 +1066,152 @@ class RealtimeInputTranslator:
for field_path, turn_detection in configured_values:
if turn_detection is None:
continue
turn_detection_type = turn_detection.get("type") if isinstance(turn_detection, dict) else turn_detection
return (
f"{field_path}={turn_detection_type!r} is not implemented by the duplex Realtime adapter; "
"set turn_detection to null and commit input explicitly, or use the model-owned duplex policy"
)
if not isinstance(turn_detection, dict):
return f"{field_path} must be null or an object with type='server_vad'"
turn_detection_type = turn_detection.get("type")
if turn_detection_type != "server_vad":
return (
f"{field_path}={turn_detection_type!r} is unsupported; "
"use null for model-owned listen/speak or type='server_vad' for hard barge-in"
)
threshold = turn_detection.get("threshold", 0.5)
if (
not isinstance(threshold, int | float)
or isinstance(threshold, bool)
or not np.isfinite(float(threshold))
or not SILERO_VAD_MIN_THRESHOLD < float(threshold) <= 1.0
):
return f"{field_path}.threshold must be greater than {SILERO_VAD_MIN_THRESHOLD} and at most 1"
for name in (
"prefix_padding_ms",
"silence_duration_ms",
"min_speech_duration_ms",
):
value = turn_detection.get(name)
if value is not None and (
not isinstance(value, int | float)
or isinstance(value, bool)
or not np.isfinite(float(value))
or float(value) < 0
):
return f"{field_path}.{name} must be a non-negative number"
interrupt_response = turn_detection.get("interrupt_response", True)
if not isinstance(interrupt_response, bool):
return f"{field_path}.interrupt_response must be a boolean"
if interrupt_response is not True:
return (
f"{field_path}.interrupt_response=false is unsupported; "
"use turn_detection=null for model-owned listen/speak"
)
present, turn_detection = cls._configured_realtime_turn_detection(session_payload)
if not present:
if session_payload.get("overlap_policy") == "barge_in_on_speech":
return (
"overlap_policy='barge_in_on_speech' requires "
"turn_detection.type='server_vad' on the Realtime endpoint"
)
return None
desired_policy = (
"barge_in_on_speech"
if isinstance(turn_detection, dict) and turn_detection.get("interrupt_response", True) is True
else "listen_only"
)
overlap_policy = session_payload.get("overlap_policy")
if isinstance(overlap_policy, str) and overlap_policy != desired_policy:
return f"overlap_policy={overlap_policy!r} conflicts with turn_detection; expected {desired_policy!r}"
return None
@staticmethod
def _configured_realtime_turn_detection(
session_payload: dict[str, object],
) -> tuple[bool, dict[str, object] | None]:
present = "turn_detection" in session_payload
selected = session_payload.get("turn_detection")
audio_config = session_payload.get("audio")
if isinstance(audio_config, dict):
audio_input = audio_config.get("input")
if isinstance(audio_input, dict) and "turn_detection" in audio_input:
present = True
selected = audio_input.get("turn_detection")
return present, dict(selected) if isinstance(selected, dict) else None
def _prepare_realtime_turn_detection_update(self, session_payload: dict[str, object]) -> None:
"""Stage a VAD replacement until the Session/runtime update commits."""
present, turn_detection = self._configured_realtime_turn_detection(session_payload)
if not present:
return
if self._turn_detection_update_pending:
raise RuntimeError("A Realtime turn-detection update is already pending")
pending_turn_detection: dict[str, object] | None = None
pending_server_vad: SileroStreamingVAD | None = None
if turn_detection is None:
session_payload["overlap_policy"] = "listen_only"
else:
normalized = dict(turn_detection)
normalized.setdefault("type", "server_vad")
normalized.setdefault("interrupt_response", True)
normalized.setdefault("threshold", 0.5)
normalized.setdefault("prefix_padding_ms", 300)
normalized.setdefault("silence_duration_ms", 500)
normalized.setdefault("min_speech_duration_ms", 96)
pending_turn_detection = normalized
pending_server_vad = SileroStreamingVAD(SileroVADConfig.from_turn_detection(normalized))
session_payload["turn_detection"] = dict(normalized)
session_payload["overlap_policy"] = "barge_in_on_speech"
self._pending_turn_detection = pending_turn_detection
self._pending_server_vad = pending_server_vad
self._turn_detection_update_pending = True
self._turn_detection_update_resolved = asyncio.Event()
async def wait_for_realtime_turn_detection_update(self) -> None:
"""Preserve wire order while the runner accepts or rejects a candidate."""
resolved = self._turn_detection_update_resolved
if self._turn_detection_update_pending and resolved is not None:
await resolved.wait()
def commit_realtime_turn_detection_update(self) -> None:
"""Publish the staged VAD only after the Session/runtime transaction ACKs."""
if not self._turn_detection_update_pending:
return
previous_server_vad = self._server_vad
self._turn_detection = (
dict(self._pending_turn_detection) if isinstance(self._pending_turn_detection, dict) else None
)
self._server_vad = self._pending_server_vad
self._clear_pending_realtime_turn_detection_update()
reset = getattr(previous_server_vad, "reset", None)
if callable(reset):
reset()
def reject_realtime_turn_detection_update(self) -> None:
"""Discard a staged VAD after any rejected Session/runtime update."""
if not self._turn_detection_update_pending:
return
reset = getattr(self._pending_server_vad, "reset", None)
if callable(reset):
reset()
self._clear_pending_realtime_turn_detection_update()
def _clear_pending_realtime_turn_detection_update(self) -> None:
resolved = self._turn_detection_update_resolved
self._turn_detection_update_pending = False
self._pending_turn_detection = None
self._pending_server_vad = None
self._turn_detection_update_resolved = None
if resolved is not None:
resolved.set()
def _server_vad_interrupts_response(self) -> bool:
return isinstance(self._turn_detection, dict) and self._turn_detection.get("interrupt_response", True) is True
def _reset_server_vad(self) -> None:
reset = getattr(self._server_vad, "reset", None)
if callable(reset):
reset()
@staticmethod
def _input_audio_transcription_config(session_payload: dict[str, object]) -> dict[str, object] | None:
transcription = session_payload.get("input_audio_transcription")
@@ -262,15 +262,10 @@ class RealtimeOutputProjector:
content_index=0,
audio_end_ms=committed_audio_ms,
)
payloads.append(
{
"type": "conversation.item.truncated",
"item_id": item_id,
"content_index": 0,
"audio_end_ms": committed_audio_ms,
"event": event,
}
)
# Keep server-side conversation history aligned with the
# acknowledged playback cursor. ``conversation.item.truncated``
# is reserved for an explicit client item-truncate request;
# cancellation itself terminates through ``response.done``.
payloads.extend(self._realtime_audio_done_events(event, response_id))
payloads.extend(
self._realtime_response_terminal_events(
@@ -58,6 +58,7 @@ class NativeRealtimeSessionProtocol(
while True:
if not self._pending_outbound.empty():
return json.dumps(await self._pending_outbound.get())
await self.wait_for_realtime_turn_detection_update()
raw = await websocket.receive_text()
try:
event = json.loads(raw)
@@ -97,11 +98,22 @@ class NativeRealtimeSessionProtocol(
return json.dumps(translated)
def encode_outbound_event(self, data: dict[str, Any]) -> list[dict[str, object]]:
self._resolve_realtime_turn_detection_update(data)
payloads = self._from_duplex_event(data)
for payload in payloads:
self._attach_event_id(payload)
return payloads
def _resolve_realtime_turn_detection_update(self, data: dict[str, Any]) -> None:
"""Publish or discard a staged VAD with the Realtime session ACK."""
if not self._turn_detection_update_pending:
return
event_type = data.get("type")
if event_type in {"session.created", "session.updated"}:
self.commit_realtime_turn_detection_update()
elif event_type == "error":
self.reject_realtime_turn_detection_update()
@staticmethod
def _attach_event_id(payload: dict[str, object]) -> None:
payload.setdefault("event_id", f"event_{uuid4().hex}")
@@ -57,6 +57,7 @@ REALTIME_ERROR_TYPES_BY_CODE = {
"native_text_append_unsupported": "invalid_request_error",
"runtime_native_stage_role_required": "server_error",
"runtime_native_runner_kv_required": "server_error",
"server_vad_unavailable": "server_error",
}
@@ -105,6 +106,12 @@ class RealtimeSessionState:
_input_sample_rate_hz: int = 16000
_output_audio_format: str = "pcm16"
_overlap_silence_rms: float = 0.003
_turn_detection: dict[str, object] | None = None
_server_vad: object | None = None
_turn_detection_update_pending: bool = False
_pending_turn_detection: dict[str, object] | None = None
_pending_server_vad: object | None = None
_turn_detection_update_resolved: asyncio.Event | None = None
_send_realtime_json: Any = None
_initial_session_update: bool = False
_input_speech_started: bool = False
@@ -180,6 +187,12 @@ class RealtimeStateOwner:
_input_sample_rate_hz: int = _RealtimeStateField()
_output_audio_format: str = _RealtimeStateField()
_overlap_silence_rms: float = _RealtimeStateField()
_turn_detection: dict[str, object] | None = _RealtimeStateField()
_server_vad: object | None = _RealtimeStateField()
_turn_detection_update_pending: bool = _RealtimeStateField()
_pending_turn_detection: dict[str, object] | None = _RealtimeStateField()
_pending_server_vad: object | None = _RealtimeStateField()
_turn_detection_update_resolved: asyncio.Event | None = _RealtimeStateField()
_send_realtime_json: Any = _RealtimeStateField()
_initial_session_update: bool = _RealtimeStateField()
_input_speech_started: bool = _RealtimeStateField()
@@ -494,6 +494,26 @@ class OmniDuplexSessionHandler(
# playback only controls history ACKs, not model admission.
if is_speech:
session.accumulate_overlap_speech(duration_ms)
vad_speech_started = self._vad_speech_started(event, payload)
if (
self._uses_native_input_append(session)
and session.capabilities.supports_barge_in
and is_speech
and session.config.overlap_policy == DuplexOverlapPolicy.BARGE_IN_ON_SPEECH.value
and vad_speech_started is not False
):
# The configured server overlap detector classified this chunk
# as speech while the assistant is still generating/playing.
# Fence the active response so the same interrupting chunk is
# retained as the start of the next turn.
return {
"action": "barge_in",
"reason": ("server_vad_speech_started" if vad_speech_started is True else "barge_in_on_speech"),
"cancel_reason": "turn_detected" if vad_speech_started is True else "barge_in",
"duration_ms": duration_ms,
"overlap_speech_ms": session.overlap_speech_ms,
"buffer_audio": True,
}
return {
"action": "listen",
"reason": "auto_response_continuous",
@@ -501,7 +521,7 @@ class OmniDuplexSessionHandler(
"overlap_speech_ms": session.overlap_speech_ms,
"buffer_audio": True,
"defer_runtime_append": False,
"force_listen": False,
"force_listen": event.get("force_listen") is True or payload.get("force_listen") is True,
"preserve_realtime_input": True,
}
if bool(event.get("force_listen", False)):
@@ -553,9 +573,22 @@ class OmniDuplexSessionHandler(
session.accumulate_overlap_speech(duration_ms)
if policy == DuplexOverlapPolicy.BARGE_IN_ON_SPEECH.value:
vad_speech_started = self._vad_speech_started(event, payload)
if vad_speech_started is False:
return {
"action": "listen",
"reason": "server_vad_utterance_active",
"duration_ms": duration_ms,
"overlap_speech_ms": session.overlap_speech_ms,
"buffer_audio": True,
"defer_runtime_append": False,
"force_listen": True,
"preserve_realtime_input": True,
}
return {
"action": "barge_in",
"reason": "policy_barge_in_on_speech",
"reason": ("server_vad_speech_started" if vad_speech_started is True else "policy_barge_in_on_speech"),
"cancel_reason": "turn_detected" if vad_speech_started is True else "barge_in",
"duration_ms": duration_ms,
"overlap_speech_ms": session.overlap_speech_ms,
"buffer_audio": True,
@@ -599,6 +632,18 @@ class OmniDuplexSessionHandler(
"defer_runtime_append": True,
}
@staticmethod
def _vad_speech_started(
event: Mapping[str, object],
payload: Mapping[str, object],
) -> bool | None:
"""Return an onset edge, or None for legacy non-server-VAD input."""
for source in (event, payload):
vad = source.get("vad")
if isinstance(vad, Mapping) and isinstance(vad.get("speech_started"), bool):
return bool(vad["speech_started"])
return None
@staticmethod
def _event_requests_barge_in(event: Mapping[str, object]) -> bool:
if event.get("force_barge_in") is True:
@@ -18,6 +18,7 @@ from vllm_omni.experimental.fullduplex.openai.commit_policy import (
decide_commit_action,
)
from vllm_omni.experimental.fullduplex.openai.protocol import (
DuplexOverlapPolicy,
DuplexPlaybackCommitPolicy,
DuplexSession,
DuplexSessionState,
@@ -875,7 +876,11 @@ class DuplexSessionRunnerMixin:
if event_type in {"input.cancel", "response.cancel", "barge_in", "output_audio_buffer.clear"}:
cancel_reason = (
"output_audio_buffer_clear" if event_type == "output_audio_buffer.clear" else "barge_in"
"output_audio_buffer_clear"
if event_type == "output_audio_buffer.clear"
else "client_cancelled"
if event_type == "response.cancel"
else "barge_in"
)
cancelled_fence = DuplexFence(
session.session_id,
@@ -1249,7 +1254,16 @@ class DuplexSessionRunnerMixin:
buffer_overlap_audio = True
if self._uses_native_input_append(session):
mark_pending_silence_superseded()
overlap_active = not self._session_auto_responds(session) and native_response_in_progress()
overlap_active = native_response_in_progress() and (
not self._session_auto_responds(session)
or (
session.capabilities.supports_barge_in
and (
session.config.overlap_policy == DuplexOverlapPolicy.BARGE_IN_ON_SPEECH.value
or self._event_requests_barge_in(event)
)
)
)
if overlap_active:
decision = self._overlap_decision(session, event, payload)
await self._emit_overlap_decision(emit_event, session, decision)
@@ -1290,11 +1304,12 @@ class DuplexSessionRunnerMixin:
native.speech_since_commit = False
await actor.cancel_append_tasks()
had_native_stream = native.data_plane_task is not None
cancel_reason = str(decision.get("cancel_reason") or "barge_in")
cancelled = await self._cancel_active_response(
session,
actor.active_response_task,
emit_event,
reason="barge_in",
reason=cancel_reason,
)
had_native_stream = (
await self._cancel_native_data_plane_stream(session) or had_native_stream
@@ -1310,7 +1325,7 @@ class DuplexSessionRunnerMixin:
"type": "audio.cancelled",
"session_id": session.session_id,
"response_id": old_response_id,
"reason": "barge_in",
"reason": cancel_reason,
"cancelled_epoch": old_epoch,
"epoch": new_epoch,
"committed_ms": committed_ms,
@@ -1330,7 +1345,7 @@ class DuplexSessionRunnerMixin:
"type": "audio.cancelled",
"session_id": session.session_id,
"response_id": session.last_response_id,
"reason": "barge_in",
"reason": cancel_reason,
"cancelled_epoch": old_epoch,
"epoch": new_epoch,
"committed_ms": committed_ms,
@@ -0,0 +1,218 @@
from __future__ import annotations
import binascii
from collections.abc import Callable
from dataclasses import dataclass
import numpy as np
import pybase64 as base64
SILERO_VAD_MIN_THRESHOLD = 0.15
class ServerVADUnavailableError(RuntimeError):
"""Raised when the optional server VAD runtime is not installed."""
@dataclass(frozen=True, slots=True)
class SileroVADConfig:
threshold: float = 0.5
prefix_padding_ms: int = 300
silence_duration_ms: int = 500
min_speech_duration_ms: int = 96
@classmethod
def from_turn_detection(cls, turn_detection: dict[str, object]) -> SileroVADConfig:
defaults = cls()
def number(name: str, default: int | float) -> int | float:
value = turn_detection.get(name, default)
return value if isinstance(value, int | float) and not isinstance(value, bool) else default
return cls(
threshold=float(number("threshold", defaults.threshold)),
prefix_padding_ms=max(0, int(number("prefix_padding_ms", defaults.prefix_padding_ms))),
silence_duration_ms=max(0, int(number("silence_duration_ms", defaults.silence_duration_ms))),
min_speech_duration_ms=max(
32,
int(number("min_speech_duration_ms", defaults.min_speech_duration_ms)),
),
)
@dataclass(frozen=True, slots=True)
class StreamingVADResult:
is_speech: bool
speech_active: bool
speech_started: bool = False
speech_stopped: bool = False
speech_probability: float = 0.0
speech_start_ms: int | None = None
speech_end_ms: int | None = None
def as_hint(self) -> dict[str, object]:
hint: dict[str, object] = {
"backend": "silero",
"is_speech": self.is_speech,
"speech_active": self.speech_active,
"speech_started": self.speech_started,
"speech_stopped": self.speech_stopped,
"speech_probability": self.speech_probability,
}
if self.speech_start_ms is not None:
hint["speech_start_ms"] = self.speech_start_ms
if self.speech_end_ms is not None:
hint["speech_end_ms"] = self.speech_end_ms
return hint
class SileroStreamingVAD:
"""Per-session streaming Silero VAD with onset debounce and utterance latch.
Silero's model is stateful, so an instance must never be shared across
Realtime sessions. Audio is normalized to 16 kHz and scored in the model's
512-sample (32 ms) windows. Loading is lazy so deployments which use the
default model-owned mode do not need the optional ``silero-vad`` package.
"""
_SAMPLE_RATE_HZ = 16_000
_WINDOW_SAMPLES = 512
_NEGATIVE_THRESHOLD_GAP = SILERO_VAD_MIN_THRESHOLD
def __init__(
self,
config: SileroVADConfig,
*,
frame_scorer: Callable[[np.ndarray], float] | None = None,
) -> None:
self.config = config
self._frame_scorer = frame_scorer
self._model: object | None = None
self._pending = np.empty(0, dtype=np.float32)
self._speech_active = False
self._candidate_samples = 0
self._candidate_start_sample: int | None = None
self._silence_samples = 0
self._processed_samples = 0
def reset(self) -> None:
self._pending = np.empty(0, dtype=np.float32)
self._speech_active = False
self._candidate_samples = 0
self._candidate_start_sample = None
self._silence_samples = 0
self._processed_samples = 0
reset_states = getattr(self._model, "reset_states", None)
if callable(reset_states):
reset_states()
def process_base64(
self,
audio: object,
*,
fmt: object,
sample_rate_hz: object,
) -> StreamingVADResult:
if fmt != "pcm_f32le" or not isinstance(audio, str):
raise ValueError("Silero server VAD requires decoded pcm_f32le audio")
try:
raw = base64.b64decode(audio, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError("Silero server VAD received invalid base64 audio") from exc
if len(raw) % 4:
raise ValueError("Silero server VAD received an incomplete pcm_f32le frame")
samples = np.frombuffer(raw, dtype="<f4").astype(np.float32, copy=False)
rate = int(sample_rate_hz) if isinstance(sample_rate_hz, int | float) else self._SAMPLE_RATE_HZ
if rate <= 0:
raise ValueError("Silero server VAD requires a positive sample rate")
if rate != self._SAMPLE_RATE_HZ and samples.size > 1:
target_size = max(1, int(round(samples.size * self._SAMPLE_RATE_HZ / rate)))
source_x = np.linspace(0.0, 1.0, num=samples.size, endpoint=True)
target_x = np.linspace(0.0, 1.0, num=target_size, endpoint=True)
samples = np.interp(target_x, source_x, samples).astype(np.float32)
return self.process(samples)
def process(self, samples: np.ndarray) -> StreamingVADResult:
samples = np.asarray(samples, dtype=np.float32).reshape(-1)
if samples.size:
self._pending = np.concatenate((self._pending, samples))
started = False
stopped = False
start_ms: int | None = None
end_ms: int | None = None
max_probability = 0.0
contained_speech = self._speech_active
min_speech_samples = int(self.config.min_speech_duration_ms * self._SAMPLE_RATE_HZ / 1000)
min_silence_samples = int(self.config.silence_duration_ms * self._SAMPLE_RATE_HZ / 1000)
negative_threshold = max(0.0, self.config.threshold - self._NEGATIVE_THRESHOLD_GAP)
while self._pending.size >= self._WINDOW_SAMPLES:
frame = self._pending[: self._WINDOW_SAMPLES]
self._pending = self._pending[self._WINDOW_SAMPLES :]
frame_start = self._processed_samples
self._processed_samples += self._WINDOW_SAMPLES
probability = min(1.0, max(0.0, float(self._score_frame(frame))))
max_probability = max(max_probability, probability)
if self._speech_active:
contained_speech = True
if probability < negative_threshold:
self._silence_samples += self._WINDOW_SAMPLES
if self._silence_samples >= min_silence_samples:
speech_end_sample = self._processed_samples - self._silence_samples
self._speech_active = False
self._silence_samples = 0
stopped = True
end_ms = max(0, int(round(speech_end_sample * 1000 / self._SAMPLE_RATE_HZ)))
else:
self._silence_samples = 0
continue
if probability >= self.config.threshold:
if self._candidate_samples == 0:
self._candidate_start_sample = frame_start
self._candidate_samples += self._WINDOW_SAMPLES
if self._candidate_samples >= min_speech_samples:
candidate_start = self._candidate_start_sample or 0
prefix_samples = int(self.config.prefix_padding_ms * self._SAMPLE_RATE_HZ / 1000)
self._speech_active = True
self._candidate_samples = 0
self._candidate_start_sample = None
self._silence_samples = 0
contained_speech = True
started = True
start_ms = max(
0,
int(round((candidate_start - prefix_samples) * 1000 / self._SAMPLE_RATE_HZ)),
)
else:
self._candidate_samples = 0
self._candidate_start_sample = None
return StreamingVADResult(
is_speech=contained_speech,
speech_active=self._speech_active,
speech_started=started,
speech_stopped=stopped,
speech_probability=max_probability,
speech_start_ms=start_ms,
speech_end_ms=end_ms,
)
def _score_frame(self, frame: np.ndarray) -> float:
if self._frame_scorer is not None:
return float(self._frame_scorer(frame))
if self._model is None:
try:
import torch
from silero_vad import load_silero_vad
except ImportError as exc:
raise ServerVADUnavailableError(
"server_vad requires the optional 'silero-vad' package; install vllm-omni[server-vad]"
) from exc
self._model = load_silero_vad(onnx=False)
self._frame_scorer = lambda value: float(
self._model(torch.from_numpy(np.ascontiguousarray(value)), self._SAMPLE_RATE_HZ).item()
)
return float(self._frame_scorer(frame))
@@ -550,6 +550,35 @@ def _native_tts_boundary_token_ids(special_token_ids):
}
def _native_duplex_output_is_control_only(
output_ids: Sequence[int],
special_token_ids: Mapping[str, int],
) -> bool:
"""Whether a native output is a confirmed no-speech handoff.
Empty and control/terminal-only deltas intentionally have no Talker
conditioning. Any ordinary text or explicit speech boundary is not a
confirmed no-op and must retain the missing-hidden-state error path.
"""
if not output_ids:
return True
control_ids = {
token_id
for token_id in (
special_token_ids.get("tts_eos_token_id"),
special_token_ids.get("tts_pad_token_id"),
special_token_ids.get("listen_token_id"),
special_token_ids.get("chunk_eos_token_id"),
special_token_ids.get("chunk_tts_eos_token_id"),
special_token_ids.get("turn_eos_token_id"),
special_token_ids.get("unit_token_id"),
special_token_ids.get("unit_end_token_id"),
)
if token_id is not None
}
return bool(control_ids) and all(token_id in control_ids for token_id in output_ids)
def _decode_native_duplex_token_ids(
token_ids: list[int],
streaming_context,
@@ -757,10 +786,23 @@ def llm2tts(
)
prompt_token_ids_len = len(prompt_token_ids)
is_native_duplex_handoff = _has_native_duplex_prompt_metadata(mm_output)
latent = mm_output.get("latent", None)
if latent is None:
latent = output.hidden_states if hasattr(output, "hidden_states") else None
if latent is None:
if is_native_duplex_handoff and _native_duplex_output_is_control_only(
llm_output_ids, special_token_ids
):
# Listen/control/terminal outputs have no Talker payload by
# design. They are a successful no-op handoff, not a stage
# failure, so do not let one such unit stop the orchestrator.
logger.debug(
"Skipping no-speech MiniCPM-o native duplex handoff for request_id=%s",
llm_output.request_id,
)
continue
raise ValueError("No latent or hidden_states found in thinker output")
thinker_hidden_states = latent.detach()
@@ -771,7 +813,6 @@ def llm2tts(
full_token_ids = prompt_token_ids + llm_output_ids
tts_bos_id = special_token_ids.get("tts_bos_token_id")
is_native_duplex_handoff = _has_native_duplex_prompt_metadata(mm_output)
if is_native_duplex_handoff:
_require_native_tts_boundary_metadata(special_token_ids)
tts_end_ids = _native_tts_boundary_token_ids(special_token_ids)