[Feature][MiniCPM-o] Support configurable concurrent duplex sessions (#6021)

Signed-off-by: natureofnature <wzliu@connect.hku.hk>
This commit is contained in:
NATURE authored and GitHub committed 2026-08-25 02:27:30 +00:00
1 parent 710024fa6f
commit 702a409664
15 files changed
+1647 -129

No files matched your search

@@ -1,4 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""MiniCPM-o 4.5 Daily-Omni + Seed-TTS accuracy regression coverage.
Daily-Omni settings follow the MiniCPM interleaved AV recipe that reaches
@@ -21,13 +22,25 @@ from tests.e2e.accuracy.qwen3_omni.qwen3_omni_acc_bench_core import (
build_acc_benchmark_cli_argv,
find_vllm_cli,
)
from tests.e2e.online_serving.helpers.minicpmo_4_5_duplex import SERVER_PARAMS as DUPLEX_TEST_PARAMS
from tests.e2e.online_serving.helpers.minicpmo_4_5_duplex import (
DEPLOY_CONFIG as _DUPLEX_DEPLOY_CONFIG,
)
from tests.e2e.online_serving.helpers.minicpmo_4_5_duplex import (
SERVER_PARAMS as DUPLEX_TEST_PARAMS,
)
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniServerParams
from tests.helpers.stage_config import get_deploy_config_path
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
_MODEL = os.environ.get("VLLM_TEST_MINICPMO_4_5_MODEL", "openbmb/MiniCPM-o-4_5")
_DEPLOY_CONFIG = get_deploy_config_path("minicpmo_4_5.yaml")
_DUPLEX_BF16_DEPLOY_CONFIG = modify_stage_config(
_DUPLEX_DEPLOY_CONFIG,
updates={
"base_config": _DEPLOY_CONFIG,
"connectors.connector_of_shared_memory.extra.code2wav_bfloat16_attention_cache": True,
},
)
_RESULT_DIR = Path(
os.environ.get(
"ACC_BENCH_RESULT_DIR",
@@ -91,6 +104,20 @@ seed_test_params = [
]
duplex_accuracy_test_params = [
*DUPLEX_TEST_PARAMS,
pytest.param(
OmniServerParams(
model=_MODEL,
stage_config_path=_DUPLEX_BF16_DEPLOY_CONFIG,
use_stage_cli=False,
server_args=list(_SEED_TTS_SERVER_ARGS),
),
id="three-stage-single-gpu-bf16-attention-cache",
),
]
def _require_vllm_cli() -> None:
try:
find_vllm_cli()
@@ -174,7 +201,7 @@ def test_minicpmo_4_5_seed_tts_wer_bench(omni_server) -> None:
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.parametrize("omni_server", DUPLEX_TEST_PARAMS, indirect=True)
@pytest.mark.parametrize("omni_server", duplex_accuracy_test_params, indirect=True)
def test_minicpmo_4_5_duplex_seed_tts_wer_bench(omni_server) -> None:
"""Gate Seed-TTS WER through the explicit Realtime TTS contract."""
_require_vllm_cli()
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Strict MiniCPM-o 4.5 Realtime duplex E2E scenario harness.
This script is intentionally scenario-based instead of a generic chat client.
@@ -1351,6 +1354,9 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
try:
await ws.send(json.dumps(_session_update_event(args)))
await _wait_for(state, lambda: state.count("session.created") > 0, timeout_s=20, label="session.created")
start_barrier = getattr(args, "start_barrier", None)
if start_barrier is not None:
await start_barrier.wait()
turn_transcripts = _turn_transcripts(args.first_turn_transcript, turns=args.turns)
turn_specs = list(zip(turn_transcripts, turn_durations, strict=True))
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Concurrent MiniCPM-o Realtime duplex and resumable-session E2E driver."""
from __future__ import annotations
@@ -14,6 +17,7 @@ from types import SimpleNamespace
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
import websockets
from websockets.asyncio.client import ClientConnection
from websockets.exceptions import ConnectionClosed
SCRIPT_DIR = Path(__file__).resolve().parent
@@ -42,6 +46,67 @@ async def run_demo(args):
return await _scenario_module().run_demo(args)
class _SynchronizedStartGate:
"""One-shot, failure-propagating start gate for Python 3.10+."""
def __init__(self, parties: int, *, timeout_s: float):
if parties < 1:
raise ValueError("synchronized start parties must be positive")
if timeout_s <= 0:
raise ValueError("synchronized start timeout must be positive")
self._parties = parties
self._timeout_s = timeout_s
self._arrived = 0
self._released = False
self._failure: BaseException | None = None
self._event = asyncio.Event()
self._lock = asyncio.Lock()
async def wait(self) -> None:
async with self._lock:
if self._failure is not None:
raise RuntimeError(f"synchronized start aborted: {self._failure!r}") from self._failure
if self._released:
return
self._arrived += 1
if self._arrived == self._parties:
self._released = True
self._event.set()
return
try:
await asyncio.wait_for(self._event.wait(), timeout=self._timeout_s)
except asyncio.TimeoutError as exc:
failure = TimeoutError(
f"synchronized start timed out after {self._timeout_s:.1f}s "
f"({self._arrived}/{self._parties} sessions ready)"
)
await self.abort(failure)
if self._released and self._failure is None:
return
raise failure from exc
if self._failure is not None:
raise RuntimeError(f"synchronized start aborted: {self._failure!r}") from self._failure
async def abort(self, failure: BaseException) -> None:
async with self._lock:
if self._released or self._failure is not None:
return
self._failure = failure
self._event.set()
async def _run_demo_with_start_gate(args: SimpleNamespace):
start_gate = getattr(args, "start_barrier", None)
try:
return await run_demo(args)
except BaseException as exc:
if start_gate is not None:
await start_gate.abort(exc)
raise
def _with_resume_mode(url: str) -> str:
parts = urlsplit(url)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
@@ -101,14 +166,24 @@ def _validate_semantic_isolation(
return True
if len(expected_tokens) != len(results):
return False
for result, expected_token in zip(results, expected_tokens, strict=True):
normalized_expected_tokens = [token.strip().casefold() for token in expected_tokens]
if any(not token for token in normalized_expected_tokens):
return False
for result_index, (result, expected_token) in enumerate(zip(results, normalized_expected_tokens, strict=True)):
details = result.get("transcript_integrity")
transcripts = (
[str(item.get("transcript", "")) for item in details if isinstance(item, dict)]
if isinstance(details, list)
else []
)
if expected_token not in "".join(transcripts):
joined_transcripts = "".join(transcripts).casefold()
if expected_token not in joined_transcripts:
return False
if any(
other_token in joined_transcripts
for other_index, other_token in enumerate(normalized_expected_tokens)
if other_index != result_index
):
return False
return True
@@ -130,10 +205,14 @@ async def _receive_until(ws, event_type: str, *, timeout_s: float) -> tuple[dict
return await asyncio.wait_for(receive(), timeout=timeout_s)
def _server_event_sequences(events: list[dict[str, object]]) -> list[int]:
return [sequence for event in events if isinstance(sequence := event.get("server_event_seq"), int)]
async def _open_admission_session(
args: argparse.Namespace,
session_id: str,
) -> tuple[object, dict[str, object]]:
) -> tuple[ClientConnection, dict[str, object]]:
url = _url_with_model(
args.url,
args.model,
@@ -159,7 +238,7 @@ async def _open_admission_session(
return ws, created
async def _close_admission_session(ws, *, timeout_s: float) -> None:
async def _close_admission_session(ws: ClientConnection, *, timeout_s: float) -> None:
await ws.send(json.dumps({"type": "session.close"}))
await _receive_until(ws, "session.closed", timeout_s=timeout_s)
await ws.close()
@@ -169,8 +248,8 @@ async def _admission_probe(args: argparse.Namespace, *, limit: int) -> dict[str,
if limit < 1:
raise ValueError("admission limit must be positive")
prefix = f"admission-{uuid.uuid4().hex}"
accepted: list[tuple[object, dict[str, object]]] = []
replacement: tuple[object, dict[str, object]] | None = None
accepted: list[tuple[ClientConnection, dict[str, object]]] = []
replacement: tuple[ClientConnection, dict[str, object]] | None = None
overflow_code = None
try:
for index in range(limit):
@@ -274,14 +353,7 @@ async def _resume_probe(
generation = created.get("attachment_generation")
if not isinstance(token, str) or not isinstance(incarnation, int):
raise RuntimeError("session.created omitted resumable credentials")
last_seq = max(
(
event.get("server_event_seq", 0)
for event in first_events
if isinstance(event.get("server_event_seq"), int)
),
default=0,
)
last_seq = max(_server_event_sequences(first_events), default=0)
delay_s = args.expire_after_s if expect_expired else args.resume_after_ms / 1000
if delay_s > 0:
@@ -324,7 +396,7 @@ async def _resume_probe(
await second.send(json.dumps({"type": "session.close"}))
closed, close_events = await _receive_until(second, "session.closed", timeout_s=args.timeout_s)
replay_sequences = [event["server_event_seq"] for event in replay if isinstance(event.get("server_event_seq"), int)]
replay_sequences = _server_event_sequences(replay)
return {
"ok": (
resumed.get("session_id") == session_id
@@ -386,14 +458,7 @@ async def _takeover_probe(
generation = created.get("attachment_generation")
if not isinstance(token, str) or not isinstance(incarnation, int) or not isinstance(generation, int):
raise RuntimeError("session.created omitted takeover credentials")
last_seq = max(
(
event.get("server_event_seq", 0)
for event in first_events
if isinstance(event.get("server_event_seq"), int)
),
default=0,
)
last_seq = max(_server_event_sequences(first_events), default=0)
second = await websockets.connect(_with_resume_mode(url), max_size=64 * 1024 * 1024)
await second.send(
@@ -457,7 +522,9 @@ async def _takeover_probe(
await first.close()
def _demo_args(args: argparse.Namespace, index: int) -> SimpleNamespace:
def _demo_args(
args: argparse.Namespace, index: int, start_barrier: _SynchronizedStartGate | None = None
) -> SimpleNamespace:
validation_mode = "response-required" if args.response_required else "model-policy"
input_wav = args.session_input_wav[index] if args.session_input_wav else args.input_wav
return SimpleNamespace(
@@ -477,14 +544,16 @@ def _demo_args(args: argparse.Namespace, index: int) -> SimpleNamespace:
omit_transcript_hints=True,
validation_mode=validation_mode,
temperature=args.temperature,
scenario="sequential",
scenario=getattr(args, "scenario", "sequential"),
silence_ms=getattr(args, "silence_ms", 500),
require_audio=args.response_required,
require_distinct_inputs=False,
expect_empty_turn=[],
expect_empty_turn=list(getattr(args, "expect_empty_turn", []) or []),
short_ack_ms=350,
turns=args.turns,
timeout_s=args.timeout_s,
model_policy_settle_ms=args.model_policy_settle_ms,
start_barrier=start_barrier,
)
@@ -511,8 +580,16 @@ async def run_multi_session(args: argparse.Namespace) -> dict[str, object]:
)
lifecycle_result = await run_lifecycle_probes(args)
start_barrier = (
_SynchronizedStartGate(
args.sessions,
timeout_s=min(float(args.timeout_s), 30.0),
)
if getattr(args, "synchronized_start", False)
else None
)
session_results = await asyncio.gather(
*(run_demo(_demo_args(args, index)) for index in range(args.sessions)),
*(_run_demo_with_start_gate(_demo_args(args, index, start_barrier)) for index in range(args.sessions)),
return_exceptions=True,
)
failures = [repr(result) for result in session_results if isinstance(result, BaseException)]
@@ -596,7 +673,14 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--turns", type=int, default=1)
parser.add_argument("--first-turn-ms", type=int, default=1400)
parser.add_argument("--turn-duration-ms", type=int, action="append", default=[])
parser.add_argument("--expect-empty-turn", type=int, action="append", default=[])
parser.add_argument("--response-required", action="store_true")
parser.add_argument(
"--scenario",
choices=["sequential", "listen-only-overlap"],
default="sequential",
)
parser.add_argument("--silence-ms", type=int, default=500)
parser.add_argument("--temperature", type=float, default=None)
parser.add_argument("--disconnect-session-index", type=int)
parser.add_argument("--takeover-session-index", type=int)
@@ -604,6 +688,11 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--expire-session-index", type=int)
parser.add_argument("--expire-after-s", type=float, default=6.0)
parser.add_argument("--verify-admission-limit", type=int)
parser.add_argument(
"--synchronized-start",
action="store_true",
help="Wait until every session is created before starting input.",
)
parser.add_argument("--model-policy-settle-ms", type=int, default=600)
parser.add_argument("--timeout-s", type=float, default=120.0)
args = parser.parse_args()
@@ -615,6 +704,13 @@ def parse_args() -> argparse.Namespace:
parser.error("provide exactly one --session-expected-token per session")
if args.session_expected_token and not args.session_input_wav:
parser.error("--session-expected-token requires --session-input-wav")
normalized_expected_tokens = [token.strip().casefold() for token in args.session_expected_token]
for token_index, token in enumerate(normalized_expected_tokens):
if any(
token in other_token or other_token in token
for other_token in normalized_expected_tokens[token_index + 1 :]
):
parser.error("--session-expected-token values must not overlap after normalization")
return args
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import asyncio
import base64
import importlib.util
@@ -758,6 +761,34 @@ def test_realtime_duplex_multi_session_rejects_cross_session_response_identity()
)
def test_realtime_duplex_multi_session_rejects_overlapping_expected_tokens(monkeypatch, capsys):
demo = _load_multi_demo_module()
monkeypatch.setattr(
demo.sys,
"argv",
[
"multi.py",
"--sessions",
"2",
"--input-wav",
"fallback.wav",
"--session-input-wav",
"a.wav",
"--session-input-wav",
"b.wav",
"--session-expected-token",
"cat",
"--session-expected-token",
"cater",
],
)
with pytest.raises(SystemExit):
demo.parse_args()
assert "must not overlap after normalization" in capsys.readouterr().err
def test_realtime_duplex_multi_session_reads_nested_terminal_identity():
demo = _load_multi_demo_module()
@@ -768,6 +799,67 @@ def test_realtime_duplex_multi_session_reads_nested_terminal_identity():
)
def test_realtime_duplex_synchronized_start_gate_releases_all_participants():
demo = _load_multi_demo_module()
async def exercise():
gate = demo._SynchronizedStartGate(3, timeout_s=0.5)
released = []
async def participant(index):
await gate.wait()
released.append(index)
await asyncio.wait_for(
asyncio.gather(*(participant(index) for index in range(3))),
timeout=1.0,
)
return released
assert set(asyncio.run(exercise())) == {0, 1, 2}
def test_realtime_duplex_synchronized_start_gate_aborts_waiters_on_failure(monkeypatch):
demo = _load_multi_demo_module()
async def fake_run_demo(args):
if args.fail:
raise RuntimeError("session creation failed")
await args.start_barrier.wait()
return {"ok": True}
monkeypatch.setattr(demo, "run_demo", fake_run_demo)
async def exercise():
gate = demo._SynchronizedStartGate(2, timeout_s=0.5)
return await asyncio.wait_for(
asyncio.gather(
demo._run_demo_with_start_gate(SimpleNamespace(start_barrier=gate, fail=True)),
demo._run_demo_with_start_gate(SimpleNamespace(start_barrier=gate, fail=False)),
return_exceptions=True,
),
timeout=1.0,
)
failed, aborted = asyncio.run(exercise())
assert isinstance(failed, RuntimeError)
assert str(failed) == "session creation failed"
assert isinstance(aborted, RuntimeError)
assert "synchronized start aborted" in str(aborted)
assert "session creation failed" in str(aborted)
def test_realtime_duplex_synchronized_start_gate_has_bounded_wait():
demo = _load_multi_demo_module()
async def exercise():
gate = demo._SynchronizedStartGate(2, timeout_s=0.01)
await gate.wait()
with pytest.raises(TimeoutError, match=r"1/2 sessions ready"):
asyncio.run(exercise())
def test_realtime_duplex_demo_resolves_distinct_turn_inputs():
demo = _load_demo_module()
primary = Path("first.wav")