2026-07-23 16:45:18 +08:00
|
|
|
# SPDX-License-Identifier: Apache-2.0
|
2026-09-05 16:20:21 +08:00
|
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
2026-07-23 16:45:18 +08:00
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-08-14 15:38:47 +08:00
|
|
|
import os
|
2026-07-23 16:45:18 +08:00
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _assert_isolated_import_succeeds(script: str) -> None:
|
2026-08-14 15:38:47 +08:00
|
|
|
# These tests validate Python import/package boundaries and do not use a
|
|
|
|
|
# GPU. Other tests in the combined entrypoints/engine suite may temporarily
|
|
|
|
|
# configure only one of the CUDA/ROCm visibility variables. Do not let an
|
|
|
|
|
# unrelated, inconsistent device-selection environment prevent the child
|
|
|
|
|
# interpreter from reaching the boundary assertion.
|
|
|
|
|
env = os.environ.copy()
|
|
|
|
|
env.pop("CUDA_VISIBLE_DEVICES", None)
|
|
|
|
|
env.pop("HIP_VISIBLE_DEVICES", None)
|
2026-07-23 16:45:18 +08:00
|
|
|
result = subprocess.run(
|
|
|
|
|
[sys.executable, "-c", script],
|
|
|
|
|
cwd=REPO_ROOT,
|
2026-08-14 15:38:47 +08:00
|
|
|
env=env,
|
2026-07-23 16:45:18 +08:00
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
2026-09-05 16:20:21 +08:00
|
|
|
# The CLI probe imports the whole vllm + benchmark graph; on a loaded
|
|
|
|
|
# shared host that alone can take over a minute.
|
|
|
|
|
timeout=180,
|
2026-07-23 16:45:18 +08:00
|
|
|
check=False,
|
|
|
|
|
)
|
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
|
|
|
|
|
2026-08-14 15:38:47 +08:00
|
|
|
def test_isolated_import_ignores_conflicting_gpu_visibility(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
"""Import-boundary checks must not depend on GPU state leaked by prior tests."""
|
|
|
|
|
monkeypatch.setenv("HIP_VISIBLE_DEVICES", "0")
|
|
|
|
|
monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "0,1")
|
|
|
|
|
|
|
|
|
|
_assert_isolated_import_succeeds("import vllm_omni.outputs")
|
|
|
|
|
|
|
|
|
|
|
2026-09-16 15:35:36 +08:00
|
|
|
def test_turn_based_stack_has_no_duplex_vocabulary() -> None:
|
|
|
|
|
# The generic bases and the turn-based siblings (AsyncOmni / AsyncOmniEngine /
|
|
|
|
|
# Orchestrator) carry no duplex code: importing them must not load the
|
|
|
|
|
# engine-side duplex package, the model plugins or the client library.
|
2026-07-23 16:45:18 +08:00
|
|
|
_assert_isolated_import_succeeds("""
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
import vllm_omni.engine.async_omni_engine
|
2026-09-16 15:35:36 +08:00
|
|
|
import vllm_omni.engine.omni_engine_base
|
2026-07-23 16:45:18 +08:00
|
|
|
import vllm_omni.engine.orchestrator
|
|
|
|
|
import vllm_omni.entrypoints.async_omni
|
2026-09-16 15:35:36 +08:00
|
|
|
import vllm_omni.entrypoints.async_omni_base
|
|
|
|
|
|
|
|
|
|
forbidden_prefixes = (
|
|
|
|
|
"vllm_omni.engine.duplex",
|
|
|
|
|
"vllm_omni.engine.duplex_omni_engine",
|
|
|
|
|
"vllm_omni.engine.duplex_orchestrator",
|
|
|
|
|
"vllm_omni.entrypoints.duplex",
|
|
|
|
|
"vllm_omni.entrypoints.duplex_omni",
|
|
|
|
|
"vllm_omni.clients",
|
|
|
|
|
"vllm_omni.model_executor.models.minicpmo_4_5.duplex",
|
|
|
|
|
"vllm_omni.model_executor.models.nemotron_voicechat.duplex",
|
|
|
|
|
"vllm_omni.model_executor.models.personaplex.duplex",
|
|
|
|
|
)
|
|
|
|
|
loaded = sorted(
|
|
|
|
|
name
|
|
|
|
|
for name in sys.modules
|
|
|
|
|
if any(name == prefix or name.startswith(prefix + ".") for prefix in forbidden_prefixes)
|
|
|
|
|
)
|
|
|
|
|
if loaded:
|
|
|
|
|
raise SystemExit("turn-based imports loaded duplex modules: " + ", ".join(loaded))
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_duplex_stack_loads_the_framework_but_not_the_model_plugins() -> None:
|
|
|
|
|
# The duplex siblings import the engine-side duplex framework eagerly;
|
|
|
|
|
# model plugins still load only through PipelineConfig.duplex_plugin, and
|
|
|
|
|
# the client-side package never enters the server import graph.
|
|
|
|
|
_assert_isolated_import_succeeds("""
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
import vllm_omni.engine.duplex_omni_engine
|
|
|
|
|
import vllm_omni.engine.duplex_orchestrator
|
|
|
|
|
import vllm_omni.entrypoints.duplex_omni
|
|
|
|
|
import vllm_omni.entrypoints.duplex.serving
|
2026-07-23 16:45:18 +08:00
|
|
|
|
2026-09-05 16:20:21 +08:00
|
|
|
expected_eager = (
|
2026-09-16 15:35:36 +08:00
|
|
|
"vllm_omni.engine.duplex.commands",
|
|
|
|
|
"vllm_omni.engine.duplex.events",
|
|
|
|
|
"vllm_omni.engine.duplex.session.engine_session",
|
|
|
|
|
"vllm_omni.engine.duplex.session.manager",
|
|
|
|
|
"vllm_omni.engine.duplex.plugin",
|
2026-09-05 16:20:21 +08:00
|
|
|
)
|
|
|
|
|
missing = sorted(name for name in expected_eager if name not in sys.modules)
|
|
|
|
|
if missing:
|
2026-09-16 15:35:36 +08:00
|
|
|
raise SystemExit("duplex framework modules not imported eagerly: " + ", ".join(missing))
|
2026-09-05 16:20:21 +08:00
|
|
|
|
|
|
|
|
forbidden_prefixes = (
|
|
|
|
|
"vllm_omni.clients",
|
|
|
|
|
"vllm_omni.model_executor.models.minicpmo_4_5.duplex",
|
|
|
|
|
"vllm_omni.model_executor.models.nemotron_voicechat.duplex",
|
|
|
|
|
"vllm_omni.model_executor.models.personaplex.duplex",
|
|
|
|
|
)
|
2026-07-23 16:45:18 +08:00
|
|
|
loaded = sorted(
|
|
|
|
|
name
|
|
|
|
|
for name in sys.modules
|
2026-09-05 16:20:21 +08:00
|
|
|
if any(name == prefix or name.startswith(prefix + ".") for prefix in forbidden_prefixes)
|
2026-07-23 16:45:18 +08:00
|
|
|
)
|
|
|
|
|
if loaded:
|
2026-09-16 15:35:36 +08:00
|
|
|
raise SystemExit("duplex imports loaded plugin-only or client modules: " + ", ".join(loaded))
|
2026-09-05 16:20:21 +08:00
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cli_import_graph_stays_clear_of_the_client_package() -> None:
|
|
|
|
|
# `vllm-omni serve` imports the CLI package, whose __init__ eagerly pulls
|
|
|
|
|
# in the benchmark patch module and every benchmark subcommand. None of
|
|
|
|
|
# that may drag the client-side vllm_omni.clients package into the server
|
|
|
|
|
# process; the benchmark runtime imports it lazily at execution time.
|
|
|
|
|
_assert_isolated_import_succeeds("""
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
import vllm_omni.entrypoints.cli.main
|
|
|
|
|
|
|
|
|
|
loaded = sorted(
|
|
|
|
|
name
|
|
|
|
|
for name in sys.modules
|
|
|
|
|
if name == "vllm_omni.clients" or name.startswith("vllm_omni.clients.")
|
|
|
|
|
)
|
|
|
|
|
if loaded:
|
|
|
|
|
raise SystemExit("CLI import graph loaded the client package: " + ", ".join(loaded))
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_client_package_stays_clear_of_the_server_import_graph() -> None:
|
|
|
|
|
# vllm_omni.clients.duplex advertises a lightweight dependency set
|
|
|
|
|
# (pybase64 + websockets). Importing it and exercising the code paths
|
|
|
|
|
# that previously reached into the server tree (timing_summary once
|
|
|
|
|
# imported vllm_omni.metrics, dragging in prometheus_client) must not
|
|
|
|
|
# load any vllm_omni module outside the client package.
|
|
|
|
|
_assert_isolated_import_succeeds("""
|
|
|
|
|
import base64
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
import vllm_omni # the package __init__ loads its own baseline graph
|
|
|
|
|
|
|
|
|
|
baseline = set(sys.modules)
|
|
|
|
|
|
|
|
|
|
from vllm_omni.clients.duplex import EventCollector
|
|
|
|
|
|
|
|
|
|
collector = EventCollector()
|
|
|
|
|
chunk = base64.b64encode(b"\\x00\\x00" * 2400).decode("ascii")
|
|
|
|
|
collector.add({"type": "response.created", "response_id": "r1"}, received_at_s=1.0)
|
2026-09-10 20:50:12 -04:00
|
|
|
collector.add({"type": "response.output_audio.delta", "response_id": "r1", "delta": chunk}, received_at_s=1.1)
|
|
|
|
|
collector.add({"type": "response.output_audio.delta", "response_id": "r1", "delta": chunk}, received_at_s=1.2)
|
2026-09-05 16:20:21 +08:00
|
|
|
summary = collector.timing_summary(after_s=0.0, response_id="r1")
|
|
|
|
|
if "audio_output" not in summary or "request_metrics" not in summary:
|
|
|
|
|
raise SystemExit("timing_summary produced no audio_output/request_metrics sections")
|
|
|
|
|
|
|
|
|
|
added = sorted(
|
|
|
|
|
name
|
|
|
|
|
for name in set(sys.modules) - baseline
|
|
|
|
|
if name.startswith("vllm_omni.") and not name.startswith("vllm_omni.clients")
|
|
|
|
|
)
|
|
|
|
|
if added:
|
|
|
|
|
raise SystemExit("the client package pulled in server modules: " + ", ".join(added))
|
2026-07-23 16:45:18 +08:00
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_stable_engine_does_not_expose_duplex_contract_modules() -> None:
|
|
|
|
|
_assert_isolated_import_succeeds("""
|
|
|
|
|
import importlib.util
|
|
|
|
|
|
|
|
|
|
from vllm_omni.engine import messages
|
|
|
|
|
|
|
|
|
|
legacy_modules = (
|
|
|
|
|
"vllm_omni.engine.duplex_contracts",
|
|
|
|
|
"vllm_omni.engine.duplex_lease",
|
|
|
|
|
"vllm_omni.engine.resumable",
|
|
|
|
|
)
|
|
|
|
|
present = [name for name in legacy_modules if importlib.util.find_spec(name) is not None]
|
|
|
|
|
if present:
|
|
|
|
|
raise SystemExit("stable duplex modules still exist: " + ", ".join(present))
|
|
|
|
|
|
|
|
|
|
duplex_exports = sorted(name for name in vars(messages) if name.startswith("Duplex"))
|
|
|
|
|
if duplex_exports:
|
|
|
|
|
raise SystemExit("stable messages still expose duplex contracts: " + ", ".join(duplex_exports))
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_stable_outputs_do_not_declare_duplex_decision_field() -> None:
|
|
|
|
|
_assert_isolated_import_succeeds("""
|
|
|
|
|
import dataclasses
|
|
|
|
|
|
|
|
|
|
from vllm_omni.outputs import OmniRequestOutput
|
|
|
|
|
|
|
|
|
|
fields = {field.name for field in dataclasses.fields(OmniRequestOutput)}
|
|
|
|
|
if "duplex_output_decision" in fields:
|
|
|
|
|
raise SystemExit("stable output declares duplex_output_decision")
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
2026-09-05 16:20:21 +08:00
|
|
|
def test_duplex_sampling_helper_lives_in_model_executor() -> None:
|
2026-07-23 16:45:18 +08:00
|
|
|
_assert_isolated_import_succeeds("""
|
|
|
|
|
import importlib.util
|
|
|
|
|
|
2026-09-05 16:20:21 +08:00
|
|
|
if importlib.util.find_spec("vllm_omni.model_executor.duplex_sampling") is None:
|
|
|
|
|
raise SystemExit("model_executor duplex_sampling helper module is missing")
|
2026-07-23 16:45:18 +08:00
|
|
|
""")
|
2026-09-05 16:20:21 +08:00
|
|
|
assert not (REPO_ROOT / "vllm_omni" / "experimental" / "fullduplex" / "model_executor.py").exists()
|
2026-07-23 16:45:18 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_runtime_package_does_not_bundle_the_browser_demo() -> None:
|
|
|
|
|
assert not (REPO_ROOT / "vllm_omni" / "experimental" / "fullduplex" / "web").exists()
|
|
|
|
|
|
|
|
|
|
|
2026-09-05 16:20:21 +08:00
|
|
|
def test_engine_duplex_uses_canonical_contract_module_names() -> None:
|
|
|
|
|
engine_dir = REPO_ROOT / "vllm_omni" / "engine" / "duplex"
|
2026-07-23 16:45:18 +08:00
|
|
|
core_dir = REPO_ROOT / "vllm_omni" / "experimental" / "fullduplex" / "core"
|
|
|
|
|
|
2026-09-05 16:20:21 +08:00
|
|
|
for name in (
|
2026-09-16 15:35:36 +08:00
|
|
|
"commands.py",
|
2026-09-05 16:20:21 +08:00
|
|
|
"contracts.py",
|
2026-09-16 15:35:36 +08:00
|
|
|
"events.py",
|
2026-09-05 16:20:21 +08:00
|
|
|
"messages.py",
|
2026-09-16 15:35:36 +08:00
|
|
|
"plugin.py",
|
2026-09-05 16:20:21 +08:00
|
|
|
"intermediate.py",
|
|
|
|
|
):
|
|
|
|
|
assert (engine_dir / name).is_file()
|
2026-09-16 15:35:36 +08:00
|
|
|
# one session and everything that runs it lives in the session subpackage
|
|
|
|
|
for name in (
|
|
|
|
|
"__init__.py",
|
|
|
|
|
"engine_session.py",
|
|
|
|
|
"manager.py",
|
|
|
|
|
"runner.py",
|
|
|
|
|
"context.py",
|
|
|
|
|
"emitter.py",
|
|
|
|
|
"model_channel.py",
|
|
|
|
|
"control.py",
|
|
|
|
|
"append_task.py",
|
|
|
|
|
"helpers.py",
|
|
|
|
|
"lease.py",
|
|
|
|
|
):
|
|
|
|
|
assert (engine_dir / "session" / name).is_file()
|
|
|
|
|
assert not (engine_dir / "session.py").exists()
|
|
|
|
|
for removed in ("control_plane.py", "control_client.py", "runtime.py"):
|
|
|
|
|
assert not (engine_dir / removed).exists()
|
2026-09-05 16:20:21 +08:00
|
|
|
# the duplex_ prefix is dropped inside the duplex package
|
|
|
|
|
assert not (engine_dir / "duplex_session.py").exists()
|
2026-07-23 16:45:18 +08:00
|
|
|
assert not (engine_dir / "duplex_lease.py").exists()
|
|
|
|
|
assert not (engine_dir / "duplex_types.py").exists()
|
2026-09-05 16:20:21 +08:00
|
|
|
# the experimental package retains only the joyvl framework; stale
|
|
|
|
|
# __pycache__ leftovers may exist, so assert on source files, not the dir
|
|
|
|
|
old_engine_dir = REPO_ROOT / "vllm_omni" / "experimental" / "fullduplex" / "engine"
|
|
|
|
|
assert not list(old_engine_dir.glob("*.py"))
|
|
|
|
|
old_personaplex_dir = REPO_ROOT / "vllm_omni" / "experimental" / "fullduplex" / "personaplex"
|
|
|
|
|
assert not list(old_personaplex_dir.glob("**/*.py"))
|
2026-07-23 16:45:18 +08:00
|
|
|
assert not (core_dir / "identity.py").exists()
|