diff --git a/.buildkite/amd/scripts/bootstrap-amd-omni.sh b/.buildkite/amd/scripts/bootstrap-amd-omni.sh
index 9eff6b6c7..60732d801 100755
--- a/.buildkite/amd/scripts/bootstrap-amd-omni.sh
+++ b/.buildkite/amd/scripts/bootstrap-amd-omni.sh
@@ -60,7 +60,8 @@ resolve_test_specs() {
if ! selected_specs=$(python3 "$AMD_SUITE_SELECTOR" \
--branch "$BUILDKITE_BRANCH" \
--labels "$PR_LABELS" \
- --debug-test-yaml "${DEBUG_TEST_YAML:-}"); then
+ --debug-test-yaml "${DEBUG_TEST_YAML:-}" \
+ --nightly "$NIGHTLY"); then
exit 1
fi
@@ -80,6 +81,12 @@ filter_test_specs_by_skip_ci() {
case "$suite_spec" in
READY_TESTS:*) level="l2" ;;
MERGE_TESTS:*) level="l3" ;;
+ NIGHTLY_TESTS:*)
+ # Explicit PR and scheduled nightly selections must survive
+ # L2/L3 diff gating, including docs-only main commits.
+ runnable_specs+=("$suite_spec")
+ continue
+ ;;
*)
echo "ERROR: unknown AMD test suite spec '$suite_spec'" >&2
exit 1
@@ -101,7 +108,7 @@ filter_test_specs_by_skip_ci() {
":memo: CI skipped — docs or pytest skip-mark changes only" \
--style "info" 2>/dev/null || true
fi
- echo "No AMD L2/L3 suites remain after skip-ci filtering."
+ echo "No AMD suites remain after skip-ci filtering."
exit 0
fi
}
@@ -219,8 +226,8 @@ if [[ $BUILDKITE_BRANCH == "main" ]]; then
file_diff=$(get_diff_main)
fi
-# Resolve PR tier labels before skip-ci so ready and merge suites can be
-# filtered independently for CI-YAML-only changes.
+# Resolve PR tier labels before skip-ci so ready/merge suites can be filtered
+# independently while an explicitly selected nightly suite passes through.
resolve_test_specs
filter_test_specs_by_skip_ci
diff --git a/.buildkite/amd/scripts/build-ci-image.sh b/.buildkite/amd/scripts/build-ci-image.sh
new file mode 100755
index 000000000..3954cd567
--- /dev/null
+++ b/.buildkite/amd/scripts/build-ci-image.sh
@@ -0,0 +1,69 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+
+set -euo pipefail
+
+# Build and push the ROCm CI image with a registry-backed BuildKit cache. The
+# AMD builders are ephemeral, so a local Docker cache cannot survive between
+# jobs. Keeping the cache beside the runtime image also reuses the registry
+# credentials already configured on the amd-cpu agents.
+
+REGISTRY="${ROCM_CI_IMAGE_REGISTRY:-rocm/vllm-omni}"
+DOCKERFILE="docker/Dockerfile.rocm"
+BUILDER_NAME="vllm-omni-rocm-builder"
+ROCM_ARCH="${PYTORCH_ROCM_ARCH:-gfx942}"
+
+if [[ -z "${BUILDKITE_COMMIT:-}" ]]; then
+ echo "ERROR: BUILDKITE_COMMIT is not set" >&2
+ exit 1
+fi
+
+dependency_files=(
+ "$DOCKERFILE"
+ "${DOCKERFILE}.dockerignore"
+ pyproject.toml
+ setup.py
+ tools/install_torchcodec_rocm.sh
+)
+while IFS= read -r dependency_file; do
+ dependency_files+=("$dependency_file")
+done < <(find requirements -maxdepth 1 -type f -name '*.txt' -print | LC_ALL=C sort)
+
+for dependency_file in "${dependency_files[@]}"; do
+ if [[ ! -f "$dependency_file" ]]; then
+ echo "ERROR: cache-key input does not exist: $dependency_file" >&2
+ exit 1
+ fi
+done
+
+CACHE_KEY=$(
+ {
+ printf 'rocm-arch=%s\n' "$ROCM_ARCH"
+ for dependency_file in "${dependency_files[@]}"; do
+ printf 'path=%s\n' "$dependency_file"
+ sha256sum "$dependency_file"
+ done
+ } | sha256sum | cut -c1-16
+)
+CACHE_REF="${REGISTRY}:rocm-deps-cache-${CACHE_KEY}"
+IMAGE_REF="${REGISTRY}:${BUILDKITE_COMMIT}"
+
+echo "ROCm dependency cache: ${CACHE_REF}"
+echo "ROCm CI image: ${IMAGE_REF}"
+
+# The docker-container driver supports exporting the full cache to a registry;
+# the default docker driver on CI does not.
+if ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
+ docker buildx create --name "$BUILDER_NAME" --driver docker-container
+fi
+docker buildx use "$BUILDER_NAME"
+docker buildx inspect --bootstrap
+
+docker buildx build --push --pull --progress=plain \
+ --target test \
+ --build-arg "ARG_PYTORCH_ROCM_ARCH=${ROCM_ARCH}" \
+ --cache-from "type=registry,ref=${CACHE_REF}" \
+ --cache-to "type=registry,ref=${CACHE_REF},mode=max,compression=zstd,ignore-error=true" \
+ --file "$DOCKERFILE" \
+ --tag "$IMAGE_REF" .
diff --git a/.buildkite/amd/scripts/select_test_suites.py b/.buildkite/amd/scripts/select_test_suites.py
index b7c72f6e4..daed05bd3 100644
--- a/.buildkite/amd/scripts/select_test_suites.py
+++ b/.buildkite/amd/scripts/select_test_suites.py
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
-"""Select AMD L2/L3 suites after Buildkite has created an AMD build.
+"""Select AMD ready/merge/nightly suites after Buildkite creates a build.
This repository-side selector does not decide which GitHub label events start
the external ``vllm-omni-amd-ci`` pipeline. Its Buildkite PR build condition
@@ -17,6 +17,7 @@ from collections.abc import Iterable
SUITE_SPECS = {
"ready": "READY_TESTS:test-amd-ready.yml",
"merge": "MERGE_TESTS:test-amd-merge.yml",
+ "nightly": "NIGHTLY_TESTS:test-amd-nightly.yml",
}
@@ -28,7 +29,7 @@ def _parse_debug_suites(value: str) -> tuple[str, ...]:
seen: set[str] = set()
for suite in suites:
if suite not in SUITE_SPECS:
- raise ValueError(f"DEBUG_TEST_YAML entries must be 'merge' or 'ready', got {suite!r}")
+ raise ValueError(f"DEBUG_TEST_YAML entries must be 'merge', 'nightly', or 'ready', got {suite!r}")
if suite in seen:
raise ValueError(f"duplicate DEBUG_TEST_YAML suite {suite!r}")
seen.add(suite)
@@ -40,18 +41,27 @@ def select_amd_test_suites(
branch: str,
labels: Iterable[str],
debug_test_yaml: str = "",
+ nightly: bool = False,
) -> tuple[str, ...]:
"""Return ordered suite names while preserving legacy unlabeled PRs."""
if debug_test_yaml:
return _parse_debug_suites(debug_test_yaml)
if branch == "main":
- return ("merge",)
+ return ("nightly",) if nightly else ("merge",)
label_set = {label.strip() for label in labels if label.strip()}
- selected = tuple(suite for label, suite in (("ready", "ready"), ("merge-test", "merge")) if label in label_set)
- # AMD historically uploaded L2 for every PR build. Keep that behavior for
- # triggers such as amd-test while adding tier selection for ready/merge-test.
+ selected = tuple(
+ suite
+ for label, suite in (
+ ("ready", "ready"),
+ ("merge-test", "merge"),
+ ("nightly-test", "nightly"),
+ )
+ if label in label_set
+ )
+ # AMD historically uploaded the ready suite for every admitted PR build.
+ # Preserve that fallback when no explicit tier label is present.
return selected or ("ready",)
@@ -60,6 +70,7 @@ def main() -> int:
parser.add_argument("--branch", required=True)
parser.add_argument("--labels", default="")
parser.add_argument("--debug-test-yaml", default="")
+ parser.add_argument("--nightly", type=int, choices=(0, 1), default=0)
args = parser.parse_args()
labels = tuple(args.labels.splitlines())
@@ -68,19 +79,15 @@ def main() -> int:
branch=args.branch,
labels=labels,
debug_test_yaml=args.debug_test_yaml,
+ nightly=bool(args.nightly),
)
except ValueError as exc:
parser.error(str(exc))
label_set = {label.strip() for label in labels if label.strip()}
- if not args.debug_test_yaml and args.branch != "main" and not ({"ready", "merge-test"} & label_set):
+ if args.branch != "main" and not args.debug_test_yaml and not ({"ready", "merge-test", "nightly-test"} & label_set):
print(
- "No AMD L2/L3 tier label found; preserving the legacy ready-suite fallback.",
- file=sys.stderr,
- )
- if "nightly-test" in label_set:
- print(
- "AMD nightly-test has no suite yet and does not affect L2/L3 selection.",
+ "No AMD ready/merge/nightly label found; preserving the legacy ready-suite fallback.",
file=sys.stderr,
)
diff --git a/.buildkite/amd/test-amd-merge.yml b/.buildkite/amd/test-amd-merge.yml
index b1b9e7ecc..6ee36de48 100644
--- a/.buildkite/amd/test-amd-merge.yml
+++ b/.buildkite/amd/test-amd-merge.yml
@@ -9,15 +9,26 @@ steps:
steps:
- label: "Simple · Model Executor Test · Shard %N/%t"
agent_pool: mi300_1
- parallelism: 2
+ parallelism: 3
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
+ timeout_in_minutes: 90
commands:
- export VLLM_ROCM_USE_AITER=0
# ignore test_teacache_extractors.py because it use rocm gemm kernel from vLLM
# that is not supported on CPU
- - "pytest -sv tests/model_executor -m 'core_model and cpu' --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT --shard-id=$$BUILDKITE_PARALLEL_JOB"
+ - "pytest -sv tests/model_executor -m 'core_model and cpu and not omni' --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT --shard-id=$$BUILDKITE_PARALLEL_JOB"
+
+ - label: "Simple · Omni Processor Test"
+ agent_pool: mi300_1
+ depends_on: amd-build
+ mirror_hardwares: [amdproduction]
+ grade: Blocking
+ timeout_in_minutes: 60
+ commands:
+ - export VLLM_ROCM_USE_AITER=0
+ - "pytest -sv tests/model_executor/models/test_omni_processing.py -m 'core_model and cpu and omni'"
- label: "Simple · Diffusion Test · Shard %N/%t"
agent_pool: mi300_1
@@ -25,6 +36,7 @@ steps:
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
+ timeout_in_minutes: 45
commands:
- export VLLM_ROCM_USE_AITER=0
# ignore test_teacache_extractors.py because it use rocm gemm kernel from vLLM
diff --git a/.buildkite/amd/test-amd-nightly.yml b/.buildkite/amd/test-amd-nightly.yml
new file mode 100644
index 000000000..0da365d0c
--- /dev/null
+++ b/.buildkite/amd/test-amd-nightly.yml
@@ -0,0 +1,66 @@
+# AMD MI300 `full_model` nightly coverage during burn-in.
+# All jobs are non-blocking until the lane has stable runtime evidence.
+# Historical build #11646 was red in Function Expansion and Accuracy (one
+# async_chunk similarity mismatch; Seed-TTS WER 0.376480 > 0.35).
+
+env:
+ VLLM_WORKER_MULTIPROC_METHOD: spawn
+ HF_HUB_DOWNLOAD_TIMEOUT: 300
+ HF_HUB_ETAG_TIMEOUT: 60
+
+steps:
+ # MI325 is the repository's legacy ROCm pytest marker. The AMD template maps
+ # agent_pool to the current MI300 Buildkite queues independently of that mark.
+ - group: ":full_moon: Qwen3-Omni MI300 Nightly"
+ steps:
+ - label: "Qwen3-Omni Function Expansion"
+ agent_pool: mi300_2
+ depends_on: amd-build
+ mirror_hardwares: [amdproduction]
+ grade: NonBlocking
+ timeout_in_minutes: 180
+ commands:
+ - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
+ - pytest -s -v tests/e2e/online_serving/test_qwen3_omni_expansion.py -m "full_model and rocm and MI325 and cards_2" --run-level "full_model"
+
+ - label: "Qwen3-Omni Accuracy"
+ agent_pool: mi300_2
+ depends_on: amd-build
+ mirror_hardwares: [amdproduction]
+ grade: NonBlocking
+ timeout_in_minutes: 180
+ artifact_paths:
+ - tests/e2e/accuracy/qwen3_omni/results/qwen_omni_acc/*.json
+ commands:
+ - export SEED_TTS_WER_EVAL=1
+ # PyTorch ROCm uses the torch.cuda device namespace.
+ - export SEED_TTS_EVAL_DEVICE=cuda:1
+ - pytest -s -v tests/e2e/accuracy/qwen3_omni/test_qwen3_omni.py -m "full_model and rocm and MI325 and cards_2" --run-level "full_model"
+ # Native tests run from the image copy under /app/vllm-omni, while
+ # Buildkite's artifact hook searches the checkout workspace.
+ - |
+ if [[ "$$PWD" != "$${BUILDKITE_BUILD_CHECKOUT_PATH:?}" ]]; then
+ artifact_dir="$${BUILDKITE_BUILD_CHECKOUT_PATH}/tests/e2e/accuracy/qwen3_omni/results/qwen_omni_acc"
+ mkdir -p "$$artifact_dir"
+ cp -v tests/e2e/accuracy/qwen3_omni/results/qwen_omni_acc/*.json "$$artifact_dir/"
+ fi
+
+ - label: "Qwen3-Omni Documentation Examples"
+ agent_pool: mi300_2
+ depends_on: amd-build
+ mirror_hardwares: [amdproduction]
+ grade: NonBlocking
+ timeout_in_minutes: 120
+ commands:
+ - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
+ - pytest -s -v tests/examples/online_serving/test_qwen3_omni.py -m "full_model and rocm and MI325 and cards_2" --run-level "full_model"
+
+ - label: "Qwen3-Omni AITER-on Smoke"
+ agent_pool: mi300_2
+ depends_on: amd-build
+ mirror_hardwares: [amdproduction]
+ grade: NonBlocking
+ timeout_in_minutes: 90
+ commands:
+ - export VLLM_ROCM_USE_AITER=1
+ - pytest -s -v tests/e2e/online_serving/test_qwen3_omni_expansion.py::test_text_to_text_audio_001 -m "full_model and rocm and MI325 and cards_2" --run-level "full_model"
diff --git a/.buildkite/amd/test-amd-ready.yml b/.buildkite/amd/test-amd-ready.yml
index 585f089a2..e46b559f3 100644
--- a/.buildkite/amd/test-amd-ready.yml
+++ b/.buildkite/amd/test-amd-ready.yml
@@ -9,26 +9,39 @@ steps:
steps:
- label: "Simple · Model Executor Test · Shard %N/%t"
agent_pool: mi300_1
- parallelism: 2
+ parallelism: 3
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
+ timeout_in_minutes: 90
commands:
- export VLLM_ROCM_USE_AITER=0
# ignore test_teacache_extractors.py because it use rocm gemm kernel from vLLM
# that is not supported on CPU
- - "pytest -sv tests/model_executor -m 'core_model and cpu' --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT --shard-id=$$BUILDKITE_PARALLEL_JOB"
+ - "pytest -sv tests/model_executor -m 'core_model and cpu and not omni' --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT --shard-id=$$BUILDKITE_PARALLEL_JOB"
- - label: "Simple · Diffusion Test"
+ - label: "Simple · Omni Processor Test"
agent_pool: mi300_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
+ timeout_in_minutes: 60
+ commands:
+ - export VLLM_ROCM_USE_AITER=0
+ - "pytest -sv tests/model_executor/models/test_omni_processing.py -m 'core_model and cpu and omni'"
+
+ - label: "Simple · Diffusion Test · Shard %N/%t"
+ agent_pool: mi300_1
+ parallelism: 4
+ depends_on: amd-build
+ mirror_hardwares: [amdproduction]
+ grade: Blocking
+ timeout_in_minutes: 45
commands:
- export VLLM_ROCM_USE_AITER=0
# ignore test_teacache_extractors.py because it use rocm gemm kernel from vLLM
# that is not supported on CPU
- - "pytest -sv tests/diffusion -m 'core_model and cpu' --ignore=tests/diffusion/cache/test_teacache_extractors.py"
+ - "pytest -sv tests/diffusion -m 'core_model and cpu' --ignore=tests/diffusion/cache/test_teacache_extractors.py --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT --shard-id=$$BUILDKITE_PARALLEL_JOB"
- label: "Simple · Engine&Entrypoints Test"
agent_pool: mi300_1
@@ -69,6 +82,27 @@ steps:
- group: ":card_index_dividers: Diffusion Test"
steps:
+ # Keep real FlashAttention execution out of the CPU-marked Simple lane.
+ # AITER may need to build several kernels on a cold worker, so run one
+ # pytest worker with job-local build directories and a bounded timeout.
+ - label: "ROCm · FlashAttention/AITER GPU Coverage"
+ agent_pool: mi300_1
+ depends_on: amd-build
+ mirror_hardwares: [amdproduction]
+ grade: NonBlocking
+ timeout_in_minutes: 60
+ commands:
+ - export VLLM_ROCM_USE_AITER=1
+ - export AITER_JIT_DIR="/tmp/vllm-omni-aiter-$$BUILDKITE_JOB_ID"
+ - export TORCH_EXTENSIONS_DIR="/tmp/vllm-omni-torch-extensions-$$BUILDKITE_JOB_ID"
+ - mkdir -p "$$AITER_JIT_DIR" "$$TORCH_EXTENSIONS_DIR"
+ - >-
+ timeout --signal=TERM --kill-after=2m 55m
+ pytest -s -v -n 1
+ tests/diffusion/attention/
+ -m 'core_model and rocm and MI325 and cards_1'
+ --run-level "core_model"
+
- label: "Diffusion · Batch Test"
agent_pool: mi300_1
depends_on: amd-build
@@ -160,6 +194,39 @@ steps:
# - export VLLM_WORKER_MULTIPROC_METHOD=spawn
# - timeout 20m pytest -s -v tests/e2e/offline_inference/test_stable_audio_expansion.py -m "advanced_model and diffusion and L4" --run-level advanced_model
+- label: "ROCm · Engine & Model Executor GPU Coverage (R2-01)"
+ agent_pool: mi300_1
+ depends_on: amd-build
+ mirror_hardwares: [amdproduction]
+ grade: NonBlocking
+ timeout_in_minutes: 20
+ artifact_paths:
+ - "artifacts/rocm-r2-01/**/*"
+ commands:
+ # Native AMD tests run from /app/vllm-omni, while Buildkite uploads
+ # artifacts relative to its checkout directory.
+ - export R2_01_ARTIFACT_DIR="$$BUILDKITE_BUILD_CHECKOUT_PATH/artifacts/rocm-r2-01"
+ - mkdir -p "$$R2_01_ARTIFACT_DIR"
+ # The CUDA ready job also covers tests/worker/test_batched_omni_output.py.
+ # Keep the initial ROCm burn-in scoped to the requested directories.
+ # Verbose pytest output and JUnit retain node IDs without a second
+ # collection-only pass over the same GPU-backed test tree.
+ # The CUDA/L4-only CosyVoice3 ragged-flow parity test cold-JITs multiple
+ # AITER kernels on ROCm and is covered by the CUDA ready lane.
+ - >-
+ pytest -s -v -ra
+ tests/engine/ tests/model_executor/
+ -m 'core_model and cuda and not
+ (cards_2 or cards_3 or cards_4 or cards_5 or cards_6 or cards_7 or cards_8)'
+ -k 'not test_code2wav_streaming_batch_matches_ragged_flow_numerics'
+ --run-level "core_model"
+ --durations=0
+ --junitxml="$$R2_01_ARTIFACT_DIR/pytest.xml"
+ 2>&1 | tee "$$R2_01_ARTIFACT_DIR/pytest.log"
+ - >-
+ tail -n 100 "$$R2_01_ARTIFACT_DIR/pytest.log" >
+ "$$R2_01_ARTIFACT_DIR/pytest-summary.txt"
+
- label: "Engine Test"
agent_pool: mi300_1
depends_on: amd-build
@@ -172,6 +239,71 @@ steps:
pytest -s -v tests/engine/test_async_omni_engine_abort.py
'
+- label: "ROCm · Entrypoints GPU Coverage (R2-02)"
+ agent_pool: mi300_1
+ depends_on: amd-build
+ mirror_hardwares: [amdproduction]
+ grade: NonBlocking
+ timeout_in_minutes: 30
+ env:
+ VLLM_WORKER_MULTIPROC_METHOD: spawn
+ artifact_paths:
+ - "artifacts/rocm-r2-02/**/*"
+ commands:
+ # Native AMD tests run from /app/vllm-omni, while Buildkite uploads
+ # artifacts relative to its checkout directory.
+ - export R2_02_ARTIFACT_DIR="$$BUILDKITE_BUILD_CHECKOUT_PATH/artifacts/rocm-r2-02"
+ - mkdir -p "$$R2_02_ARTIFACT_DIR"
+ - >-
+ ps -eo pid=,ppid=,lstart=,stat=,comm= >
+ "$$R2_02_ARTIFACT_DIR/processes-before.txt"
+ # Verbose pytest output and JUnit retain selected node IDs without a
+ # second collection-only pass over the same GPU-backed test tree.
+ # The inner timeout leaves five minutes for teardown verification and
+ # artifact generation before the Buildkite step timeout.
+ - >-
+ timeout --signal=TERM --kill-after=1m 25m
+ pytest -s -v -ra
+ tests/entrypoints/
+ -m 'core_model and cuda and not
+ (cards_2 or cards_3 or cards_4 or cards_5 or cards_6 or cards_7 or cards_8)'
+ --run-level "core_model"
+ --durations=0
+ --junitxml="$$R2_02_ARTIFACT_DIR/pytest.xml"
+ 2>&1 | tee "$$R2_02_ARTIFACT_DIR/pytest.log"
+ - >-
+ tail -n 100 "$$R2_02_ARTIFACT_DIR/pytest.log" >
+ "$$R2_02_ARTIFACT_DIR/pytest-summary.txt"
+ - |
+ sleep 5
+ ps -eo pid=,ppid=,lstart=,stat=,comm= > "$$R2_02_ARTIFACT_DIR/processes-after.txt"
+ # Compare PID plus process start time so PID reuse cannot hide a leak.
+ # Double dollars preserve awk field references through Buildkite interpolation.
+ awk '
+ NR == FNR {
+ if ($$9 != "ps") {
+ baseline[$$1 "|" $$3 "|" $$4 "|" $$5 "|" $$6 "|" $$7] = 1
+ }
+ next
+ }
+ $$9 != "ps" {
+ identity = $$1 "|" $$3 "|" $$4 "|" $$5 "|" $$6 "|" $$7
+ if (!(identity in baseline)) {
+ print
+ leaked = 1
+ }
+ }
+ END {
+ if (leaked) {
+ print "status=FAIL leaked_processes_detected=1"
+ exit 1
+ }
+ print "status=PASS leaked_processes=0"
+ }
+ ' "$$R2_02_ARTIFACT_DIR/processes-before.txt" \
+ "$$R2_02_ARTIFACT_DIR/processes-after.txt" \
+ 2>&1 | tee "$$R2_02_ARTIFACT_DIR/process-cleanup.txt"
+
# - label: "Omni Model Test Qwen3-Omni"
# agent_pool: mi300_2
# depends_on: amd-build
@@ -201,6 +333,12 @@ steps:
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
+ # A ROCm hardware hang aborts the interpreter with 134. Let Buildkite
+ # create one fresh pod instead of retrying on the poisoned GPU process.
+ retry:
+ automatic:
+ - exit_status: 134
+ limit: 1
commands:
- pytest -s -v tests/e2e/online_serving/test_cosyvoice3_tts_expansion.py -m "slow" --run-level "core_model"
diff --git a/.buildkite/amd/test-template-amd-omni.j2 b/.buildkite/amd/test-template-amd-omni.j2
index e2042b96a..cca99f509 100644
--- a/.buildkite/amd/test-template-amd-omni.j2
+++ b/.buildkite/amd/test-template-amd-omni.j2
@@ -3,7 +3,8 @@
Last synced: 2026-08-04
Modifications: Use the shared MI300 queues and Omni command-passing contract
#}
-{% set docker_image_amd = "rocm/vllm-omni:$BUILDKITE_COMMIT" %}
+{% set docker_registry_amd = "rocm/vllm-omni" %}
+{% set docker_image_amd = docker_registry_amd ~ ":$BUILDKITE_COMMIT" %}
{% set default_working_dir = "/app/vllm-omni" %}
{# MI300 runs tests natively in Kubernetes; Docker-in-Docker is unavailable. #}
@@ -74,11 +75,12 @@ steps:
depends_on: ~
soft_fail: false
commands:
- - "docker build -f docker/Dockerfile.rocm -t {{ docker_image_amd }} --target test --build-arg ARG_PYTORCH_ROCM_ARCH=gfx942 --progress plain ."
- - "docker push {{ docker_image_amd }}"
+ - "bash .buildkite/amd/scripts/build-ci-image.sh"
key: "amd-build"
env:
DOCKER_BUILDKIT: "1"
+ PYTORCH_ROCM_ARCH: "gfx942"
+ ROCM_CI_IMAGE_REGISTRY: "{{ docker_registry_amd }}"
retry:
automatic:
- exit_status: -1 # Agent was lost
@@ -156,6 +158,14 @@ steps:
{% for path in step.artifact_paths %}
- "{{ path }}"
{% endfor %}
+{% endif %}
+{% if step.retry %}
+ retry:
+ automatic:
+{% for retry_rule in step.retry.automatic %}
+ - exit_status: {{ retry_rule.exit_status }}
+ limit: {{ retry_rule.limit }}
+{% endfor %}
{% endif %}
{% if step.timeout_in_minutes %}
timeout_in_minutes: {{ step.timeout_in_minutes }}
@@ -224,6 +234,14 @@ steps:
{% for path in step.artifact_paths %}
- "{{ path }}"
{% endfor %}
+{% endif %}
+{% if step.retry %}
+ retry:
+ automatic:
+{% for retry_rule in step.retry.automatic %}
+ - exit_status: {{ retry_rule.exit_status }}
+ limit: {{ retry_rule.limit }}
+{% endfor %}
{% endif %}
{% if step.timeout_in_minutes %}
timeout_in_minutes: {{ step.timeout_in_minutes }}
diff --git a/.buildkite/common/ci_source_file_dependencies.yml b/.buildkite/common/ci_source_file_dependencies.yml
new file mode 100644
index 000000000..5dfbf8da4
--- /dev/null
+++ b/.buildkite/common/ci_source_file_dependencies.yml
@@ -0,0 +1,471 @@
+# Uploader-only path-prefix presets for CUDA L2–L5 and NPU L4 test YAML.
+# Referenced as ``source_file_dependencies: omni_qwen3_omni_function`` (or a list of
+# keys) on a step; ``upload_pipeline.py`` expands the key(s) only.
+#
+# YAML anchors above the registry hold model business-code paths. Job keys
+# ({model}_function / _perf / _accuracy / _reliability / _doc / _cov) alias
+# those anchors and add the scripts the job actually runs. Feature keys below
+# are unchanged. ``source_filter_fallback`` is not a job key: when no job-key
+# prefix matches, a matching diff keeps every job for that upload.
+#
+# YAML anchors are nested lists; the loader flattens them.
+
+x-qwen3-tts: &qwen3_tts
+ - vllm_omni/engine/async_omni_engine.py
+ - vllm_omni/entrypoints/omni_base.py
+ - vllm_omni/worker/gpu_model_runner.py
+ - vllm_omni/model_executor/models/qwen3_tts/
+ - vllm_omni/model_executor/models/common/qwen3_code_predictor.py
+ - vllm_omni/model_executor/models/common/snake_activation.py
+ - vllm_omni/model_executor/models/common/alias_free_activation.py
+ - vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
+ - vllm_omni/deploy/qwen3_tts.yaml
+ - vllm_omni/entrypoints/openai/serving_speech.py
+ - vllm_omni/entrypoints/openai/tts_adapters/
+ - vllm_omni/engine/stage_pool.py
+ - vllm_omni/worker_v2/
+ - vllm_omni/core/sched/
+ - vllm_omni/distributed/omni_connectors/
+ - vllm_omni/config/stage_config.py
+ - vllm_omni/config/omni_config.py
+ - vllm_omni/engine/orchestrator.py
+ - vllm_omni/engine/stage_engine_core_proc.py
+ - vllm_omni/deploy/qwen3_tts_mrv2.yaml
+ - vllm_omni/deploy/qwen3_tts_high_concurrency_mrv2.yaml
+ - vllm_omni/deploy/qwen3_tts_high_concurrency_mrv2_b4.yaml
+
+x-minicpmo-4-5: &minicpmo_4_5
+ - vllm_omni/engine/async_omni_engine.py
+ - vllm_omni/entrypoints/omni_base.py
+ - vllm_omni/model_executor/models/minicpmo_4_5/
+ - vllm_omni/model_executor/models/step_audio2/
+ - vllm_omni/model_executor/models/cosyvoice3/code2wav_core/
+ - vllm_omni/model_executor/models/cosyvoice3/utils.py
+ - vllm_omni/model_executor/stage_input_processors/minicpmo_4_5_omni.py
+ - vllm_omni/deploy/minicpmo_4_5.yaml
+ - vllm_omni/transformers_utils/chat_templates/minicpmo45_native.jinja
+
+x-minicpmo-4-5-duplex: &minicpmo_4_5_duplex
+ - vllm_omni/deploy/minicpmo_4_5_duplex.yaml
+ - vllm_omni/engine/duplex/
+ - vllm_omni/engine/duplex_omni_engine.py
+ - vllm_omni/engine/duplex_orchestrator.py
+ - vllm_omni/engine/omni_engine_base.py
+ - vllm_omni/entrypoints/duplex/
+ - vllm_omni/entrypoints/duplex_omni.py
+ - vllm_omni/entrypoints/async_omni_base.py
+ - vllm_omni/protocol/
+ - vllm_omni/clients/
+ - vllm_omni/outputs/duplex.py
+ - vllm_omni/model_executor/duplex_sampling.py
+ - vllm_omni/experimental/fullduplex/
+ - vllm_omni/core/sched/
+ - vllm_omni/outputs/multimodal_accumulation.py
+ - vllm_omni/engine/stage_init_utils.py
+ - vllm_omni/engine/orchestrator.py
+ - vllm_omni/entrypoints/cli/serve.py
+ - vllm_omni/entrypoints/openai/api_server.py
+ - vllm_omni/entrypoints/openai/duplex_capability.py
+ - vllm_omni/config/stage_config.py
+
+x-qwen2-5-omni: &qwen2_5_omni
+ - vllm_omni/engine/async_omni_engine.py
+ - vllm_omni/entrypoints/omni_base.py
+ - vllm_omni/model_executor/models/qwen2_5_omni/
+ - vllm_omni/model_executor/stage_input_processors/qwen2_5_omni.py
+ - vllm_omni/deploy/qwen2_5_omni.yaml
+
+x-qwen3-omni: &qwen3_omni
+ - vllm_omni/engine/async_omni_engine.py
+ - vllm_omni/entrypoints/omni_base.py
+ - vllm_omni/entrypoints/openai/serving_chat.py
+ - vllm_omni/model_executor/models/qwen3_omni/
+ - vllm_omni/model_executor/models/common/qwen3_code_predictor.py
+ - vllm_omni/model_executor/models/common/snake_activation.py
+ - vllm_omni/model_executor/models/qwen2_5_omni/qwen2_5_omni_thinker.py
+ - vllm_omni/model_executor/models/qwen3_tts/cuda_graph_decoder_wrapper.py
+ - vllm_omni/model_executor/stage_input_processors/qwen3_omni.py
+ - vllm_omni/deploy/qwen3_omni_moe.yaml
+ - vllm_omni/deploy/qwen3_omni_moe_thinking.yaml
+
+x-wan22: &wan22
+ - vllm_omni/diffusion/models/wan2_2/
+ - vllm_omni/diffusion/models/dmd2/
+
+x-cosmos3: &cosmos3
+ - vllm_omni/diffusion/models/cosmos3/
+ - vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl_wan.py
+ - vllm_omni/experimental/world_models/
+ - vllm_omni/model_extras/cosmos3.py
+
+x-qwen-image: &qwen_image
+ - vllm_omni/diffusion/models/qwen_image/
+ - vllm_omni/diffusion/models/dmd2/
+ - vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl_qwenimage.py
+
+x-hunyuan-image3: &hunyuan_image3
+ - vllm_omni/diffusion/models/hunyuan_image3/
+ - vllm_omni/model_executor/models/hunyuan_image3/
+ - vllm_omni/deploy/hunyuan_image3_dit.yaml
+
+x-minimax-h3: &minimax_h3
+ - vllm_omni/diffusion/models/minimax_h3/
+ - vllm_omni/diffusion/layers/indexed_modulation.py
+
+x-hunyuan-video: &hunyuan_video
+ - vllm_omni/diffusion/models/hunyuan_video/
+ - vllm_omni/model_executor/models/hunyuan_video/
+
+x-ltx2: <x2
+ - vllm_omni/diffusion/models/ltx2/
+ - vllm_omni/model_extras/ltx2.py
+
+x-sana-video: &sana_video
+ - vllm_omni/diffusion/models/sana_video/
+ - vllm_omni/model_extras/sana_video.py
+
+x-z-image: &z_image
+ - vllm_omni/diffusion/models/z_image/
+
+x-stable-audio: &stable_audio
+ - vllm_omni/diffusion/models/stable_audio/
+
+x-lingbot-video: &lingbot_video
+ - vllm_omni/diffusion/models/lingbot_video/
+ - vllm_omni/model_extras/lingbot_video.py
+
+x-lingbot-world: &lingbot_world
+ - vllm_omni/diffusion/models/lingbot_world/
+
+
+source_file_dependencies:
+ # Hardware presets and shared test helpers. Not a job key — do not attach this
+ # to a step. Used only when no job-key prefix matches: then a hit keeps every job.
+ source_filter_fallback:
+ - .buildkite/common/ci_mirror_hardwares.yml
+ - tests/helpers/
+
+ # --- features ---
+ diffusion_distributed_attention:
+ - tests/diffusion/distributed/
+ - tests/diffusion/attention/test_attention_sp.py
+ - vllm_omni/diffusion/distributed/
+ - vllm_omni/diffusion/attention/
+ - vllm_omni/diffusion/data.py
+ - vllm_omni/diffusion/models/
+
+ custom_pipeline:
+ - tests/e2e/features/custom_pipeline/
+ - tests/e2e/features/helpers/custom_pipeline.py
+ - vllm_omni/diffusion/
+ - vllm_omni/engine/
+ - vllm_omni/entrypoints/
+ - vllm_omni/inputs/
+ - vllm_omni/outputs/
+
+ rlhf_verl_omni:
+ - tests/e2e/features/rlhf_test/test_verl_omni_e2e.py
+ - tests/e2e/features/helpers/verl_omni_server.py
+ - tests/e2e/features/helpers/custom_pipeline.py
+ - vllm_omni/diffusion/
+ - vllm_omni/engine/
+ - vllm_omni/entrypoints/
+ - vllm_omni/inputs/
+ - vllm_omni/outputs/
+
+ # Tiny-model diffusion jobs (shared across many model trees under models/).
+ diffusion_tiny_model:
+ - tests/model_tests/diffusion/
+ - vllm_omni/diffusion/models/
+
+ diffusion_offloader:
+ - tests/diffusion/offloader/
+ - vllm_omni/diffusion/offloader/
+
+ diffusion_quantization:
+ - tests/diffusion/quantization/
+ - vllm_omni/diffusion/quantization/
+
+ # --- models ---
+ # --- omni / tts jobs ---
+ omni_qwen3_omni_function:
+ - *qwen3_omni
+ - tests/e2e/offline_inference/test_qwen3_omni.py
+ - tests/e2e/offline_inference/test_qwen3_omni_colocate_async.py
+ - tests/e2e/offline_inference/test_qwen3_omni_autoround_w4a16_expansion.py
+ - tests/e2e/offline_inference/test_qwen3_omni_modelopt_nvfp4_w4a4.py
+ - tests/e2e/online_serving/test_qwen3_omni.py
+ - tests/e2e/online_serving/test_qwen3_omni_expansion.py
+ - tests/e2e/online_serving/test_qwen3_omni_multi_replicas.py
+
+ omni_qwen3_omni_accuracy:
+ - *qwen3_omni
+ - tests/e2e/accuracy/qwen3_omni/
+
+ omni_qwen3_omni_perf:
+ - *qwen3_omni
+ - tests/dfx/perf/scripts/run_benchmark.py
+ - tests/dfx/perf/tests/test_qwen3_omni_vllm_text.json
+ - tests/dfx/perf/tests/test_qwen3_omni_async_chunk.json
+ - tests/dfx/perf/tests/test_qwen3_omni_no_async_chunk.json
+ - tests/dfx/perf/tests/test_qwen3_omni_multi_replicas.json
+
+ omni_qwen3_omni_reliability:
+ - *qwen3_omni
+ - tests/dfx/reliability/test_reliability_qwen3_omni.py
+
+ omni_qwen3_omni_doc:
+ - *qwen3_omni
+ - examples/online_serving/qwen3_omni/
+ - examples/online_serving/openai_chat_completion_client_for_multimodal_generation.py
+ - tests/examples/online_serving/test_qwen3_omni.py
+
+ omni_qwen2_5_omni_doc:
+ - *qwen2_5_omni
+ - examples/online_serving/qwen2_5_omni/
+ - examples/online_serving/openai_chat_completion_client_for_multimodal_generation.py
+ - tests/examples/online_serving/test_qwen2_5_omni.py
+
+ omni_minicpmo_4_5_function:
+ - *minicpmo_4_5
+ - tests/e2e/offline_inference/test_minicpmo_4_5.py
+ - tests/e2e/online_serving/test_minicpmo_4_5.py
+ - tests/e2e/online_serving/test_minicpmo_4_5_expansion.py
+
+ omni_minicpmo_4_5_accuracy:
+ - *minicpmo_4_5
+ - tests/e2e/accuracy/minicpmo_4_5/
+
+ omni_minicpmo_4_5_perf:
+ - *minicpmo_4_5
+ - tests/dfx/perf/scripts/run_benchmark.py
+ - tests/dfx/perf/tests/test_minicpmo_4_5_ready.json
+ - tests/dfx/perf/tests/test_minicpmo_4_5.json
+
+ omni_minicpmo_4_5_doc:
+ - *minicpmo_4_5
+ - examples/online_serving/minicpmo/realtime_web/
+ - examples/online_serving/minicpmo/realtime_duplex_demo.py
+ - tests/examples/test_minicpmo_realtime_web_server.py
+ - tests/examples/test_minicpmo_realtime_web_static.py
+ - tests/examples/test_minicpmo_realtime_duplex_simple_demo.py
+
+ omni_minicpmo_4_5_duplex_function:
+ - *minicpmo_4_5
+ - *minicpmo_4_5_duplex
+ - tests/e2e/online_serving/test_minicpmo_4_5_duplex.py
+ - tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py
+ - tests/e2e/online_serving/test_duplex_client_live.py
+ - 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
+ - tests/e2e/online_serving/helpers/minicpmo_4_5_duplex.py
+ - tests/assets/minicpmo_4_5/
+ - tests/clients/
+ - tests/engine/duplex/
+ - tests/engine/test_duplex_omni_engine.py
+ - tests/engine/test_duplex_orchestrator.py
+ - tests/entrypoints/duplex/
+ - tests/entrypoints/test_duplex_omni.py
+ - tests/entrypoints/openai_api/test_duplex_api_server.py
+ - tests/model_executor/models/minicpmo_4_5/duplex/
+ - tests/protocol/
+
+ omni_minicpmo_4_5_duplex_perf:
+ - *minicpmo_4_5
+ - *minicpmo_4_5_duplex
+ - tests/dfx/perf/scripts/run_benchmark.py
+ - tests/dfx/perf/tests/test_minicpmo_4_5_duplex_seed_tts.json
+
+ tts_qwen3_tts_function:
+ - *qwen3_tts
+ - tests/e2e/features/prefill_phase/
+ - tests/e2e/offline_inference/test_qwen3_tts_base.py
+ - tests/e2e/offline_inference/test_qwen3_tts_customvoice.py
+ - tests/e2e/online_serving/test_qwen3_tts_base.py
+ - tests/e2e/online_serving/test_qwen3_tts_base_expansion.py
+ - tests/e2e/online_serving/test_qwen3_tts_customvoice.py
+ - tests/e2e/online_serving/test_qwen3_tts_customvoice_expansion.py
+
+ tts_qwen3_tts_perf:
+ - *qwen3_tts
+ - tests/dfx/perf/scripts/run_benchmark.py
+ - tests/dfx/perf/tests/test_tts.json
+
+ tts_qwen3_tts_cov:
+ - *qwen3_tts
+ - .buildkite/common/scripts/run_cov_split.sh
+ - pyproject.toml
+ - tests/e2e/offline_inference/test_qwen3_tts_base.py
+ - tests/e2e/online_serving/test_qwen3_tts_base.py
+
+ # --- diffusion jobs ---
+ diffusion_wan22_function:
+ - *wan22
+ - tests/e2e/offline_inference/test_wan22_t2v.py
+ - tests/e2e/offline_inference/test_wan22_autoround_w4a16_expansion.py
+ - tests/e2e/offline_inference/test_device_postprocess_equivalence.py
+ - tests/e2e/online_serving/test_wan22_t2v.py
+ - tests/e2e/online_serving/test_wan22_expansion.py
+
+ diffusion_wan22_accuracy:
+ - *wan22
+ - tests/e2e/accuracy/wan22_i2v/
+ - tests/e2e/accuracy/test_diffusers_backend_similarity.py
+
+ diffusion_wan22_perf:
+ - *wan22
+ - tests/dfx/perf/scripts/run_diffusion_benchmark.py
+ - tests/dfx/perf/tests/test_wan22_i2v_vllm_omni.json
+
+ diffusion_wan22_reliability:
+ - *wan22
+ - tests/dfx/reliability/test_reliability_wan22.py
+
+ diffusion_image_to_video_doc:
+ - *wan22
+ - *hunyuan_video
+ - *ltx2
+ - *sana_video
+ - *lingbot_video
+ - examples/offline_inference/image_to_video/
+ - tests/examples/offline_inference/test_image_to_video.py
+
+ diffusion_cosmos3_function:
+ - *cosmos3
+ - tests/e2e/online_serving/test_cosmos3.py
+
+ diffusion_cosmos3_accuracy:
+ - *cosmos3
+ - tests/e2e/accuracy/test_cosmos3_similarity.py
+
+ diffusion_cosmos3_perf:
+ - *cosmos3
+ - tests/dfx/perf/scripts/run_diffusion_benchmark.py
+ - tests/dfx/perf/tests/test_cosmos3_vllm_omni.json
+
+ diffusion_qwen_image_function:
+ - *qwen_image
+ - tests/e2e/online_serving/test_qwen_image.py
+ - tests/e2e/online_serving/test_qwen_image_expansion.py
+ - tests/e2e/offline_inference/test_qwen_image_autoround_w4a16_expansion.py
+
+ diffusion_qwen_image_accuracy:
+ - *qwen_image
+ - tests/e2e/accuracy/test_qwen_image.py
+ - tests/e2e/accuracy/test_diffusers_backend_similarity.py
+
+ diffusion_qwen_image_perf:
+ - *qwen_image
+ - tests/dfx/perf/scripts/run_diffusion_benchmark.py
+ - tests/dfx/perf/tests/test_qwen_image_vllm_omni.json
+
+ diffusion_text_to_image_doc:
+ - *qwen_image
+ - *z_image
+ - examples/offline_inference/text_to_image/
+ - examples/online_serving/text_to_image/
+ - tests/examples/offline_inference/test_text_to_image.py
+ - tests/examples/online_serving/test_text_to_image.py
+ - vllm_omni/diffusion/models/nextstep_1_1/
+ - vllm_omni/diffusion/models/longcat_image/
+ - vllm_omni/diffusion/models/ovis_image/
+ - vllm_omni/diffusion/models/omnigen2/
+ - vllm_omni/diffusion/models/sd3/
+ - vllm_omni/diffusion/models/flux/
+ - vllm_omni/diffusion/models/flux2/
+ - vllm_omni/diffusion/models/flux2_klein/
+ - vllm_omni/diffusion/models/hidream_image/
+ - vllm_omni/diffusion/models/krea2/
+ - vllm_omni/diffusion/models/lingbot_video/
+
+ diffusion_hunyuan_image3_function:
+ - *hunyuan_image3
+ - tests/e2e/offline_inference/test_hunyuanimage3_i2t_expansion.py
+
+ diffusion_hunyuan_image3_accuracy:
+ - *hunyuan_image3
+ - tests/e2e/accuracy/test_hunyuan_image3_pixel_accuracy.py
+
+ diffusion_hunyuan_image3_perf:
+ - *hunyuan_image3
+ - tests/dfx/perf/scripts/run_diffusion_benchmark.py
+ - tests/dfx/perf/tests/test_hunyuan_image_tp2_cfgp2.json
+ - tests/dfx/perf/tests/test_hunyuan_image_tp2_sp2.json
+ - tests/dfx/perf/tests/test_hunyuan_image_tp4.json
+
+ diffusion_hunyuan_image3_reliability:
+ - *hunyuan_image3
+ - tests/dfx/reliability/test_reliability_hunyuan_image.py
+
+ diffusion_minimax_h3_function:
+ - *minimax_h3
+ - tests/e2e/online_serving/test_minimax_h3_dlo_dp2_t2va.py
+ - tests/e2e/online_serving/minimax_h3/
+ - tests/diffusion/models/minimax_h3/
+ - vllm_omni/diffusion/lora/
+ - vllm_omni/diffusion/offloader/
+ - vllm_omni/diffusion/distributed/
+ - vllm_omni/entrypoints/openai/
+
+ diffusion_minimax_h3_accuracy:
+ - *minimax_h3
+ - tests/e2e/accuracy/minimax_h3/
+
+ diffusion_minimax_h3_perf:
+ - *minimax_h3
+ - tests/dfx/perf/scripts/run_diffusion_benchmark.py
+ - tests/dfx/perf/tests/test_minimax_h3_vllm_omni.json
+
+ diffusion_hunyuan_video_function:
+ - *hunyuan_video
+ - tests/e2e/online_serving/test_hunyuan_video_15_expansion.py
+
+ diffusion_hunyuan_video_accuracy:
+ - *hunyuan_video
+ - tests/e2e/accuracy/hunyuanvideo15_t2v/
+ - tests/e2e/accuracy/hunyuanvideo15_i2v/
+
+ diffusion_hunyuan_video_perf:
+ - *hunyuan_video
+ - tests/dfx/perf/scripts/run_diffusion_benchmark.py
+ - tests/dfx/perf/tests/test_hunyuanvideo15_t2v_vllm_omni.json
+ - tests/dfx/perf/tests/test_hunyuanvideo15_i2v_vllm_omni.json
+
+ diffusion_z_image_function:
+ - *z_image
+ - tests/e2e/online_serving/test_z_image.py
+ - tests/e2e/online_serving/test_zimage_expansion.py
+
+ diffusion_sana_video_function:
+ - *sana_video
+ - tests/e2e/online_serving/test_sana_video_expansion.py
+ - tests/e2e/accuracy/sana_video/
+
+ diffusion_stable_audio_function:
+ - *stable_audio
+ - tests/e2e/offline_inference/test_stable_audio_expansion.py
+ - tests/e2e/online_serving/test_stable_audio_online_expansion.py
+
+ diffusion_lingbot_function:
+ - *lingbot_video
+ - *lingbot_world
+ - tests/e2e/online_serving/test_lingbot_video.py
+ - tests/e2e/online_serving/test_lingbot_video_moe.py
+ - tests/e2e/offline_inference/test_lingbot_world_v2.py
+
+
+ # Invalid-param jobs: model business code only, plus the reliability scripts.
+ reliability_invalid_param_h100:
+ - *qwen3_omni
+ - *minicpmo_4_5
+ - *qwen_image
+ - *wan22
+ - tests/dfx/reliability/invalid_param_test/
+
+ reliability_invalid_param_l4:
+ - *qwen3_tts
+ - *stable_audio
+ - tests/dfx/reliability/invalid_param_test/
diff --git a/.buildkite/common/scripts/skip_ci.py b/.buildkite/common/scripts/skip_ci.py
index 8d5fdd5e0..3b4168c01 100644
--- a/.buildkite/common/scripts/skip_ci.py
+++ b/.buildkite/common/scripts/skip_ci.py
@@ -44,6 +44,7 @@ L3_YAML_FILES: dict[str, str] = {
L45_YAML_FILES: dict[str, str] = {
".buildkite/cuda/test-nightly.yml": "cuda",
".buildkite/cuda/test-weekly.yml": "cuda",
+ ".buildkite/amd/test-amd-nightly.yml": "amd",
".buildkite/npu/test-npu-nightly.yml": "npu",
}
diff --git a/.buildkite/common/scripts/upload_pipeline.py b/.buildkite/common/scripts/upload_pipeline.py
index 749f35f5a..71ed75119 100644
--- a/.buildkite/common/scripts/upload_pipeline.py
+++ b/.buildkite/common/scripts/upload_pipeline.py
@@ -10,8 +10,17 @@ Bootstrap mode (``bootstrap-upload-steps.yml``):
- Detect docs-only, pytest skip-mark-only, or combined skip-ci from git diff.
- When only CI level YAML changes, enable **L2/L3** upload steps for affected levels only.
-Test pipeline mode (e.g. test-merge.yml):
- - Drop steps whose ``source_file_dependencies`` do not match changed files.
+Test pipeline mode (e.g. test-merge.yml, test-nightly.yml, test-weekly.yml):
+ - Drop steps whose ``source_file_dependencies`` do not match changed files
+ (string/list keys from ci_source_file_dependencies.yml, or inline path
+ prefixes). Filtering applies on **PR label** uploads and on post-merge
+ ``main`` L3 uploads. Scheduled ``main`` uploads that pass ``--all``
+ (NIGHTLY/WEEKLY) or ``--e2e`` keep every selected job and still strip
+ the field. If no job-key prefix matches, a change to the pipeline YAML
+ being uploaded or to a path under the ``source_filter_fallback`` registry
+ key keeps every job so command, env, and hardware edits can be validated
+ before merge. When any job-key prefix already matches, normal filtering
+ wins.
- Expand uploader-only ``mirror_hardwares`` into ``agents`` (+ optional ``image``
for NPU) + ``plugins`` (see ci_mirror_hardwares.yml).
- Omit ``mirror_hardwares`` to compose ``{chip}_{n}`` from pytest ``-m`` SKU
@@ -87,6 +96,10 @@ BOOTSTRAP_UPLOAD_IF_KEYS = {
}
E2E_GROUP_MARKER = "E2E Test"
CI_MIRROR_HARDWARES_PATH = ROOT / ".buildkite/common/ci_mirror_hardwares.yml"
+CI_SOURCE_FILE_DEPENDENCIES_PATH = ROOT / ".buildkite/common/ci_source_file_dependencies.yml"
+# Registry key whose prefixes bypass source filtering. Paths live in
+# ci_source_file_dependencies.yml; do not attach this key to a job.
+SOURCE_FILTER_FALLBACK_KEY = "source_filter_fallback"
CUDA_HF_TOKEN_ENV = "VLLM_CI_HF_TOKEN"
CUDA_HF_TOKEN_EXPORT = f'if [ -n "$${{{CUDA_HF_TOKEN_ENV}:-}}" ]; then export HF_TOKEN="$${{{CUDA_HF_TOKEN_ENV}}}"; fi'
@@ -95,14 +108,7 @@ CUDA_HF_TOKEN_EXPORT = f'if [ -n "$${{{CUDA_HF_TOKEN_ENV}:-}}" ]; then export HF
# ``*_UPLOAD_IF``: full gate for uploading that child pipeline.
NIGHTLY_MAIN_IF = 'build.branch == "main" && build.env("NIGHTLY") == "1"'
NIGHTLY_LABEL_IF = (
- f"({NIGHTLY_MAIN_IF}) || "
- '(build.branch != "main" && ('
- 'build.pull_request.labels includes "nightly-test" || '
- 'build.pull_request.labels includes "omni-test" || '
- 'build.pull_request.labels includes "tts-test" || '
- 'build.pull_request.labels includes "diffusion-x2iat-test" || '
- 'build.pull_request.labels includes "diffusion-x2v-test"'
- "))"
+ f'({NIGHTLY_MAIN_IF}) || (build.branch != "main" && build.pull_request.labels includes "nightly-test")'
)
WEEKLY_E2E_IF = 'build.branch == "main" && build.env("WEEKLY") == "1"'
WEEKLY_MAIN_IF = 'build.branch == "main" && (build.env("WEEKLY") == "1" || build.env("NON_CRITICAL") == "1")'
@@ -248,7 +254,7 @@ def _render_bootstrap_pipeline(
return yaml.safe_dump(doc, sort_keys=False)
-# --- Test pipeline (test-ready.yml, test-merge.yml) ---
+# --- Test pipeline (test-ready.yml, test-merge.yml, test-nightly.yml) ---
@lru_cache(maxsize=1)
@@ -264,6 +270,117 @@ def _load_mirror_hardwares() -> dict[str, dict[str, Any]]:
return presets
+@lru_cache(maxsize=1)
+def _load_source_file_dependencies() -> dict[str, list[str]]:
+ def flatten(value: Any, *, key: str) -> list[str]:
+ """Flatten YAML-anchor nested lists into a de-duplicated prefix list."""
+ if isinstance(value, str):
+ if not value.strip():
+ raise ValueError(f"empty path in source_file_dependencies[{key!r}]")
+ return [value]
+ if isinstance(value, list):
+ flattened: list[str] = []
+ seen: set[str] = set()
+ for item in value:
+ for path in flatten(item, key=key):
+ if path not in seen:
+ seen.add(path)
+ flattened.append(path)
+ return flattened
+ raise ValueError(
+ f"source_file_dependencies[{key!r}] must be a list of path prefixes, got {type(value).__name__}",
+ )
+
+ if not CI_SOURCE_FILE_DEPENDENCIES_PATH.is_file():
+ raise FileNotFoundError(
+ f"missing CI source_file_dependencies registry: {CI_SOURCE_FILE_DEPENDENCIES_PATH}",
+ )
+ doc = yaml.safe_load(CI_SOURCE_FILE_DEPENDENCIES_PATH.read_text(encoding="utf-8"))
+ if not isinstance(doc, dict):
+ raise ValueError(f"invalid CI source_file_dependencies registry: {CI_SOURCE_FILE_DEPENDENCIES_PATH}")
+ presets = doc.get("source_file_dependencies")
+ if not isinstance(presets, dict):
+ raise ValueError(f"source_file_dependencies must be a mapping in {CI_SOURCE_FILE_DEPENDENCIES_PATH}")
+ loaded: dict[str, list[str]] = {}
+ for key, value in presets.items():
+ if not isinstance(key, str) or not key.strip():
+ raise ValueError(
+ f"source_file_dependencies keys must be non-empty strings in {CI_SOURCE_FILE_DEPENDENCIES_PATH}",
+ )
+ loaded[key] = flatten(value, key=key)
+ return loaded
+
+
+_REGISTRY_KEY_RE = re.compile(r"^[a-z][a-z0-9_]*$")
+
+
+def _parse_command_text(step: dict[str, Any]) -> str:
+ """Parse step ``commands`` / ``command`` into a single string."""
+ raw = step.get("commands", step.get("command"))
+ if raw is None:
+ return ""
+ if isinstance(raw, str):
+ return raw
+ if isinstance(raw, list):
+ return "\n".join(str(item) for item in raw if item is not None)
+ return str(raw)
+
+
+def _resolve_source_file_dependencies(step: dict[str, Any]) -> list[str] | None:
+ """Expand a registry key (or list of keys / inline path prefixes)."""
+ deps = step.get("source_file_dependencies")
+ if deps is None:
+ return None
+
+ def dedupe(*groups: list[str]) -> list[str]:
+ merged: list[str] = []
+ seen: set[str] = set()
+ for group in groups:
+ for path in group:
+ if path not in seen:
+ seen.add(path)
+ merged.append(path)
+ return merged
+
+ def lookup(key: str) -> list[str]:
+ if key == SOURCE_FILTER_FALLBACK_KEY:
+ raise ValueError(
+ f"source_file_dependencies {key!r} bypasses filtering and cannot be attached to a step "
+ f"({_get_step_label(step)!r})",
+ )
+ registry = _load_source_file_dependencies()
+ paths = registry.get(key)
+ if paths is None:
+ known = ", ".join(sorted(registry))
+ raise ValueError(
+ f"unknown source_file_dependencies {key!r} in step {_get_step_label(step)!r}; known: {known}",
+ )
+ return list(paths)
+
+ prefixes: list[str]
+ if isinstance(deps, str):
+ prefixes = lookup(deps) if _REGISTRY_KEY_RE.fullmatch(deps) else [deps]
+ elif isinstance(deps, list):
+ if not all(isinstance(item, str) for item in deps):
+ raise ValueError(
+ f"source_file_dependencies must be a string key or list of strings in step {_get_step_label(step)!r}",
+ )
+ keyish = [bool(_REGISTRY_KEY_RE.fullmatch(item)) for item in deps]
+ if deps and all(keyish):
+ prefixes = dedupe(*(lookup(key) for key in deps))
+ elif any(keyish):
+ raise ValueError(
+ f"source_file_dependencies in step {_get_step_label(step)!r} mixes registry keys and path prefixes",
+ )
+ else:
+ prefixes = list(deps)
+ else:
+ raise ValueError(
+ f"source_file_dependencies must be a string key or list in step {_get_step_label(step)!r}",
+ )
+ return prefixes
+
+
@lru_cache(maxsize=1)
def _cuda_mirror_chips() -> tuple[str, ...]:
"""Lowercase CUDA SKUs that have at least one ``{chip}_{n}`` preset.
@@ -278,7 +395,7 @@ def _cuda_mirror_chips() -> tuple[str, ...]:
return tuple(sorted(chips, key=lambda chip: (-len(chip), chip)))
-def _read_cards_marks(expr: str) -> tuple[set[int], bool]:
+def _parse_cards_marks(expr: str) -> tuple[set[int], bool]:
"""Parse registered ``cards_n`` / ``not cards_n`` from a pytest ``-m`` expr.
Returns positive card counts and whether any ``not cards_*`` is present.
@@ -295,8 +412,8 @@ def _read_cards_marks(expr: str) -> tuple[set[int], bool]:
return positives, has_not_cards
-def _read_hardware_marks(expr: str) -> set[str]:
- """Return lowercase positive CUDA SKUs that have a mirror preset.
+def _parse_hardware_marks(expr: str) -> set[str]:
+ """Parse lowercase positive CUDA SKUs that have a mirror preset.
``not H100`` is ignored. SKUs with no ``{chip}_*`` preset (e.g. H200) are dropped.
"""
@@ -338,36 +455,22 @@ def _get_mirror_hw_selector() -> str:
return selector
-def _read_pytest_marks(commands: Any) -> tuple[set[str], int | Literal["max"] | None]:
- """Read pytest ``-m`` from step commands: CUDA SKU chips and cards count.
+def _parse_pytest_marks(step: dict[str, Any]) -> tuple[set[str], int | Literal["max"] | None]:
+ """Parse pytest ``-m`` from step commands: CUDA SKU chips and cards count.
*chips* are lowercase SKUs from positive markers (``not H100`` is ignored).
*cards* is the max positive ``cards_n``, ``"max"`` when only ``not cards_*``
is present, or ``None``.
"""
- chunks: list[str] = []
-
- def _collect(value: Any) -> None:
- if value is None:
- return
- if isinstance(value, str):
- chunks.append(value)
- elif isinstance(value, list):
- for part in value:
- _collect(part)
- else:
- chunks.append(str(value))
-
- _collect(commands)
- text = "\n".join(chunks)
+ text = _parse_command_text(step)
chips: set[str] = set()
positives: set[int] = set()
has_not_cards = False
for match in _PYTEST_MARKER_ARG.finditer(text):
expr = match.group(1) or match.group(2) or match.group(3) or ""
- chips |= _read_hardware_marks(expr)
- card_counts, not_cards = _read_cards_marks(expr)
+ chips |= _parse_hardware_marks(expr)
+ card_counts, not_cards = _parse_cards_marks(expr)
positives.update(card_counts)
has_not_cards = has_not_cards or not_cards
@@ -484,7 +587,7 @@ def _expand_mirror_hardwares(step: dict[str, Any]) -> dict[str, Any] | None:
if "mirror_hardwares" not in step:
if has_pool:
return step
- chips, cards = _read_pytest_marks(step.get("commands"))
+ chips, cards = _parse_pytest_marks(step)
if not chips and cards is None:
return step
preset_name = _compose_mirror_hardware_name(chips, cards, step_label=step_label)
@@ -552,11 +655,7 @@ def _process_test_steps(
processed.append(step)
continue
- deps = step.get("source_file_dependencies")
- if deps is not None and not isinstance(deps, list):
- raise ValueError(
- f"source_file_dependencies must be a list in step {_get_step_label(step)!r}",
- )
+ deps = _resolve_source_file_dependencies(step)
if changed_files is not None and deps is not None and not _match_source_file(changed_files, deps):
_log(f"skip {_get_step_label(step)!r} (no changes under {deps})")
continue
@@ -595,10 +694,25 @@ def _select_e2e_group_steps(steps: list[Any]) -> list[Any]:
return selected
+def _any_source_dependency_match(steps: list[Any], changed_files: list[str]) -> bool:
+ """True when any step with ``source_file_dependencies`` matches *changed_files*."""
+ for step in steps:
+ if not isinstance(step, dict):
+ continue
+ deps = _resolve_source_file_dependencies(step)
+ if deps is not None and _match_source_file(changed_files, deps):
+ return True
+ nested = step.get("steps")
+ if isinstance(nested, list) and _any_source_dependency_match(nested, changed_files):
+ return True
+ return False
+
+
def _render_test_pipeline(
doc: dict[str, Any],
changed_files: list[str] | None,
*,
+ pipeline_path: Path | None = None,
e2e_only: bool = False,
) -> dict[str, Any]:
"""Filter steps by PR diff and strip uploader-only ``source_file_dependencies`` metadata."""
@@ -611,6 +725,13 @@ def _render_test_pipeline(
return doc
if e2e_only:
steps = _select_e2e_group_steps(steps)
+ # Bypass is a fallback: only when no job-key prefix matched.
+ if changed_files is not None and pipeline_path is not None:
+ if not _any_source_dependency_match(steps, changed_files):
+ bypass = _source_filter_fallback_reason(changed_files, pipeline_path)
+ if bypass is not None:
+ _log(f"keep all jobs (no source_file_dependencies match; bypassed by {bypass})")
+ changed_files = None
steps = _process_test_steps(steps, changed_files)
return {**doc, "steps": steps}
@@ -618,6 +739,60 @@ def _render_test_pipeline(
# --- Entry (read file → bootstrap or test render → YAML string) ---
+def _repo_relative_posix(path: Path) -> str:
+ resolved = path.resolve()
+ try:
+ return resolved.relative_to(ROOT).as_posix()
+ except ValueError:
+ return path.as_posix().replace("\\", "/")
+
+
+def _source_filter_fallback_prefixes() -> list[str]:
+ """Shared uploader/registry prefixes from ``source_filter_fallback``. Not a job key."""
+ registry = _load_source_file_dependencies()
+ prefixes = registry.get(SOURCE_FILTER_FALLBACK_KEY)
+ if not prefixes:
+ raise ValueError(
+ f"source_file_dependencies[{SOURCE_FILTER_FALLBACK_KEY!r}] must list shared uploader paths "
+ f"in {CI_SOURCE_FILE_DEPENDENCIES_PATH}",
+ )
+ return prefixes
+
+
+def _source_filter_fallback_reason(changed_files: list[str] | None, pipeline_path: Path) -> str | None:
+ """Return the changed path that should disable source filtering, if any."""
+ if not changed_files:
+ return None
+ changed = set(changed_files)
+ pipeline_rel = _repo_relative_posix(pipeline_path)
+ if pipeline_rel in changed:
+ return pipeline_rel
+ prefixes = _source_filter_fallback_prefixes()
+ for path in changed_files:
+ if _match_source_file([path], prefixes):
+ return path
+ return None
+
+
+def _changed_files_for_source_filter(
+ ctx,
+ *,
+ force_all: bool,
+ e2e_only: bool,
+) -> list[str] | None:
+ """Return the diff to filter against, or None to keep every step.
+
+ ``--all`` / ``--e2e`` disable filtering (scheduled NIGHTLY/WEEKLY full
+ uploads and weekly E2E sweeps). Post-merge ``main`` L3 and PR-label
+ uploads filter against the commit/PR diff. Pipeline YAML /
+ ``source_filter_fallback`` matches are applied later in
+ ``_render_test_pipeline`` only when no job-key prefix already matched.
+ """
+ if force_all or e2e_only:
+ return None
+ return ctx.changed_files
+
+
def _render_pipeline(
path: Path,
*,
@@ -635,13 +810,18 @@ def _render_pipeline(
text = path.read_text(encoding="utf-8")
ctx = resolve_ci_context_from_git()
- changed_files = None if force_all or e2e_only else ctx.changed_files
+ changed_files = _changed_files_for_source_filter(ctx, force_all=force_all, e2e_only=e2e_only)
doc = yaml.safe_load(text)
if not isinstance(doc, dict):
raise ValueError(f"invalid pipeline YAML: {path}")
- doc = _render_test_pipeline(doc, changed_files, e2e_only=e2e_only)
+ doc = _render_test_pipeline(
+ doc,
+ changed_files,
+ pipeline_path=path,
+ e2e_only=e2e_only,
+ )
return yaml.safe_dump(doc, sort_keys=False)
diff --git a/.buildkite/cuda/bootstrap-upload-steps.yml b/.buildkite/cuda/bootstrap-upload-steps.yml
index ab4254feb..3f5aa1cb1 100644
--- a/.buildkite/cuda/bootstrap-upload-steps.yml
+++ b/.buildkite/cuda/bootstrap-upload-steps.yml
@@ -22,7 +22,8 @@ steps:
agents:
queue: "cpu_queue_premerge"
- # L3 Test — PR merge-test label (diff-aware) or main+WEEKLY=1 (--e2e, full E2E group)
+ # L3 Test — PR merge-test label or post-merge on main (both diff-aware);
+ # main+WEEKLY=1 uploads the E2E group only (--e2e).
- label: "Upload Merge Pipeline"
depends_on: image-build
key: upload-merge-pipeline
@@ -36,20 +37,30 @@ steps:
agents:
queue: "cpu_queue_premerge"
- # L4 Test — main+NIGHTLY=1 (scheduled), or PR with specific label (e.g. add label then Rebuild)
+ # L4 Test — main+NIGHTLY=1 (full pipeline), or PR nightly-test label (diff-aware)
- label: "Upload Nightly Pipeline"
depends_on: image-build
key: upload-nightly-pipeline
commands:
- - python3 .buildkite/common/scripts/upload_pipeline.py --upload .buildkite/cuda/test-nightly.yml
+ - |
+ EXTRA=""
+ if [ "${NIGHTLY:-}" = "1" ] && [ "${BUILDKITE_BRANCH}" = "main" ]; then
+ EXTRA="--all"
+ fi
+ python3 .buildkite/common/scripts/upload_pipeline.py --upload $$EXTRA .buildkite/cuda/test-nightly.yml
agents:
queue: "cpu_queue_premerge"
- # L5 Test — main+WEEKLY=1 / NON_CRITICAL=1 (scheduled), or PR with weekly-test label
+ # L5 Test — main+WEEKLY=1 / NON_CRITICAL=1 (full pipeline), or PR weekly-test label (diff-aware)
- label: "Upload Weekly Pipeline"
depends_on: image-build
key: upload-weekly-pipeline
commands:
- - python3 .buildkite/common/scripts/upload_pipeline.py --upload .buildkite/cuda/test-weekly.yml
+ - |
+ EXTRA=""
+ if [ "${BUILDKITE_BRANCH}" = "main" ]; then
+ EXTRA="--all"
+ fi
+ python3 .buildkite/common/scripts/upload_pipeline.py --upload $$EXTRA .buildkite/cuda/test-weekly.yml
agents:
queue: "cpu_queue_premerge"
diff --git a/.buildkite/cuda/test-merge.yml b/.buildkite/cuda/test-merge.yml
index 71eabf527..cf0af0d31 100644
--- a/.buildkite/cuda/test-merge.yml
+++ b/.buildkite/cuda/test-merge.yml
@@ -1,5 +1,6 @@
# CUDA CI L3 (`advanced_model` e2e) plus L1 CPU Simple Test (`core_model and cpu`).
-# Uploaded by Upload Merge Pipeline: PR label `merge-test`, or post-merge on main.
+# Uploaded by Upload Merge Pipeline: PR label `merge-test`, or post-merge on main
+# (both diff-aware via source_file_dependencies).
env:
VLLM_WORKER_MULTIPROC_METHOD: spawn
@@ -65,7 +66,9 @@ steps:
# 0.26 startup regression is understood and fixed.
timeout_in_minutes: 90
commands:
- - pytest -s -v tests/entrypoints/ -m "advanced_model and cuda and H100" --run-level "advanced_model"
+ # Turn-based server VAD over /v1/realtime is deferred to RFC #7181 PR 5
+ # (see the P0 thread on #7413); the test stays in the tree and runs again there.
+ - pytest -s -v tests/entrypoints/ -m "advanced_model and cuda and H100" --run-level "advanced_model" --deselect tests/entrypoints/openai_api/test_qwen3_omni_realtime_websocket.py::TestQwen3OmniRealtimeWebSocket::test_server_vad_multi_turn_without_client_commit
mirror_hardwares: h100_2
- label: "Entrypoint Test with L4"
@@ -78,21 +81,7 @@ steps:
depends_on: upload-merge-pipeline
steps:
- label: "TTS · Qwen3-TTS CustomVoice Test"
- source_file_dependencies:
- - tests/e2e/features/prefill_phase/
- - vllm_omni/worker/gpu_model_runner.py
- - tests/helpers/media.py
- - tests/e2e/online_serving/test_qwen3_tts_customvoice.py
- - tests/e2e/offline_inference/test_qwen3_tts_customvoice.py
- - vllm_omni/model_executor/models/qwen3_tts/
- - vllm_omni/model_executor/models/common/qwen3_code_predictor.py
- - vllm_omni/model_executor/models/common/snake_activation.py
- - vllm_omni/model_executor/models/common/alias_free_activation.py
- - vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
- - vllm_omni/deploy/qwen3_tts.yaml
- - vllm_omni/entrypoints/openai/serving_speech.py
- - vllm_omni/entrypoints/openai/tts_adapters/
- - vllm_omni/engine/stage_pool.py
+ source_file_dependencies: tts_qwen3_tts_function
commands:
- |
timeout 40m bash -c "
@@ -103,21 +92,7 @@ steps:
mirror_hardwares: l4_1
- label: "TTS · Qwen3-TTS Base Test"
- source_file_dependencies:
- - tests/helpers/media.py
- - tests/e2e/online_serving/test_qwen3_tts_base.py
- - tests/e2e/offline_inference/test_qwen3_tts_base.py
- - vllm_omni/model_executor/models/qwen3_tts/
- - vllm_omni/model_executor/models/common/qwen3_code_predictor.py
- - vllm_omni/model_executor/models/common/snake_activation.py
- - vllm_omni/model_executor/models/common/alias_free_activation.py
- - vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
- - vllm_omni/deploy/qwen3_tts.yaml
- - vllm_omni/entrypoints/openai/serving_speech.py
- - vllm_omni/entrypoints/openai/tts_adapters/
- - vllm_omni/engine/stage_pool.py
- - .buildkite/common/scripts/run_cov_split.sh
- - pyproject.toml
+ source_file_dependencies: tts_qwen3_tts_cov
# 40 -> 60: the coverage split runs the model twice.
timeout_in_minutes: 60
commands:
@@ -132,36 +107,14 @@ steps:
mirror_hardwares: l4_1
- label: "Omni · Qwen3-Omni Test"
- source_file_dependencies:
- - tests/e2e/offline_inference/test_qwen3_omni.py
- - tests/e2e/offline_inference/test_qwen3_omni_colocate_async.py
- - tests/e2e/online_serving/test_qwen3_omni.py
- - tests/helpers/runtime.py
- - vllm_omni/entrypoints/openai/serving_chat.py
- - vllm_omni/model_executor/models/qwen3_omni/
- - vllm_omni/model_executor/models/common/qwen3_code_predictor.py
- - vllm_omni/model_executor/models/common/snake_activation.py
- - vllm_omni/model_executor/models/qwen2_5_omni/qwen2_5_omni_thinker.py
- - vllm_omni/model_executor/models/qwen3_tts/cuda_graph_decoder_wrapper.py
- - vllm_omni/model_executor/stage_input_processors/qwen3_omni.py
- - vllm_omni/deploy/qwen3_omni_moe.yaml
- - vllm_omni/deploy/qwen3_omni_moe_thinking.yaml
+ source_file_dependencies: omni_qwen3_omni_function
timeout_in_minutes: 50
commands:
- pytest -s -v tests/e2e/offline_inference/test_qwen3_omni.py tests/e2e/online_serving/test_qwen3_omni.py tests/e2e/offline_inference/test_qwen3_omni_colocate_async.py -m 'advanced_model and cuda' --run-level 'advanced_model'
mirror_hardwares: h100_2
- label: "Omni · MiniCPM-o 4.5 Test"
- source_file_dependencies:
- - tests/e2e/offline_inference/test_minicpmo_4_5.py
- - tests/e2e/online_serving/test_minicpmo_4_5.py
- - vllm_omni/transformers_utils/chat_templates/minicpmo45_native.jinja
- - vllm_omni/model_executor/models/minicpmo_4_5/
- - vllm_omni/model_executor/models/step_audio2/
- - vllm_omni/model_executor/models/cosyvoice3/code2wav_core/
- - vllm_omni/model_executor/models/cosyvoice3/utils.py
- - vllm_omni/model_executor/stage_input_processors/minicpmo_4_5_omni.py
- - vllm_omni/deploy/minicpmo_4_5.yaml
+ source_file_dependencies: omni_minicpmo_4_5_function
timeout_in_minutes: 50
commands:
- pytest -s -v tests/e2e/offline_inference/test_minicpmo_4_5.py tests/e2e/online_serving/test_minicpmo_4_5.py -m 'advanced_model and cuda' --run-level 'advanced_model'
@@ -174,48 +127,7 @@ steps:
mirror_hardwares: h100_2
- label: "Omni · MiniCPM-o 4.5 Duplex Test"
- source_file_dependencies:
- - tests/assets/minicpmo_4_5/response_required_16k.wav
- - tests/assets/minicpmo_4_5/soft_interrupt_16k.wav
- - tests/e2e/online_serving/test_minicpmo_4_5_duplex.py
- - tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py
- - 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
- - vllm_omni/engine/duplex/
- - vllm_omni/entrypoints/duplex/
- # Public duplex client library + its unit suites: client-side
- # changes must re-run the duplex e2e group. The live client probe
- # (test_duplex_client_live.py, single GPU, core tier) runs in the
- # ready pipeline's duplex group.
- - vllm_omni/clients/
- - tests/clients/
- - vllm_omni/entrypoints/duplex_request_client.py
- - vllm_omni/outputs/duplex.py
- - vllm_omni/model_executor/duplex_sampling.py
- - tests/engine/duplex/
- - tests/entrypoints/duplex/
- - tests/model_executor/models/minicpmo_4_5/duplex/
- # Legacy Realtime probe client + video stacking used by the e2e drivers.
- - vllm_omni/experimental/fullduplex/
- - vllm_omni/model_executor/models/minicpmo_4_5/
- - vllm_omni/model_executor/models/step_audio2/
- - vllm_omni/model_executor/models/cosyvoice3/code2wav_core/
- - vllm_omni/model_executor/models/cosyvoice3/utils.py
- - vllm_omni/model_executor/stage_input_processors/minicpmo_4_5_omni.py
- - vllm_omni/core/sched/
- - vllm_omni/outputs/multimodal_accumulation.py
- - vllm_omni/engine/stage_init_utils.py
- - vllm_omni/engine/async_omni_engine.py
- - vllm_omni/engine/orchestrator.py
- - vllm_omni/entrypoints/cli/serve.py
- - vllm_omni/entrypoints/openai/api_server.py
- - vllm_omni/entrypoints/openai/duplex_capability.py
- - vllm_omni/config/stage_config.py
+ source_file_dependencies: omni_minicpmo_4_5_duplex_function
timeout_in_minutes: 50
commands:
- pytest -s -v tests/e2e/online_serving/test_minicpmo_4_5_duplex.py -m 'advanced_model and cuda' --run-level 'advanced_model'
@@ -234,11 +146,7 @@ steps:
mirror_hardwares: h100_1
- label: "Diffusion · Qwen Image Test"
- source_file_dependencies:
- - tests/e2e/online_serving/test_qwen_image.py
- - vllm_omni/diffusion/models/qwen_image/
- - vllm_omni/diffusion/models/dmd2/
- - vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl_qwenimage.py
+ source_file_dependencies: diffusion_qwen_image_function
commands:
- |
timeout 40m bash -c '
@@ -248,11 +156,7 @@ steps:
mirror_hardwares: h100_1
- label: "Diffusion · Wan22 Test"
- source_file_dependencies:
- - tests/e2e/offline_inference/test_wan22_t2v.py
- - tests/e2e/online_serving/test_wan22_t2v.py
- - vllm_omni/diffusion/models/wan2_2/
- - vllm_omni/diffusion/models/dmd2/
+ source_file_dependencies: diffusion_wan22_function
commands:
- |
timeout 40m bash -c "
@@ -262,9 +166,7 @@ steps:
mirror_hardwares: h100_1
- label: "Diffusion · HunyuanVideo-1.5 Test"
- source_file_dependencies:
- - tests/e2e/online_serving/test_hunyuan_video_15_expansion.py
- - vllm_omni/diffusion/models/hunyuan_video/
+ source_file_dependencies: diffusion_hunyuan_video_function
commands:
- |
timeout 40m bash -c "
@@ -274,17 +176,7 @@ steps:
mirror_hardwares: h100_1
- label: "Diffusion · MiniMax-H3 L3 DLO DP2 and FastH3"
- source_file_dependencies:
- - tests/e2e/online_serving/minimax_h3/
- - tests/helpers/assertions.py
- - tests/helpers/mark.py
- - tests/helpers/media.py
- - tests/helpers/runtime.py
- - vllm_omni/diffusion/models/minimax_h3/
- - vllm_omni/diffusion/lora/
- - vllm_omni/diffusion/offloader/
- - vllm_omni/diffusion/distributed/
- - vllm_omni/entrypoints/openai/
+ source_file_dependencies: diffusion_minimax_h3_function
# Keep four server lifecycles in one Buildkite job, but never in one
# pytest process. DLO uses multiprocessing shared-memory queues; the
# parent's resource_tracker can retain a stale /psm_* entry during
diff --git a/.buildkite/cuda/test-nightly.yml b/.buildkite/cuda/test-nightly.yml
index c85e9abc1..adc94f9e9 100644
--- a/.buildkite/cuda/test-nightly.yml
+++ b/.buildkite/cuda/test-nightly.yml
@@ -12,138 +12,145 @@ steps:
- group: ":card_index_dividers: Omni Model Test"
key: nightly-omni-test-group
depends_on: upload-nightly-pipeline
- if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test" || build.pull_request.labels includes "omni-test"
+ if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test"
steps:
- label: ":full_moon: Omni · Function Test with H100 · Single-GPU"
+ source_file_dependencies:
+ - omni_qwen3_omni_function
+ - omni_minicpmo_4_5_function
timeout_in_minutes: 120
commands:
- pytest -sv tests/e2e --run-level "full_model" -m "full_model and H100 and B200 and omni and cards_1" --ignore=tests/e2e/accuracy --ignore=tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py
- label: ":full_moon: Omni · Function Test with H100 · 2-GPU"
+ source_file_dependencies:
+ - omni_qwen3_omni_function
+ - omni_minicpmo_4_5_function
timeout_in_minutes: 90
commands:
- pytest -sv tests/e2e --run-level "full_model" -m "full_model and H100 and B200 and omni and cards_2" --ignore=tests/e2e/accuracy --ignore=tests/e2e/online_serving/test_qwen3_omni_multi_replicas.py
- label: ":full_moon: Omni · MiniCPM-o 4.5 Duplex Test"
+ source_file_dependencies: omni_minicpmo_4_5_duplex_function
timeout_in_minutes: 50
commands:
- - pytest -s -v tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py -m "full_model and cuda and H100 and omni and cards_1" --run-level "full_model"
- mirror_hardwares: h100_1
+ - pytest -s -v tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py -m "full_model and cuda and H100 and B200 and omni and cards_1" --run-level "full_model"
- label: ":full_moon: Omni · Doc Test with L4 · 4-GPU"
+ source_file_dependencies: omni_qwen2_5_omni_doc
timeout_in_minutes: 90
commands:
- export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
- - pytest -s -v tests/examples/ -m "full_model and omni and L4 and cards_4" --run-level "full_model"
- mirror_hardwares: l4_4
+ - pytest -s -v tests/examples/ -m "full_model and omni and L4 and B200 and cards_4" --run-level "full_model"
- label: ":full_moon: Omni · Doc Test with H100 · 2-GPU"
+ source_file_dependencies: omni_qwen3_omni_doc
timeout_in_minutes: 90
commands:
- - pytest -s -v tests/examples/ -m "full_model and omni and H100 and cards_2" --run-level "full_model"
- mirror_hardwares: h100_2
+ - pytest -s -v tests/examples/ -m "full_model and omni and H100 and B200 and cards_2" --run-level "full_model"
- label: ":full_moon: Omni · Accuracy Test"
+ source_file_dependencies: omni_qwen3_omni_accuracy
timeout_in_minutes: 180
commands:
- export SEED_TTS_WER_EVAL=1
- export SEED_TTS_EVAL_DEVICE=cuda:1
- |
set +e
- pytest -s -v tests/e2e/accuracy/qwen3_omni/test_qwen3_omni.py -m "full_model" --run-level full_model
+ pytest -s -v tests/e2e/accuracy/qwen3_omni/test_qwen3_omni.py -m "full_model and H100 and B200 and cards_2" --run-level full_model
EXIT=$$?
buildkite-agent artifact upload "tests/e2e/accuracy/qwen3_omni/results/qwen_omni_acc/*.json"
exit $$EXIT
- mirror_hardwares: h100_2
- label: ":full_moon: Omni · MiniCPM-o 4.5 · Accuracy Test"
+ source_file_dependencies: omni_minicpmo_4_5_accuracy
timeout_in_minutes: 180
commands:
- export SEED_TTS_WER_EVAL=1
- export SEED_TTS_EVAL_DEVICE=cuda:1
- |
set +e
- pytest -s -v tests/e2e/accuracy/minicpmo_4_5/test_minicpmo_4_5.py -m "full_model and cuda" --run-level full_model
+ pytest -s -v tests/e2e/accuracy/minicpmo_4_5/test_minicpmo_4_5.py -m "full_model and H100 and B200 and cuda and (cards_1 or cards_2)" --run-level full_model
EXIT=$$?
buildkite-agent artifact upload "tests/e2e/accuracy/minicpmo_4_5/results/*.json"
exit $$EXIT
- mirror_hardwares: h100_2
- label: ":full_moon: Omni · Perf Test · No Async Chunk"
+ source_file_dependencies: omni_qwen3_omni_perf
key: nightly-omni-performance-no-async-chunk
timeout_in_minutes: 300
commands:
- export BENCHMARK_DIR=tests/dfx/perf/results
- |
set +e
- pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen3_omni_no_async_chunk.json
+ pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen3_omni_no_async_chunk.json -m "H100 and B200 and cards_2"
EXIT=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
exit $$EXIT
- mirror_hardwares: h100_2
- label: ":full_moon: Omni · Perf Test · Async Chunk"
+ source_file_dependencies: omni_qwen3_omni_perf
key: nightly-omni-performance-async-chunk
timeout_in_minutes: 180
commands:
- export BENCHMARK_DIR=tests/dfx/perf/results
- |
set +e
- pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen3_omni_async_chunk.json -m "H100 and full_model"
+ pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen3_omni_async_chunk.json -m "H100 and B200 and full_model and cards_2"
EXIT=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
exit $$EXIT
- mirror_hardwares: h100_2
- label: ":full_moon: Omni · MiniCPM-o 4.5 · Perf Test"
+ source_file_dependencies: omni_minicpmo_4_5_perf
key: nightly-omni-performance-minicpmo-4-5
timeout_in_minutes: 180
commands:
- export BENCHMARK_DIR=tests/dfx/perf/results
- |
set +e
- pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_minicpmo_4_5.json
+ pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_minicpmo_4_5.json -m "H100 and B200 and cards_1"
EXIT=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
exit $$EXIT
- mirror_hardwares: h100_1
- label: ":full_moon: Omni · MiniCPM-o 4.5 · Duplex Seed-TTS Perf Test"
+ source_file_dependencies: omni_minicpmo_4_5_duplex_perf
key: nightly-omni-performance-minicpmo-4-5-duplex-seed-tts
timeout_in_minutes: 180
commands:
- export BENCHMARK_DIR=tests/dfx/perf/results
- |
set +e
- pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_minicpmo_4_5_duplex_seed_tts.json
+ pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_minicpmo_4_5_duplex_seed_tts.json -m "H100 and B200 and cards_1"
EXIT=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
exit $$EXIT
- mirror_hardwares: h100_1
- label: ":full_moon: Omni · Multi-Replica Startup Test with 4x H100"
+ source_file_dependencies: omni_qwen3_omni_function
timeout_in_minutes: 45
commands:
- - pytest -s -v tests/e2e/online_serving/test_qwen3_omni_multi_replicas.py -m "full_model" --run-level "core_model"
- mirror_hardwares: h100_4
+ - pytest -s -v tests/e2e/online_serving/test_qwen3_omni_multi_replicas.py -m "full_model and H100 and B200 and cards_4" --run-level "core_model"
- group: ":card_index_dividers: TTS Model Test"
key: nightly-tts-test-group
depends_on: upload-nightly-pipeline
- if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test" || build.pull_request.labels includes "tts-test"
+ if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test"
steps:
- label: ":full_moon: TTS · Function Test with L4"
+ source_file_dependencies: tts_qwen3_tts_function
timeout_in_minutes: 120
commands:
- export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
- export VLLM_USE_DEEP_GEMM="0"
- export VLLM_MOE_USE_DEEP_GEMM="0"
- - pytest -s -v tests/e2e/ -m "full_model and L4 and tts" --run-level "full_model" --ignore=tests/e2e/accuracy
- mirror_hardwares: l4_1
+ - pytest -s -v tests/e2e/ -m "full_model and L4 and B200 and tts and cards_1" --run-level "full_model" --ignore=tests/e2e/accuracy
- label: ":full_moon: TTS · Perf Test · Single-GPU"
+ source_file_dependencies: tts_qwen3_tts_perf
key: nightly-tts-performance-single
timeout_in_minutes: 180
commands:
@@ -151,13 +158,13 @@ steps:
- export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
- |
set +e
- pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_tts.json -m "H100 and cards_1"
+ pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_tts.json -m "H100 and B200 and cards_1"
EXIT=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
exit $$EXIT
- mirror_hardwares: h100_1
- label: ":full_moon: TTS · Perf Test · 2-GPU"
+ source_file_dependencies: tts_qwen3_tts_perf
key: nightly-tts-performance-2gpu
timeout_in_minutes: 180
commands:
@@ -165,11 +172,10 @@ steps:
- export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
- |
set +e
- pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_tts.json -m "H100 and cards_2"
+ pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_tts.json -m "H100 and B200 and cards_2"
EXIT=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
exit $$EXIT
- mirror_hardwares: h100_2
- group: ":card_index_dividers: Tiny Model Tests [Multi-GPU]"
key: nightly-tiny-model-test-group
@@ -180,6 +186,7 @@ steps:
steps:
- label: ":full_moon: Tiny Model Tests · Diffusion (Multi-GPU accelerations) · 2-GPU"
+ source_file_dependencies: diffusion_tiny_model
timeout_in_minutes: 30
# NOTE: Single-GPU (core_model) tiny model tests run on the ready label,
# so nightly only adds the multi-GPU parallelism configurations.
@@ -189,91 +196,85 @@ steps:
# for the CI, since these tests should pass irrespective of whether or not we use
# the full weights or not.
commands:
- - pytest -sv tests/model_tests/diffusion -m 'full_model and cuda and cards_2' --run-level "core_model"
- mirror_hardwares: l4_2
+ - pytest -sv tests/model_tests/diffusion -m 'full_model and L4 and B200 and cuda and cards_2' --run-level "core_model"
- label: ":full_moon: Tiny Model Tests · Diffusion (Multi-GPU accelerations) · 4-GPU"
+ source_file_dependencies: diffusion_tiny_model
timeout_in_minutes: 30
# CFG+TP extra_test_groups request 2×2=4 devices (see get_required_device_count).
commands:
- - pytest -sv tests/model_tests/diffusion -m 'full_model and cuda and cards_4' --run-level "core_model"
- mirror_hardwares: l4_4
+ - pytest -sv tests/model_tests/diffusion -m 'full_model and L4 and B200 and cuda and cards_4' --run-level "core_model"
- group: ":card_index_dividers: Diffusion X2I(&A&T) Model Test"
key: nightly-diffusion-x2iat-group
depends_on: upload-nightly-pipeline
if: >-
build.env("NIGHTLY") == "1" ||
- build.pull_request.labels includes "nightly-test" ||
- build.pull_request.labels includes "diffusion-x2iat-test"
+ build.pull_request.labels includes "nightly-test"
steps:
- label: ":full_moon: Diffusion X2I(&A&T) · Function Test with H100 · Single-GPU"
+ source_file_dependencies: diffusion_qwen_image_function
timeout_in_minutes: 120
commands:
- >-
pytest -sv
tests/e2e/online_serving/test_qwen_image_expansion.py
+ -m "full_model and diffusion and H100 and B200 and cards_1"
+ --run-level "full_model"
+
+ - label: ":robot_face: Robot Policy OpenPI · π0.5 · H100 · Single-GPU"
+ timeout_in_minutes: 120
+ commands:
+ - pip install --no-deps "openpi-client @ git+https://github.com/Physical-Intelligence/openpi.git@215abfb217dbac7d5f1273282331b9b1866c0479#subdirectory=packages/openpi-client"
+ - >-
+ pytest -sv
+ tests/e2e/online_serving/test_pi05_expansion.py
-m "full_model and diffusion and H100 and cards_1"
--run-level "full_model"
mirror_hardwares: h100_1
- label: ":full_moon: Diffusion X2I(&A&T) · Function Test with H100 · 2-GPU"
+ source_file_dependencies: diffusion_qwen_image_function
timeout_in_minutes: 120
commands:
- >-
pytest -sv
tests/e2e/online_serving/test_qwen_image_expansion.py
- -m "full_model and diffusion and H100 and cards_2"
+ -m "full_model and diffusion and H100 and B200 and cards_2"
--run-level "full_model"
- mirror_hardwares: h100_2
- - label: ":full_moon: Diffusion X2I(&A&T) · Boogu Edit CFG · 3-GPU"
+ - label: ":full_moon: Diffusion X2I(&A&T) · Function Test with L4 · Single-GPU"
+ source_file_dependencies: diffusion_qwen_image_function
timeout_in_minutes: 120
commands:
- - >-
- pytest -sv
- tests/e2e/online_serving/test_boogu_image_edit_expansion.py
- -k "test_double_guidance_cfg_parallel and cfg_parallel_3_full"
- -m "slow and diffusion and cuda and H100 and cards_3"
- --run-level "full_model"
- mirror_hardwares: h100_3
+ - pytest -sv tests/e2e/offline_inference/test_qwen_image_autoround_w4a16_expansion.py -m "full_model and diffusion and L4 and B200 and cards_1" --run-level "full_model"
- - label: ":full_moon: Diffusion X2I(&A&T) · Function Test with L4 · 4-GPU"
- timeout_in_minutes: 120
- commands:
- - pytest -sv tests/e2e/ -k "not test_wan and not hunyuan" -m "full_model and diffusion and L4 and cards_4" --run-level "full_model" --ignore=tests/e2e/accuracy
- mirror_hardwares: l4_4
- label: ":full_moon: Diffusion X2I(&A&T) · Doc Test"
+ source_file_dependencies: diffusion_text_to_image_doc
timeout_in_minutes: 60
commands:
- - pytest -s -v tests/examples/*/test_text_to_image.py -m "full_model and example and H100" --run-level "full_model"
- mirror_hardwares: h100_2
+ - pytest -s -v tests/examples/*/test_text_to_image.py -m "full_model and example and H100 and B200 and (cards_1 or cards_2)" --run-level "full_model"
- label: ":full_moon: Diffusion X2I(&A&T) · Accuracy Test"
+ source_file_dependencies: diffusion_qwen_image_accuracy
timeout_in_minutes: 180
commands:
- export VLLM_HTTP_TIMEOUT_KEEP_ALIVE=120
- - pytest -s -v tests/e2e/accuracy/test_qwen_image.py --run-level full_model
- - pytest -s -v tests/e2e/accuracy/test_diffusers_backend_similarity.py -k '2i_matches_diffusers' -m full_model --run-level full_model
- mirror_hardwares: h100_1
+ - pytest -s -v tests/e2e/accuracy/test_qwen_image.py -m "H100 and B200 and cards_1" --run-level full_model
+ - pytest -s -v tests/e2e/accuracy/test_diffusers_backend_similarity.py -k '2i_matches_diffusers' -m "full_model and H100 and B200 and cards_1" --run-level full_model
- label: ":full_moon: HunyuanImage3-DIT · Accuracy Test"
+ source_file_dependencies: diffusion_hunyuan_image3_accuracy
timeout_in_minutes: 180
commands:
- export HUNYUAN_IMAGE3_MODEL="tencent/HunyuanImage-3.0-Instruct"
- export HUNYUAN_IMAGE3_DEVICES="0,1,2,3"
- - pytest -s -v tests/e2e/accuracy/test_hunyuan_image3_pixel_accuracy.py -m full_model --run-level full_model
- mirror_hardwares: h100_4
-
- - label: ":full_moon: HiDream-O1-Image · Accuracy Test"
- timeout_in_minutes: 180
- commands:
- - pytest -s -v tests/e2e/accuracy/test_hidream_o1_image.py -m full_model --run-level full_model
- mirror_hardwares: h100_2
+ - pytest -s -v tests/e2e/accuracy/test_hunyuan_image3_pixel_accuracy.py -m "full_model and H100 and B200 and cards_4" --run-level full_model
- label: ":full_moon: Diffusion X2I(&A&T) · Perf Test · Qwen-Image · Single-GPU"
+ source_file_dependencies: diffusion_qwen_image_perf
key: nightly-diffusion-x2iat-performance-qwen-image-single
timeout_in_minutes: 180
commands:
@@ -281,168 +282,182 @@ steps:
- export CACHE_DIT_VERSION=1.5.0
- |
set +e
- pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen_image_vllm_omni.json -m "H100 and cards_1"
+ pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen_image_vllm_omni.json -m "H100 and B200 and cards_1"
EXIT=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json"
buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log"
exit $$EXIT
- mirror_hardwares: h100_1
- label: ":full_moon: Diffusion X2I(&A&T) · Perf Test · Qwen-Image · 4-GPU"
+ source_file_dependencies: diffusion_qwen_image_perf
key: nightly-diffusion-x2iat-performance-qwen-image-4gpu
timeout_in_minutes: 180
commands:
- export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results
- - export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN
- export CACHE_DIT_VERSION=1.5.0
+ # Do not pin DIFFUSION_ATTENTION_BACKEND: H100 auto-selects FLASH_ATTN;
+ # B200 rejects explicit FLASH_ATTN without FA4 and uses CUDNN/TRTLLM.
- |
set +e
- pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen_image_vllm_omni.json -m "H100 and cards_4"
+ pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen_image_vllm_omni.json -m "H100 and B200 and cards_4"
EXIT=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json"
buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log"
exit $$EXIT
- mirror_hardwares: h100_4
- group: ":card_index_dividers: Diffusion X2V Model Test"
key: nightly-diffusion-x2v-group
depends_on: upload-nightly-pipeline
if: >-
build.env("NIGHTLY") == "1" ||
- build.pull_request.labels includes "nightly-test" ||
- build.pull_request.labels includes "diffusion-x2v-test"
+ build.pull_request.labels includes "nightly-test"
steps:
- label: ":full_moon: Diffusion X2V · Wan2.2 T2V Function Test · Single-GPU"
+ source_file_dependencies: diffusion_wan22_function
timeout_in_minutes: 90
commands:
- - pytest -s -v tests/e2e/online_serving/test_wan22_expansion.py -k "t2v" -m "full_model and cuda and H100 and cards_1" --run-level "full_model"
- mirror_hardwares: h100_1
+ - pytest -s -v tests/e2e/online_serving/test_wan22_expansion.py -k "t2v" -m "full_model and cuda and H100 and B200 and cards_1" --run-level "full_model"
- label: ":full_moon: Diffusion X2V · Wan2.2 T2V Function Test · 2-GPU"
+ source_file_dependencies: diffusion_wan22_function
timeout_in_minutes: 90
commands:
- - pytest -s -v tests/e2e/online_serving/test_wan22_expansion.py -k "t2v" -m "full_model and cuda and H100 and cards_2" --run-level "full_model"
- mirror_hardwares: h100_2
+ - pytest -s -v tests/e2e/online_serving/test_wan22_expansion.py -k "t2v" -m "full_model and cuda and H100 and B200 and cards_2" --run-level "full_model"
- label: ":full_moon: Diffusion X2V · Wan2.2 I2V/TI2V Function Test · Single-GPU"
+ source_file_dependencies: diffusion_wan22_function
timeout_in_minutes: 90
commands:
- - pytest -s -v tests/e2e/online_serving/test_wan22_expansion.py -k "i2v" -m "full_model and cuda and H100 and cards_1" --run-level "full_model"
- mirror_hardwares: h100_1
+ - pytest -s -v tests/e2e/online_serving/test_wan22_expansion.py -k "i2v" -m "full_model and cuda and H100 and B200 and cards_1" --run-level "full_model"
- label: ":full_moon: Diffusion X2V · Wan2.2 I2V/TI2V Function Test · 2-GPU"
+ source_file_dependencies: diffusion_wan22_function
timeout_in_minutes: 90
commands:
- - pytest -s -v tests/e2e/online_serving/test_wan22_expansion.py -k "i2v" -m "full_model and cuda and H100 and cards_2" --run-level "full_model"
- mirror_hardwares: h100_2
+ - pytest -s -v tests/e2e/online_serving/test_wan22_expansion.py -k "i2v" -m "full_model and cuda and H100 and B200 and cards_2" --run-level "full_model"
- label: ":full_moon: Diffusion X2V · HunyuanVideo-1.5 Function Test"
+ source_file_dependencies: diffusion_hunyuan_video_function
timeout_in_minutes: 90
commands:
- - pytest -s -v tests/e2e/online_serving/test_hunyuan_video_15_expansion.py -m "full_model and cuda" --run-level "full_model"
- mirror_hardwares: h100_2
+ - pytest -s -v tests/e2e/online_serving/test_hunyuan_video_15_expansion.py -m "full_model and H100 and B200 and cuda and (cards_1 or cards_2)" --run-level "full_model"
- - label: ":full_moon: Diffusion X2V · MAGI-2 Preview Serving Smoke"
- timeout_in_minutes: 180
+ - label: ":full_moon: Diffusion X2V · LingBot Function Test"
+ source_file_dependencies: diffusion_lingbot_function
+ timeout_in_minutes: 120
commands:
- - pytest -s -v tests/e2e/online_serving/test_magi2.py -m "advanced_model and diffusion and cuda and H100 and cards_4" --run-level "advanced_model"
- mirror_hardwares: h100_4
+ - >-
+ pytest -s -v
+ tests/e2e/online_serving/test_lingbot_video.py
+ tests/e2e/online_serving/test_lingbot_video_moe.py
+ tests/e2e/offline_inference/test_lingbot_world_v2.py
+ -m "full_model and diffusion and H100 and B200 and cards_1"
+ --run-level "full_model"
- label: ":full_moon: Diffusion X2V · Doc Test"
+ source_file_dependencies: diffusion_image_to_video_doc
timeout_in_minutes: 60
commands:
- - pytest -s -v tests/examples/offline_inference/test_image_to_video.py -m "full_model and example and H100" --run-level "full_model"
- mirror_hardwares: h100_1
+ - pytest -s -v tests/examples/offline_inference/test_image_to_video.py -m "full_model and example and H100 and B200 and cards_1" --run-level "full_model"
- - label: ":full_moon: Diffusion X2V · Other Function Test"
+ - label: ":full_moon: Diffusion X2V · Wan2.2 Autoround Function Test"
+ source_file_dependencies: diffusion_wan22_function
timeout_in_minutes: 90
commands:
- - pytest -s -v tests/e2e/offline_inference/test_wan22_autoround_w4a16_expansion.py -m "full_model and cuda" --run-level "full_model"
- mirror_hardwares: h100_2
+ - pytest -s -v tests/e2e/offline_inference/test_wan22_autoround_w4a16_expansion.py -m "full_model and H100 and B200 and cuda and (cards_1 or cards_2)" --run-level "full_model"
+
+ - label: ":full_moon: Diffusion X2V · Wan2.2 Device-Postprocess Equivalence Test"
+ source_file_dependencies: diffusion_wan22_function
+ timeout_in_minutes: 90
+ commands:
+ - |
+ VLLM_OMNI_DEVICE_POSTPROCESS_MODEL=Wan-AI/Wan2.2-TI2V-5B-Diffusers \
+ pytest -s -v tests/e2e/offline_inference/test_device_postprocess_equivalence.py -m "full_model and H100 and B200 and cuda and cards_1" --run-level "full_model"
- label: ":full_moon: Diffusion X2V · Wan2.2 Accuracy Test"
+ source_file_dependencies: diffusion_wan22_accuracy
timeout_in_minutes: 180
commands:
- - pytest -s -v tests/e2e/accuracy/wan22_i2v/test_wan22_i2v_video_similarity.py -m full_model --run-level full_model
- - pytest -s -v tests/e2e/accuracy/test_diffusers_backend_similarity.py -k '2v_matches_diffusers' -m full_model --run-level full_model
- mirror_hardwares: h100_2
+ - pytest -s -v tests/e2e/accuracy/wan22_i2v/test_wan22_i2v_video_similarity.py -m "full_model and H100 and B200 and (cards_1 or cards_2)" --run-level full_model
+ - pytest -s -v tests/e2e/accuracy/test_diffusers_backend_similarity.py -k '2v_matches_diffusers' -m "full_model and H100 and B200 and cards_1" --run-level full_model
- label: ":full_moon: Diffusion X2V · MiniMax H3 I2VA/Ref2VA Accuracy Test"
+ source_file_dependencies: diffusion_minimax_h3_accuracy
timeout_in_minutes: 180
commands:
- - pytest -s -v tests/e2e/accuracy/minimax_h3/test_minimax_h3_i2va_ref2va_similarity.py -m full_model --run-level full_model
- mirror_hardwares: h100_4
+ - pytest -s -v tests/e2e/accuracy/minimax_h3/test_minimax_h3_i2va_ref2va_similarity.py -m "full_model and H100 and B200 and cards_4" --run-level full_model
- - label: ":full_moon: Diffusion X2V · Other Accuracy Test"
+ - label: ":full_moon: Diffusion X2V · HunyuanVideo-1.5 Accuracy Test"
+ source_file_dependencies: diffusion_hunyuan_video_accuracy
timeout_in_minutes: 180
commands:
- - pytest -s -v tests/e2e/accuracy/hunyuanvideo15_t2v/test_hunyuanvideo15_t2v_video_similarity.py -m full_model --run-level full_model
- - pytest -s -v tests/e2e/accuracy/hunyuanvideo15_i2v/test_hunyuanvideo15_i2v_video_similarity.py -m full_model --run-level full_model
- mirror_hardwares: h100_2
+ - pytest -s -v tests/e2e/accuracy/hunyuanvideo15_t2v/test_hunyuanvideo15_t2v_video_similarity.py -m "full_model and H100 and B200 and (cards_1 or cards_2)" --run-level full_model
+ - pytest -s -v tests/e2e/accuracy/hunyuanvideo15_i2v/test_hunyuanvideo15_i2v_video_similarity.py -m "full_model and H100 and B200 and (cards_1 or cards_2)" --run-level full_model
- label: ":full_moon: Diffusion X2V · Perf Test · Single-GPU"
+ source_file_dependencies: diffusion_wan22_perf
key: nightly-diffusion-x2v-performance-single
timeout_in_minutes: 180
commands:
- export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results
- |
set +e
- pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_wan22_i2v_vllm_omni.json -m "H100 and cards_1"
+ pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_wan22_i2v_vllm_omni.json -m "H100 and B200 and cards_1"
EXIT=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json"
buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log"
exit $$EXIT
- mirror_hardwares: h100_1
- label: ":full_moon: Diffusion X2V · Perf Test · 2-GPU"
+ source_file_dependencies:
+ - diffusion_wan22_perf
+ - diffusion_cosmos3_perf
key: nightly-diffusion-x2v-performance-2gpu
timeout_in_minutes: 180
commands:
- export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results
- - export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN
- |
set +e
- pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_wan22_i2v_vllm_omni.json -m "H100 and cards_2"
+ pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_wan22_i2v_vllm_omni.json -m "H100 and B200 and cards_2"
EXIT1=$$?
- pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_cosmos3_vllm_omni.json -m "H100 and cards_2"
+ pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_cosmos3_vllm_omni.json -m "H100 and B200 and cards_2"
EXIT2=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json"
buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log"
if [ $$EXIT1 -ne 0 ]; then exit $$EXIT1; fi
exit $$EXIT2
- mirror_hardwares: h100_2
- label: ":full_moon: Diffusion X2V · Perf Test · HunyuanVideo-1.5"
+ source_file_dependencies: diffusion_hunyuan_video_perf
key: nightly-diffusion-x2v-performance-hunyuanvideo15
timeout_in_minutes: 180
commands:
- export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results
- |
set +e
- pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_hunyuanvideo15_t2v_vllm_omni.json
+ pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_hunyuanvideo15_t2v_vllm_omni.json -m "H100 and B200 and (cards_1 or cards_2)"
EXIT1=$$?
- pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_hunyuanvideo15_i2v_vllm_omni.json
+ pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_hunyuanvideo15_i2v_vllm_omni.json -m "H100 and B200 and (cards_1 or cards_2)"
EXIT2=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json"
buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log"
if [ $$EXIT1 -ne 0 ]; then exit $$EXIT1; fi
exit $$EXIT2
- mirror_hardwares: h100_2
- label: ":full_moon: Diffusion X2V · Perf Test · MiniMax-H3"
+ source_file_dependencies: diffusion_minimax_h3_perf
key: nightly-diffusion-x2v-performance-minimax-h3
timeout_in_minutes: 180
commands:
- export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results
- |
set +e
- pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_minimax_h3_vllm_omni.json
+ pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_minimax_h3_vllm_omni.json -m "H100 and B200 and cards_4"
EXIT=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json"
buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log"
exit $$EXIT
- mirror_hardwares: h100_4
- group: ":card_index_dividers: Diffusion Test"
key: nightly-diffusion-test-group
@@ -450,59 +465,59 @@ steps:
if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test"
steps:
- label: ":full_moon: Diffusion · Distributed Test with L4 · 2-GPU"
+ source_file_dependencies: diffusion_distributed_attention
timeout_in_minutes: 60
commands:
- - pytest -sv tests/diffusion/distributed/ -m 'full_model and cuda and L4 and cards_2' --run-level "full_model"
- mirror_hardwares: l4_2
+ - pytest -sv tests/diffusion/distributed/ -m 'full_model and cuda and L4 and B200 and cards_2' --run-level "full_model"
- label: ":full_moon: Diffusion · Distributed Test with L4 · 3-GPU"
+ source_file_dependencies: diffusion_distributed_attention
timeout_in_minutes: 60
commands:
- - pytest -sv tests/diffusion/distributed/ -m 'full_model and cuda and L4 and cards_3' --run-level "full_model"
- mirror_hardwares: l4_3
+ - pytest -sv tests/diffusion/distributed/ -m 'full_model and cuda and L4 and B200 and cards_3' --run-level "full_model"
- label: ":full_moon: Diffusion · Distributed Test with L4 · 4-GPU"
+ source_file_dependencies: diffusion_distributed_attention
timeout_in_minutes: 60
commands:
- - pytest -sv tests/diffusion/distributed/ -m 'full_model and cuda and L4 and cards_4' --run-level "full_model"
- mirror_hardwares: l4_4
+ - pytest -sv tests/diffusion/distributed/ -m 'full_model and cuda and L4 and B200 and cards_4' --run-level "full_model"
- label: ":full_moon: Diffusion · Distributed Test with H100 · 2-GPU"
+ source_file_dependencies: diffusion_distributed_attention
timeout_in_minutes: 60
commands:
- - pytest -sv tests/diffusion/distributed/ -m 'full_model and cuda and H100 and cards_2' --run-level "full_model"
- mirror_hardwares: h100_2
+ - pytest -sv tests/diffusion/distributed/ -m 'full_model and cuda and H100 and B200 and cards_2' --run-level "full_model"
- label: ":full_moon: Diffusion · Distributed Test with H100 · 4-GPU"
+ source_file_dependencies: diffusion_distributed_attention
timeout_in_minutes: 60
commands:
- - pytest -sv tests/diffusion/distributed/ -m 'full_model and cuda and H100 and cards_4' --run-level "full_model"
- mirror_hardwares: h100_4
+ - pytest -sv tests/diffusion/distributed/ -m 'full_model and cuda and H100 and B200 and cards_4' --run-level "full_model"
- label: ":full_moon: Diffusion · Offloader Test"
+ source_file_dependencies: diffusion_offloader
timeout_in_minutes: 60
commands:
- - pytest -sv tests/diffusion/offloader -m "full_model and cuda"
- mirror_hardwares: l4_1
+ - pytest -sv tests/diffusion/offloader -m "full_model and L4 and B200 and cuda and cards_1"
- label: ":full_moon: Diffusion · Quantization Test with H100 · Single-GPU"
+ source_file_dependencies: diffusion_quantization
timeout_in_minutes: 60
commands:
- - pytest -sv tests/diffusion/quantization -m 'full_model and cuda and H100' --run-level "full_model"
- mirror_hardwares: h100_1
+ - pytest -sv tests/diffusion/quantization -m 'full_model and cuda and H100 and B200 and cards_1' --run-level "full_model"
- label: ":full_moon: Diffusion · MiniMax H3 FP8 Quality Test with H100 · 2-GPU"
+ source_file_dependencies: diffusion_minimax_h3_function
timeout_in_minutes: 60
commands:
- - pytest -sv tests/diffusion/models/minimax_h3/test_minimax_h3_quantization_quality.py -m 'full_model and cuda and H100' --run-level "full_model"
- mirror_hardwares: h100_2
+ - pytest -sv tests/diffusion/models/minimax_h3/test_minimax_h3_quantization_quality.py -m 'full_model and cuda and H100 and B200 and cards_2' --run-level "full_model"
- label: ":full_moon: Diffusion · Quantization Test with L4"
+ source_file_dependencies: diffusion_quantization
timeout_in_minutes: 60
commands:
- pip install "vllm-gguf-plugin==0.0.4"
- - pytest -sv tests/diffusion/quantization -m 'full_model and cuda and L4' --run-level "full_model"
- mirror_hardwares: l4_1
+ - pytest -sv tests/diffusion/quantization -m 'full_model and cuda and L4 and B200 and cards_1' --run-level "full_model"
# NOTE: ":email: Nightly Collection & Email" is deprecated and will be removed soon.
# Do not add new depends_on entries or extend this step.
diff --git a/.buildkite/cuda/test-ready.yml b/.buildkite/cuda/test-ready.yml
index b5fc335e7..1f07b0721 100644
--- a/.buildkite/cuda/test-ready.yml
+++ b/.buildkite/cuda/test-ready.yml
@@ -38,6 +38,12 @@ steps:
depends_on: upload-ready-pipeline
steps:
+ - label: "Diffusion · FlashAttention Test"
+ timeout_in_minutes: 20
+ commands:
+ - timeout 15m pytest -s -v tests/diffusion/attention/ -m 'core_model and cuda and L4 and cards_1' --run-level "core_model"
+ mirror_hardwares: l4_1
+
- label: "Diffusion · Offloader Test"
commands:
- timeout 40m pytest -s -v tests/diffusion/offloader -m "core_model and cuda" --run-level "core_model"
@@ -54,16 +60,19 @@ steps:
mirror_hardwares: l4_1
- label: "Diffusion · Distributed Test · Single-GPU"
+ source_file_dependencies: diffusion_distributed_attention
commands:
- timeout 20m pytest -sv tests/diffusion/distributed -m 'core_model and cuda and L4 and cards_1' --run-level "core_model"
mirror_hardwares: l4_1
- label: "Diffusion · Distributed Test · 2-GPU"
+ source_file_dependencies: diffusion_distributed_attention
commands:
- timeout 20m pytest -sv tests/diffusion/distributed -m 'core_model and cuda and L4 and cards_2' --run-level "core_model"
mirror_hardwares: l4_2
- label: "Diffusion · Distributed & Attention Test · 4-GPU"
+ source_file_dependencies: diffusion_distributed_attention
commands:
- timeout 20m pytest -sv tests/diffusion/distributed -m 'core_model and cuda and L4 and cards_4' --run-level "core_model"
- timeout 20m pytest -sv tests/diffusion/attention/test_attention_sp.py -m 'core_model and cuda and L4 and cards_4' --run-level "core_model"
@@ -93,7 +102,7 @@ steps:
commands:
- |
timeout 15m bash -c "
- pytest -sv tests/engine/ tests/model_executor/ tests/worker/test_batched_omni_output.py -m 'core_model and cuda' --run-level 'core_model'
+ pytest -sv tests/engine/ tests/model_executor/ tests/worker/test_batched_omni_output.py tests/worker_v2/ -m 'core_model and cuda' --run-level 'core_model'
"
mirror_hardwares: l4_1
@@ -120,29 +129,14 @@ steps:
depends_on: upload-ready-pipeline
steps:
- label: "Custom Pipeline Test"
- source_file_dependencies:
- - tests/e2e/features/custom_pipeline/
- - tests/e2e/features/helpers/custom_pipeline.py
- - vllm_omni/diffusion/
- - vllm_omni/engine/
- - vllm_omni/entrypoints/
- - vllm_omni/inputs/
- - vllm_omni/outputs/
+ source_file_dependencies: custom_pipeline
commands:
- timeout 20m pytest -s -v tests/e2e/features/custom_pipeline/ -m "core_model"
mirror_hardwares: l4_1
- label: "RLHF · VeRL-Omni E2E Test"
- source_file_dependencies:
- - tests/e2e/features/rlhf_test/test_verl_omni_e2e.py
- - tests/e2e/features/helpers/verl_omni_server.py
- - tests/e2e/features/helpers/custom_pipeline.py
- - vllm_omni/diffusion/
- - vllm_omni/engine/
- - vllm_omni/entrypoints/
- - vllm_omni/inputs/
- - vllm_omni/outputs/
+ source_file_dependencies: rlhf_verl_omni
commands:
- |
timeout 20m bash -c "
@@ -151,17 +145,7 @@ steps:
mirror_hardwares: l4_1
- label: "Omni · Qwen3-Omni Test"
- source_file_dependencies:
- - tests/e2e/online_serving/test_qwen3_omni.py
- - tests/helpers/runtime.py
- - vllm_omni/entrypoints/openai/serving_chat.py
- - vllm_omni/model_executor/models/qwen3_omni/
- - vllm_omni/model_executor/models/common/qwen3_code_predictor.py
- - vllm_omni/model_executor/models/common/snake_activation.py
- - vllm_omni/model_executor/models/qwen2_5_omni/qwen2_5_omni_thinker.py
- - vllm_omni/model_executor/models/qwen3_tts/cuda_graph_decoder_wrapper.py
- - vllm_omni/model_executor/stage_input_processors/qwen3_omni.py
- - vllm_omni/deploy/qwen3_omni_moe.yaml
+ source_file_dependencies: omni_qwen3_omni_function
commands:
- |
timeout 20m bash -c "
@@ -170,14 +154,7 @@ steps:
mirror_hardwares: h100_2
- label: "Omni · MiniCPM-o 4.5 Offline Test"
- source_file_dependencies:
- - tests/e2e/offline_inference/test_minicpmo_4_5.py
- - vllm_omni/model_executor/models/minicpmo_4_5/
- - vllm_omni/model_executor/models/step_audio2/
- - vllm_omni/model_executor/models/cosyvoice3/code2wav_core/
- - vllm_omni/model_executor/models/cosyvoice3/utils.py
- - vllm_omni/model_executor/stage_input_processors/minicpmo_4_5_omni.py
- - vllm_omni/deploy/minicpmo_4_5.yaml
+ source_file_dependencies: omni_minicpmo_4_5_function
commands:
- |
timeout 20m bash -c "
@@ -186,15 +163,7 @@ steps:
mirror_hardwares: h100_1
- label: "Omni · MiniCPM-o 4.5 Online Test"
- source_file_dependencies:
- - tests/e2e/online_serving/test_minicpmo_4_5.py
- - vllm_omni/transformers_utils/chat_templates/minicpmo45_native.jinja
- - vllm_omni/model_executor/models/minicpmo_4_5/
- - vllm_omni/model_executor/models/step_audio2/
- - vllm_omni/model_executor/models/cosyvoice3/code2wav_core/
- - vllm_omni/model_executor/models/cosyvoice3/utils.py
- - vllm_omni/model_executor/stage_input_processors/minicpmo_4_5_omni.py
- - vllm_omni/deploy/minicpmo_4_5.yaml
+ source_file_dependencies: omni_minicpmo_4_5_function
commands:
- |
timeout 20m bash -c "
@@ -203,48 +172,7 @@ steps:
mirror_hardwares: h100_1
- label: "Omni · MiniCPM-o 4.5 Duplex Test"
- source_file_dependencies:
- - tests/assets/minicpmo_4_5/response_required_16k.wav
- - tests/assets/minicpmo_4_5/soft_interrupt_16k.wav
- - tests/e2e/online_serving/test_minicpmo_4_5_duplex.py
- - tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py
- - 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
- - vllm_omni/engine/duplex/
- - vllm_omni/entrypoints/duplex/
- # Public duplex client library + its unit suites: client-side
- # changes must re-run the duplex e2e group, which includes the
- # live client probe (test_duplex_client_live.py, single GPU).
- - vllm_omni/clients/
- - tests/clients/
- - tests/e2e/online_serving/test_duplex_client_live.py
- - vllm_omni/entrypoints/duplex_request_client.py
- - vllm_omni/outputs/duplex.py
- - vllm_omni/model_executor/duplex_sampling.py
- - tests/engine/duplex/
- - tests/entrypoints/duplex/
- - tests/model_executor/models/minicpmo_4_5/duplex/
- # Legacy Realtime probe client + video stacking used by the e2e drivers.
- - vllm_omni/experimental/fullduplex/
- - vllm_omni/model_executor/models/minicpmo_4_5/
- - vllm_omni/model_executor/models/step_audio2/
- - vllm_omni/model_executor/models/cosyvoice3/code2wav_core/
- - vllm_omni/model_executor/models/cosyvoice3/utils.py
- - vllm_omni/model_executor/stage_input_processors/minicpmo_4_5_omni.py
- - vllm_omni/core/sched/
- - vllm_omni/outputs/multimodal_accumulation.py
- - vllm_omni/engine/stage_init_utils.py
- - vllm_omni/engine/async_omni_engine.py
- - vllm_omni/engine/orchestrator.py
- - vllm_omni/entrypoints/cli/serve.py
- - vllm_omni/entrypoints/openai/api_server.py
- - vllm_omni/entrypoints/openai/duplex_capability.py
- - vllm_omni/config/stage_config.py
+ source_file_dependencies: omni_minicpmo_4_5_duplex_function
commands:
- |
timeout 35m bash -c "
@@ -254,29 +182,14 @@ steps:
- label: "Omni · MiniCPM-o 4.5 Perf Test"
timeout_in_minutes: 60
- source_file_dependencies:
- - tests/dfx/perf/tests/test_minicpmo_4_5_ready.json
- - vllm_omni/deploy/minicpmo_4_5.yaml
- - vllm_omni/model_executor/models/minicpmo_4_5/
- - vllm_omni/model_executor/stage_input_processors/minicpmo_4_5_omni.py
+ source_file_dependencies: omni_minicpmo_4_5_perf
commands:
- export BENCHMARK_DIR=tests/dfx/perf/results
- pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_minicpmo_4_5_ready.json -m 'core_model and cuda'
mirror_hardwares: h100_1
- label: "TTS · Qwen3-TTS CustomVoice Test"
- source_file_dependencies:
- - tests/helpers/media.py
- - tests/e2e/online_serving/test_qwen3_tts_customvoice.py
- - vllm_omni/model_executor/models/qwen3_tts/
- - vllm_omni/model_executor/models/common/qwen3_code_predictor.py
- - vllm_omni/model_executor/models/common/snake_activation.py
- - vllm_omni/model_executor/models/common/alias_free_activation.py
- - vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
- - vllm_omni/deploy/qwen3_tts.yaml
- - vllm_omni/entrypoints/openai/serving_speech.py
- - vllm_omni/entrypoints/openai/tts_adapters/
- - vllm_omni/engine/stage_pool.py
+ source_file_dependencies: tts_qwen3_tts_function
commands:
- |
timeout 20m bash -c "
@@ -286,10 +199,7 @@ steps:
mirror_hardwares: l4_1
- label: "Diffusion · Wan22 Test"
- source_file_dependencies:
- - tests/e2e/online_serving/test_wan22_t2v.py
- - vllm_omni/diffusion/models/wan2_2/
- - vllm_omni/diffusion/models/dmd2/
+ source_file_dependencies: diffusion_wan22_function
commands:
- |
timeout 40m bash -c "
@@ -299,12 +209,7 @@ steps:
mirror_hardwares: h100_1
- label: "Diffusion · Cosmos3 Test"
- source_file_dependencies:
- - tests/e2e/online_serving/test_cosmos3.py
- - vllm_omni/diffusion/models/cosmos3/
- - vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl_wan.py
- - vllm_omni/experimental/world_models/
- - vllm_omni/model_extras/cosmos3.py
+ source_file_dependencies: diffusion_cosmos3_function
commands:
- |
timeout 40m bash -c "
@@ -315,14 +220,7 @@ steps:
- label: "Diffusion · MiniMax-H3 DLO DP2 T2VA Test"
timeout_in_minutes: 90
- source_file_dependencies:
- - tests/e2e/online_serving/test_minimax_h3_dlo_dp2_t2va.py
- - tests/helpers/assertions.py
- - tests/helpers/runtime.py
- - vllm_omni/diffusion/models/minimax_h3/
- - vllm_omni/diffusion/offloader/
- - vllm_omni/diffusion/distributed/
- - vllm_omni/entrypoints/openai/
+ source_file_dependencies: diffusion_minimax_h3_function
commands:
- |
timeout 80m bash -c "
@@ -344,11 +242,7 @@ steps:
mirror_hardwares: h100_1
- label: "Diffusion · Qwen Image Test"
- source_file_dependencies:
- - tests/e2e/online_serving/test_qwen_image.py
- - vllm_omni/diffusion/models/qwen_image/
- - vllm_omni/diffusion/models/dmd2/
- - vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl_qwenimage.py
+ source_file_dependencies: diffusion_qwen_image_function
commands:
- |
timeout 40m bash -c "
@@ -358,9 +252,7 @@ steps:
mirror_hardwares: h100_1
- label: "Diffusion · HunyuanVideo-1.5 Test"
- source_file_dependencies:
- - tests/e2e/online_serving/test_hunyuan_video_15_expansion.py
- - vllm_omni/diffusion/models/hunyuan_video/
+ source_file_dependencies: diffusion_hunyuan_video_function
commands:
- |
timeout 40m bash -c "
diff --git a/.buildkite/cuda/test-weekly.yml b/.buildkite/cuda/test-weekly.yml
index 614af9572..70ab59356 100644
--- a/.buildkite/cuda/test-weekly.yml
+++ b/.buildkite/cuda/test-weekly.yml
@@ -29,36 +29,42 @@ steps:
steps:
- label: "Reliability Test · qwen3-omni"
+ source_file_dependencies: omni_qwen3_omni_reliability
timeout_in_minutes: 180
commands:
- pytest -s -v tests/dfx/reliability/test_reliability_qwen3_omni.py -m "slow"
mirror_hardwares: h100_2
- label: "Reliability Test · wan22"
+ source_file_dependencies: diffusion_wan22_reliability
timeout_in_minutes: 180
commands:
- pytest -s -v tests/dfx/reliability/test_reliability_wan22.py -m "slow"
mirror_hardwares: h100_2
- label: "Reliability Test · hunyuan-image"
+ source_file_dependencies: diffusion_hunyuan_image3_reliability
timeout_in_minutes: 180
commands:
- pytest -s -v tests/dfx/reliability/test_reliability_hunyuan_image.py -m "slow"
mirror_hardwares: h100_4
- label: "Reliability Test · Invalid parameters · H100 · Single-GPU"
+ source_file_dependencies: reliability_invalid_param_h100
timeout_in_minutes: 90
commands:
- pytest -s -v tests/dfx/reliability/invalid_param_test/ -m "slow and H100 and cards_1"
mirror_hardwares: h100_1
- label: "Reliability Test · Invalid parameters · H100 · 2-GPU"
+ source_file_dependencies: reliability_invalid_param_h100
timeout_in_minutes: 90
commands:
- pytest -s -v tests/dfx/reliability/invalid_param_test/ -m "slow and H100 and cards_2"
mirror_hardwares: h100_2
- label: "Reliability Test · Invalid parameters · L4"
+ source_file_dependencies: reliability_invalid_param_l4
timeout_in_minutes: 120
commands:
- pytest -s -v tests/dfx/reliability/invalid_param_test/ -m "slow and L4"
@@ -70,6 +76,7 @@ steps:
steps:
- label: ":full_moon: Omni · Perf Test · vLLM Text"
+ source_file_dependencies: omni_qwen3_omni_perf
key: weekly-omni-performance-vllm-text
timeout_in_minutes: 180
commands:
@@ -96,6 +103,7 @@ steps:
mirror_hardwares: h100_2
- label: ":full_moon: Omni · Perf Test · Multi-Replica"
+ source_file_dependencies: omni_qwen3_omni_perf
key: weekly-omni-performance-multi-replicas
timeout_in_minutes: 180
commands:
@@ -183,13 +191,14 @@ steps:
# from downgrading transformers; see
# examples/offline_inference/text_to_speech/README.md.
- label: "TTS · Gepard-1.0 · L4"
- timeout_in_minutes: 60
+ timeout_in_minutes: 90
commands:
- export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
- printf '%s\n' "transformers>=5.5.3" "huggingface-hub>=1.0" > /tmp/gepard-overrides.txt
- uv pip install --python "$(python3 -c 'import sys; print(sys.executable)')" "nemo_toolkit[tts]==2.7.3" --overrides /tmp/gepard-overrides.txt
- >-
pytest -s -v tests/e2e/offline_inference/test_gepard_tts.py
+ tests/e2e/online_serving/test_gepard_tts.py
-m "tts and cuda"
--run-level "full_model"
mirror_hardwares: l4_1
@@ -221,24 +230,277 @@ steps:
--run-level "full_model"
mirror_hardwares: l4_4
- - label: "Diffusion · H100 · Single-GPU"
- timeout_in_minutes: 150
+ # Split the old shared H100 Single-GPU sweep: one pytest process collected
+ # every slow+cards_1 diffusion file and exceeded 150 minutes.
+ - label: "Diffusion · Qwen-Image · H100 · Single-GPU"
+ timeout_in_minutes: 120
commands:
- >-
pytest -sv
- tests/e2e/ -m "slow and diffusion and H100 and cards_1"
+ tests/e2e/online_serving/test_qwen_image_edit.py
+ tests/e2e/online_serving/test_qwen_image_edit_expansion.py
+ tests/e2e/online_serving/test_qwen_image_layered.py
+ tests/e2e/online_serving/test_qwen_image_layered_expansion.py
+ tests/e2e/accuracy/test_qwen_image_edit.py
+ tests/e2e/accuracy/test_qwen_image_layered.py
+ -m "slow and diffusion and H100 and cards_1"
--run-level "full_model"
mirror_hardwares: h100_1
- - label: "Diffusion · H100 · 2-GPU"
+ - label: "Diffusion · Wan2.1 VACE · H100 · Single-GPU"
timeout_in_minutes: 90
commands:
- >-
pytest -sv
- tests/e2e/ -m "slow and diffusion and H100 and cards_2"
+ tests/e2e/online_serving/test_wan_2_1_vace_expansion.py
+ -m "slow and diffusion and H100 and cards_1"
+ --run-level "full_model"
+ mirror_hardwares: h100_1
+
+ - label: "Diffusion · LongCat · H100 · Single-GPU"
+ timeout_in_minutes: 90
+ commands:
+ - >-
+ pytest -sv
+ tests/e2e/online_serving/test_longcat_image_expansion.py
+ tests/e2e/online_serving/test_longcat_image_edit_expansion.py
+ -m "slow and diffusion and H100 and cards_1"
+ --run-level "full_model"
+ mirror_hardwares: h100_1
+
+ - label: "Diffusion · Bagel · H100 · Single-GPU"
+ timeout_in_minutes: 90
+ commands:
+ - >-
+ pytest -sv
+ tests/e2e/online_serving/test_bagel.py
+ tests/e2e/online_serving/test_bagel_expansion.py
+ tests/e2e/offline_inference/test_bagel.py
+ tests/e2e/offline_inference/test_bagel_expansion.py
+ -m "slow and diffusion and H100 and cards_1"
+ --run-level "full_model"
+ mirror_hardwares: h100_1
+
+ - label: "Diffusion · Boogu · H100 · Single-GPU"
+ timeout_in_minutes: 90
+ commands:
+ - >-
+ pytest -sv
+ tests/e2e/online_serving/test_boogu_image.py
+ tests/e2e/online_serving/test_boogu_image_edit.py
+ tests/e2e/online_serving/test_boogu_image_expansion.py
+ tests/e2e/online_serving/test_boogu_image_edit_expansion.py
+ -m "slow and diffusion and H100 and cards_1"
+ --run-level "full_model"
+ mirror_hardwares: h100_1
+
+ - label: "Diffusion · Flux · H100 · Single-GPU"
+ timeout_in_minutes: 90
+ commands:
+ - >-
+ pytest -sv
+ tests/e2e/online_serving/test_flux_2_dev_expansion.py
+ tests/e2e/offline_inference/test_flux1_schnell_expansion.py
+ -m "slow and diffusion and H100 and cards_1"
+ --run-level "full_model"
+ mirror_hardwares: h100_1
+
+ - label: "Diffusion · LTX · H100 · Single-GPU"
+ timeout_in_minutes: 120
+ commands:
+ - >-
+ pytest -sv
+ tests/e2e/online_serving/test_ltx.py
+ tests/e2e/online_serving/test_ltx25.py
+ tests/e2e/accuracy/ltx/test_ltx_official_similarity.py
+ tests/e2e/accuracy/ltx/test_ltx25_official_similarity.py
+ -m "slow and diffusion and H100 and cards_1"
+ --run-level "full_model"
+ mirror_hardwares: h100_1
+
+ - label: "Diffusion · Krea2 · H100 · Single-GPU"
+ timeout_in_minutes: 90
+ commands:
+ - >-
+ pytest -sv
+ tests/e2e/online_serving/test_krea2_expansion.py
+ tests/e2e/offline_inference/test_krea2_expansion.py
+ -m "slow and diffusion and H100 and cards_1"
+ --run-level "full_model"
+ mirror_hardwares: h100_1
+
+ - label: "Diffusion · HiDream-I1 · H100 · Single-GPU"
+ timeout_in_minutes: 90
+ commands:
+ - >-
+ pytest -sv
+ tests/e2e/online_serving/test_hidream_i1_full_expansion.py
+ -m "slow and diffusion and H100 and cards_1"
+ --run-level "full_model"
+ mirror_hardwares: h100_1
+
+ - label: "Diffusion · SANA-Video · H100 · Single-GPU"
+ source_file_dependencies: diffusion_sana_video_function
+ timeout_in_minutes: 180
+ commands:
+ - export SANA_VIDEO_GOLDEN_BASE_URL=https://vllm-public-assets.s3.us-west-2.amazonaws.com/omni-assets/sana-video/v2
+ - >-
+ pytest -sv
+ tests/e2e/online_serving/test_sana_video_expansion.py
+ tests/e2e/accuracy/sana_video/
+ -m "slow and diffusion and H100 and cards_1"
+ --run-level "full_model"
+ mirror_hardwares: h100_1
+
+ - label: "Diffusion · SenseNova-U1 · H100 · Single-GPU"
+ timeout_in_minutes: 90
+ commands:
+ - >-
+ pytest -sv
+ tests/e2e/offline_inference/test_sensenova_u1_expansion.py
+ tests/e2e/offline_inference/test_sensenova_u1_text2img_expansion.py
+ tests/e2e/offline_inference/test_sensenova_u1_img2img_expansion.py
+ -m "slow and diffusion and H100 and cards_1"
+ --run-level "full_model"
+ mirror_hardwares: h100_1
+
+ - label: "Diffusion · MammothModa2 · H100 · Single-GPU"
+ timeout_in_minutes: 90
+ commands:
+ - >-
+ pytest -sv
+ tests/e2e/offline_inference/test_mammoth_moda2_expansion.py
+ -m "slow and diffusion and H100 and cards_1"
+ --run-level "full_model"
+ mirror_hardwares: h100_1
+
+ - label: "Diffusion · Helios · H100 · Single-GPU"
+ timeout_in_minutes: 90
+ commands:
+ - >-
+ pytest -sv
+ tests/e2e/accuracy/test_video_streaming_output_similarity.py
+ -m "slow and diffusion and H100 and cards_1"
+ --run-level "full_model"
+ mirror_hardwares: h100_1
+
+ # Split the old shared H100 2-GPU sweep: one pytest process collected every
+ # slow+cards_2 diffusion file and exceeded 90 minutes.
+ - label: "Diffusion · Qwen-Image · H100 · 2-GPU"
+ timeout_in_minutes: 90
+ commands:
+ - >-
+ pytest -sv
+ tests/e2e/online_serving/test_qwen_image_edit_expansion.py
+ tests/e2e/online_serving/test_qwen_image_layered_expansion.py
+ -m "slow and diffusion and H100 and cards_2"
--run-level "full_model"
mirror_hardwares: h100_2
+ - label: "Diffusion · Wan2.1 VACE · H100 · 2-GPU"
+ timeout_in_minutes: 90
+ commands:
+ - >-
+ pytest -sv
+ tests/e2e/online_serving/test_wan_2_1_vace_expansion.py
+ -m "slow and diffusion and H100 and cards_2"
+ --run-level "full_model"
+ mirror_hardwares: h100_2
+
+ - label: "Diffusion · LongCat · H100 · 2-GPU"
+ timeout_in_minutes: 90
+ commands:
+ - >-
+ pytest -sv
+ tests/e2e/online_serving/test_longcat_image_expansion.py
+ tests/e2e/online_serving/test_longcat_image_edit_expansion.py
+ -m "slow and diffusion and H100 and cards_2"
+ --run-level "full_model"
+ mirror_hardwares: h100_2
+
+ - label: "Diffusion · Bagel · H100 · 2-GPU"
+ timeout_in_minutes: 90
+ commands:
+ - >-
+ pytest -sv
+ tests/e2e/online_serving/test_bagel_expansion.py
+ -m "slow and diffusion and H100 and cards_2"
+ --run-level "full_model"
+ mirror_hardwares: h100_2
+
+ - label: "Diffusion · Boogu · H100 · 2-GPU"
+ timeout_in_minutes: 90
+ commands:
+ - >-
+ pytest -sv
+ tests/e2e/online_serving/test_boogu_image_expansion.py
+ tests/e2e/online_serving/test_boogu_image_edit_expansion.py
+ -m "slow and diffusion and H100 and cards_2"
+ --run-level "full_model"
+ mirror_hardwares: h100_2
+
+ - label: "Diffusion · Flux · H100 · 2-GPU"
+ timeout_in_minutes: 90
+ commands:
+ - >-
+ pytest -sv
+ tests/e2e/online_serving/test_flux_2_dev_expansion.py
+ tests/e2e/offline_inference/test_flux1_kontext_expansion.py
+ -m "slow and diffusion and H100 and cards_2"
+ --run-level "full_model"
+ mirror_hardwares: h100_2
+
+ - label: "Diffusion · GLM-Image · H100 · 2-GPU"
+ timeout_in_minutes: 90
+ commands:
+ - >-
+ pytest -sv
+ tests/e2e/online_serving/test_glm_image_expansion.py
+ tests/e2e/offline_inference/test_glm_image_autoround_w4a16_expansion.py
+ -m "slow and diffusion and H100 and cards_2"
+ --run-level "full_model"
+ mirror_hardwares: h100_2
+
+ - label: "Diffusion · LTX · H100 · 2-GPU"
+ timeout_in_minutes: 90
+ commands:
+ - >-
+ pytest -sv
+ tests/e2e/online_serving/test_ltx.py
+ -m "slow and diffusion and H100 and cards_2"
+ --run-level "full_model"
+ mirror_hardwares: h100_2
+
+ - label: "Diffusion · Krea2 · H100 · 2-GPU"
+ timeout_in_minutes: 90
+ commands:
+ - >-
+ pytest -sv
+ tests/e2e/offline_inference/test_krea2_expansion.py
+ -m "slow and diffusion and H100 and cards_2"
+ --run-level "full_model"
+ mirror_hardwares: h100_2
+
+ - label: "Diffusion · DreamZero · H100 · 2-GPU"
+ timeout_in_minutes: 90
+ commands:
+ - >-
+ pytest -sv
+ tests/e2e/online_serving/test_dreamzero_expansion.py
+ tests/e2e/accuracy/test_dreamzero.py
+ -m "slow and diffusion and H100 and cards_2"
+ --run-level "full_model"
+ mirror_hardwares: h100_2
+
+ - label: "Diffusion · H100 · 3-GPU"
+ timeout_in_minutes: 120
+ commands:
+ - >-
+ pytest -sv
+ tests/e2e/ -m "slow and diffusion and H100 and cards_3"
+ --run-level "full_model"
+ mirror_hardwares: h100_3
+
- label: "Diffusion · H100 · 4-GPU"
timeout_in_minutes: 90
commands:
@@ -246,4 +508,33 @@ steps:
pytest -sv
tests/e2e/ -m "slow and diffusion and H100 and cards_4"
--run-level "full_model"
+ --ignore=tests/e2e/online_serving/test_magi2.py
+ mirror_hardwares: h100_4
+
+ # Dedicated: HiDream-O1 accuracy needs a long TP2 run; the shared H100
+ # 2-GPU sweep is only 90 minutes.
+ - label: "Diffusion · HiDream-O1-Image Accuracy · H100 · 2-GPU"
+ timeout_in_minutes: 180
+ commands:
+ - pytest -s -v tests/e2e/accuracy/test_hidream_o1_image.py -m "slow and diffusion and cuda and H100 and cards_2" --run-level "full_model"
+ mirror_hardwares: h100_2
+
+ # Dedicated: Boogu CFG-parallel size 3 needs 3 GPUs; weekly has no H100 3-GPU sweep.
+ - label: "Diffusion · Boogu Edit CFG · H100 · 3-GPU"
+ timeout_in_minutes: 120
+ commands:
+ - >-
+ pytest -sv
+ tests/e2e/online_serving/test_boogu_image_edit_expansion.py
+ -k "test_double_guidance_cfg_parallel and cfg_parallel_3_full"
+ -m "slow and diffusion and cuda and H100 and cards_3"
+ --run-level "full_model"
+ mirror_hardwares: h100_3
+
+ # Dedicated: MAGI-2 Preview serving smoke needs ~30 min server init; the
+ # shared H100 4-GPU sweep is only 90 minutes.
+ - label: "Diffusion · MAGI-2 Preview Serving Smoke · H100 · 4-GPU"
+ timeout_in_minutes: 180
+ commands:
+ - pytest -s -v tests/e2e/online_serving/test_magi2.py -m "slow and diffusion and cuda and H100 and cards_4" --run-level "full_model"
mirror_hardwares: h100_4
diff --git a/.buildkite/intel/pipeline-intel.yml b/.buildkite/intel/pipeline-intel.yml
index d6134b028..1e2f86af2 100644
--- a/.buildkite/intel/pipeline-intel.yml
+++ b/.buildkite/intel/pipeline-intel.yml
@@ -10,7 +10,7 @@ steps:
DOCKER_BUILDKIT: "1"
# Buildkite will automatically replace this with the actual commit hash
VLLM_IMAGE_TAG: "${BUILDKITE_COMMIT}"
- VLLM_VERSION: "v0.28.0"
+ VLLM_VERSION: "v0.29.0"
priority: 100
timeout_in_minutes: 60
soft_fail: false
diff --git a/.buildkite/npu/bootstrap-upload-steps.yml b/.buildkite/npu/bootstrap-upload-steps.yml
index 398fbc6dc..d88fcc70b 100644
--- a/.buildkite/npu/bootstrap-upload-steps.yml
+++ b/.buildkite/npu/bootstrap-upload-steps.yml
@@ -93,7 +93,7 @@ steps:
command: |
python3 .buildkite/common/scripts/upload_pipeline.py --upload .buildkite/npu/test-npu-ready.yml
- # L4 Test — main+NIGHTLY=1 (scheduled), or PR with specific label (e.g. add label then Rebuild)
+ # L4 Test — main+NIGHTLY=1 (scheduled), or PR nightly-test label
- label: "Upload Nightly Pipeline"
depends_on:
- image-build-a2
@@ -102,6 +102,11 @@ steps:
- image-build-310p
key: upload-nightly-pipeline
commands:
- - python3 .buildkite/common/scripts/upload_pipeline.py --upload .buildkite/npu/test-npu-nightly.yml
+ - |
+ EXTRA=""
+ if [ "${NIGHTLY:-}" = "1" ] && [ "${BUILDKITE_BRANCH}" = "main" ]; then
+ EXTRA="--all"
+ fi
+ python3 .buildkite/common/scripts/upload_pipeline.py --upload $$EXTRA .buildkite/npu/test-npu-nightly.yml
agents:
queue: "cpu_queue_premerge"
diff --git a/.buildkite/npu/pipeline-npu-a3.yml b/.buildkite/npu/pipeline-npu-a3.yml
index a57f5f8e5..3544b384d 100644
--- a/.buildkite/npu/pipeline-npu-a3.yml
+++ b/.buildkite/npu/pipeline-npu-a3.yml
@@ -96,13 +96,7 @@
# key: upload-nightly-pipeline
# if: >-
# (build.branch == "main" && build.env("NIGHTLY") == "1") ||
- # (build.branch != "main" && (
- # build.pull_request.labels includes "nightly-test" ||
- # build.pull_request.labels includes "omni-test" ||
- # build.pull_request.labels includes "tts-test" ||
- # build.pull_request.labels includes "diffusion-x2iat-test" ||
- # build.pull_request.labels includes "diffusion-x2v-test"
- # ))
+ # (build.branch != "main" && build.pull_request.labels includes "nightly-test")
# commands:
# - buildkite-agent pipeline upload .buildkite/npu/test-npu-nightly.yml
# agents:
diff --git a/.buildkite/npu/test-npu-nightly.yml b/.buildkite/npu/test-npu-nightly.yml
index e049783ce..0c39a3f93 100644
--- a/.buildkite/npu/test-npu-nightly.yml
+++ b/.buildkite/npu/test-npu-nightly.yml
@@ -4,10 +4,10 @@ steps:
depends_on: upload-nightly-pipeline
if: >-
build.env("NIGHTLY") == "1" ||
- build.pull_request.labels includes "nightly-test" ||
- build.pull_request.labels includes "omni-test"
+ build.pull_request.labels includes "nightly-test"
steps:
- label: ":full_moon: Omni · Perf Test · No Async Chunk"
+ source_file_dependencies: omni_qwen3_omni_perf
key: nightly-omni-performance-no-async-chunk
timeout_in_minutes: 180
mirror_hardwares: a3_npu_4
@@ -24,6 +24,7 @@ steps:
buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
exit $$EXIT
- label: ":full_moon: Omni · Perf Test · Async Chunk"
+ source_file_dependencies: omni_qwen3_omni_perf
key: nightly-omni-performance-async-chunk
timeout_in_minutes: 180
mirror_hardwares: a3_npu_4
@@ -40,6 +41,7 @@ steps:
buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
exit $$EXIT
- label: ":full_moon: Omni · MiniCPM-o 4.5 Test"
+ source_file_dependencies: omni_minicpmo_4_5_function
mirror_hardwares: a3_npu_2
env:
VLLM_WORKER_MULTIPROC_METHOD: spawn
@@ -48,6 +50,7 @@ steps:
commands:
- pytest -s -v tests/e2e/offline_inference/test_minicpmo_4_5.py tests/e2e/online_serving/test_minicpmo_4_5.py tests/e2e/online_serving/test_minicpmo_4_5_expansion.py -m 'full_model' --run-level 'full_model'
- label: ":full_moon: Omni · MiniCPM-o 4.5 · Duplex Test"
+ source_file_dependencies: omni_minicpmo_4_5_duplex_function
timeout_in_minutes: 180
mirror_hardwares: a3_npu_2
env:
@@ -62,6 +65,7 @@ steps:
-m "npu and A3 and omni"
--run-level full_model
- label: ":full_moon: Omni · MiniCPM-o 4.5 · Accuracy Test"
+ source_file_dependencies: omni_minicpmo_4_5_accuracy
timeout_in_minutes: 180
mirror_hardwares: a3_npu_2
env:
@@ -82,6 +86,7 @@ steps:
buildkite-agent artifact upload "tests/e2e/accuracy/minicpmo_4_5/results/*.json"
exit $$EXIT
- label: ":full_moon: Omni · MiniCPM-o 4.5 · Perf Test"
+ source_file_dependencies: omni_minicpmo_4_5_perf
key: nightly-omni-performance-minicpmo-4-5-npu
timeout_in_minutes: 180
mirror_hardwares: a3_npu_2
@@ -98,6 +103,7 @@ steps:
buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
exit $$EXIT
- label: ":full_moon: Omni · MiniCPM-o 4.5 · Perf Test (A2)"
+ source_file_dependencies: omni_minicpmo_4_5_perf
key: nightly-omni-performance-minicpmo-4-5-npu-a2
timeout_in_minutes: 180
mirror_hardwares: a2b3_npu_1
@@ -115,6 +121,7 @@ steps:
buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
exit $$EXIT
- label: ":full_moon: Omni · MiniCPM-o 4.5 · Duplex Seed-TTS Perf Test"
+ source_file_dependencies: omni_minicpmo_4_5_duplex_perf
key: nightly-omni-performance-minicpmo-4-5-duplex-seed-tts-npu
timeout_in_minutes: 180
mirror_hardwares: a3_npu_2
@@ -133,9 +140,10 @@ steps:
- group: ":card_index_dividers: TTS Model Test"
key: nightly-tts-test-group
depends_on: upload-nightly-pipeline
- if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test" || build.pull_request.labels includes "tts-test"
+ if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test"
steps:
- label: ":full_moon: TTS · Perf Test"
+ source_file_dependencies: tts_qwen3_tts_perf
timeout_in_minutes: 180
mirror_hardwares: a3_npu_2
env:
@@ -151,15 +159,15 @@ steps:
EXIT=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
exit $$EXIT
- - group: ":card_index_dividers: Diffusion I2I Model Test"
- key: nightly-HunyuanImage3-x2i-test-group
+ - group: ":card_index_dividers: Diffusion X2I(&A&T) Model Test"
+ key: nightly-diffusion-x2iat-group
depends_on: upload-nightly-pipeline
if: >-
build.env("NIGHTLY") == "1" ||
- build.pull_request.labels includes "nightly-test" ||
- build.pull_request.labels includes "diffusion-x2iat-test"
+ build.pull_request.labels includes "nightly-test"
steps:
- label: ":full_moon: HunyuanImage3-DIT · Accuracy Test"
+ source_file_dependencies: diffusion_hunyuan_image3_accuracy
timeout_in_minutes: 180
mirror_hardwares: a3_npu_4
env:
@@ -169,15 +177,8 @@ steps:
DIFFUSION_ATTENTION_BACKEND: TORCH_SDPA
commands:
- pytest -s -v tests/e2e/accuracy/test_hunyuan_image3_pixel_accuracy.py -m "full_model and A3" --run-level full_model
- - group: ":card_index_dividers: Diffusion Benchmark Perf Test"
- key: nightly-diffusion-benchmark-group
- depends_on: upload-nightly-pipeline
- if: >-
- build.env("NIGHTLY") == "1" ||
- build.pull_request.labels includes "nightly-test" ||
- build.pull_request.labels includes "diffusion-x2iat-test"
- steps:
- label: ":full_moon: Diffusion Perf · HunyuanImage3 TP=2 CFGP=2"
+ source_file_dependencies: diffusion_hunyuan_image3_perf
timeout_in_minutes: 180
mirror_hardwares: a3_npu_4
env:
@@ -194,6 +195,7 @@ steps:
buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log"
exit $$EXIT
- label: ":full_moon: Diffusion Perf · HunyuanImage3 TP=2 SP=2"
+ source_file_dependencies: diffusion_hunyuan_image3_perf
timeout_in_minutes: 180
mirror_hardwares: a3_npu_4
env:
@@ -210,6 +212,7 @@ steps:
buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log"
exit $$EXIT
- label: ":full_moon: Diffusion Perf · HunyuanImage3 TP=4"
+ source_file_dependencies: diffusion_hunyuan_image3_perf
timeout_in_minutes: 180
mirror_hardwares: a3_npu_4
env:
@@ -230,10 +233,41 @@ steps:
depends_on: upload-nightly-pipeline
if: >-
build.env("NIGHTLY") == "1" ||
- build.pull_request.labels includes "nightly-test" ||
- build.pull_request.labels includes "diffusion-x2v-test"
+ build.pull_request.labels includes "nightly-test"
steps:
- - label: ":full_moon: Diffusion X2V · Function Test"
+ - label: ":full_moon: Diffusion X2V · HunyuanVideo-1.5 Function Test"
+ source_file_dependencies: diffusion_hunyuan_video_function
+ timeout_in_minutes: 180
+ mirror_hardwares: a2b3_npu_4
+ env:
+ VLLM_WORKER_MULTIPROC_METHOD: spawn
+ VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1"
+ HF_TOKEN: "${HF_TOKEN}"
+ HF_HUB_OFFLINE: "1"
+ commands:
+ - pytest -s -v tests/e2e/online_serving/test_hunyuan_video_15_expansion.py -m "npu and A2" --run-level "full_model"
+ - label: ":full_moon: Diffusion X2V · HunyuanVideo-1.5 Perf Test"
+ source_file_dependencies: diffusion_hunyuan_video_perf
+ key: nightly-diffusion-x2v-performance-hunyuanvideo15
+ timeout_in_minutes: 180
+ mirror_hardwares: a3_npu_2
+ env:
+ VLLM_WORKER_MULTIPROC_METHOD: spawn
+ VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1"
+ HF_TOKEN: "${HF_TOKEN}"
+ HF_HUB_OFFLINE: "1"
+ DIFFUSION_ATTENTION_BACKEND: TORCH_SDPA
+ commands:
+ - export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results
+ - |
+ set +e
+ pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py -m "npu and A3" --test-config-file tests/dfx/perf/tests/test_hunyuanvideo15_t2v_vllm_omni.json
+ EXIT=$$?
+ buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json"
+ buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log"
+ exit $$EXIT
+ - label: ":full_moon: Diffusion X2V · Wan 2.2 Function Test"
+ source_file_dependencies: diffusion_wan22_function
mirror_hardwares: a2b3_npu_4
env:
VLLM_WORKER_MULTIPROC_METHOD: spawn
@@ -241,7 +275,8 @@ steps:
HF_TOKEN: "${HF_TOKEN}"
commands:
- pytest -s -v tests/e2e/online_serving/test_wan22_expansion.py -k npu_i2v --run-level "full_model"
- - label: ":full_moon: Diffusion X2V · Perf Test"
+ - label: ":full_moon: Diffusion X2V · Wan 2.2 Perf Test"
+ source_file_dependencies: diffusion_wan22_perf
key: nightly-diffusion-x2v-performance
timeout_in_minutes: 180
mirror_hardwares: a3_npu_4
diff --git a/.claude/skills/vllm-omni-test/SKILL.md b/.claude/skills/vllm-omni-test/SKILL.md
index 2d4226788..f0fb009d5 100644
--- a/.claude/skills/vllm-omni-test/SKILL.md
+++ b/.claude/skills/vllm-omni-test/SKILL.md
@@ -468,10 +468,10 @@ When the user asks for **invalid parameter validation**, **invalid request bodie
| **Invalid parameters Test · H100** | `pytest -s -v tests/dfx/reliability/invalid_param_test/ -m "slow and H100"` | Diffusion / omni / video invalid-param tests with `H100` hardware mark |
| **Invalid parameters Test · L4** | `pytest -s -v tests/dfx/reliability/invalid_param_test/ -m "slow and L4"` | TTS / lighter models marked `L4` |
-- **Trigger:** `build.env("WEEKLY") == "1"` or PR label `weekly-test`.
+- **Trigger:** `build.env("WEEKLY") == "1"` (all weekly jobs) or PR label `weekly-test` (Reliability / Perf, then filtered by `source_file_dependencies`).
- **Default:** extending an existing `invalid_param_test` script needs **no YAML edit** — the weekly steps already sweep the whole directory.
- **Edit YAML only** when adding a **new hardware queue**, a **new top-level script** that must run in isolation, or a **model-specific weekly shard** (mirror neighboring reliability steps).
-- Weekly steps **do not** use `source_file_dependencies`.
+- Weekly Reliability / Perf jobs declare `source_file_dependencies`. Filtering applies only on the `weekly-test` label; `WEEKLY=1` on `main` keeps every job in those groups. CPU coverage (`WEEKLY=1` only) and the E2E Tests group (`NON_CRITICAL=1` only) do **not** use this field.
**Example — append to `test_invalid_image_generation.py`:**
@@ -644,7 +644,7 @@ If the test is not already collected by an existing pipeline command (for exampl
| **L2** | `test-ready.yml` with `core_model` + `source_file_dependencies` | merge / nightly pipelines |
| **Invalid param** | `test-weekly.yml` — **Invalid parameters Test** group (`-m "slow and H100"` / `-m "slow and L4"`). Usually **no YAML edit** when appending cases to existing `invalid_param_test` scripts | `test-ready.yml`, `test-merge.yml`, `test-nightly.yml` |
-`test-nightly.yml` shards use **explicit pytest file paths** in `commands` (and PR labels like `diffusion-x2iat-test`); they generally **do not** use `source_file_dependencies`. Only suggest merge/ready YAML when the requested level is L3/L2 (or the user explicitly asks for multi-level CI).
+`test-nightly.yml` shards use **explicit pytest file paths** in `commands` plus `source_file_dependencies`; PR trigger is `nightly-test` (path filter), or `NIGHTLY=1` on `main` (all jobs). Only suggest merge/ready YAML when the requested level is L3/L2 (or the user explicitly asks for multi-level CI).
**`test-nightly.yml` top-level groups** (each uses `full_model` + type marker in pytest `-m`):
@@ -653,7 +653,7 @@ If the test is not already collected by an existing pipeline command (for exampl
| **Omni Model Test** | `omni` | Function / doc / accuracy / **perf** for Qwen-Omni family |
| **TTS Model Test** | `tts` | `-m "full_model and L4 and tts"` (and H100 variants when enabled); **Perf Test** uses `run_benchmark.py` + `test_tts.json` |
| **Diffusion X2I(&A&T) Model Test** | `diffusion` | Image/audio/text diffusion; explicit file shards for **Function**; **Perf Test · <Model>** uses `run_diffusion_benchmark.py` + JSON config |
-| **Diffusion X2V Model Test** | `diffusion` | Video-only (Wan, HunyuanVideo, …); PR label `diffusion-x2v-test`; separate **Perf** steps when in-tree |
+| **Diffusion X2V Model Test** | `diffusion` | Video-only (Wan, HunyuanVideo, …); separate **Perf** steps when in-tree |
Guidelines when editing YAML:
diff --git a/.claude/skills/vllm-omni-test/references/test-routing.md b/.claude/skills/vllm-omni-test/references/test-routing.md
index 5b90957d6..a61324e6d 100644
--- a/.claude/skills/vllm-omni-test/references/test-routing.md
+++ b/.claude/skills/vllm-omni-test/references/test-routing.md
@@ -37,7 +37,7 @@ Every model-centric e2e test must declare **exactly one** type marker:
| **Diffusion X2I(&A&T) Model Test** | x2**i** / x2**a** / x2**t** — image, audio, text (non-video) | Qwen-Image*, BAGEL, FLUX, SD3, Z-Image, LongCat, DreamZero |
| **Diffusion X2V Model Test** | x2**v** — video only | Wan2.2, HunyuanVideo 1.5, Wan VACE |
-PR labels for selective nightly runs: `diffusion-x2iat-test`, `diffusion-x2v-test` (plus `nightly-test` / `NIGHTLY=1`).
+PR label for L4 on a branch: `nightly-test` (jobs then filtered by `source_file_dependencies`). Scheduled `main` uses `NIGHTLY=1` and runs every nightly job. L5 Reliability / Perf use `weekly-test` with the same path filter; CPU coverage and the weekly E2E group are env-only (`WEEKLY=1` / `NON_CRITICAL=1`) and are not source-filtered.
## Level and Marker Mapping
@@ -238,7 +238,7 @@ pytest -s -v dfx/reliability/invalid_param_test/ -m "slow and L4"
pytest -s -v dfx/reliability/invalid_param_test/test_invalid_image_generation.py::test_images_generations_invalid_requests -m "slow and H100"
```
-**Trigger:** `WEEKLY=1` or PR label `weekly-test`. Appending cases to existing scripts usually needs **no YAML edit** (directory sweep). No `source_file_dependencies` on weekly steps.
+**Trigger:** `WEEKLY=1` (all jobs) or PR label `weekly-test` (Reliability / Perf, filtered by `source_file_dependencies`). Appending cases to existing scripts usually needs **no YAML edit** (directory sweep).
### Platform-targeted examples
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index c92c7b22f..e77f70914 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -67,6 +67,11 @@
# File-level overrides within entrypoints/
/vllm_omni/entrypoints/openai/errors.py @alex-jw-brooks @NickCao
/vllm_omni/entrypoints/cli/benchmark/ @alex-jw-brooks @Bounty-hunter
+# Shared wire codec: protocol/realtime (OpenAI Realtime GA) and protocol/duplex
+# (vLLM-Omni's extension of it) — RFC #6592 P0a. Layer-neutral by design — the
+# engine depends on it — so it is owned by the serving-boundary document rather
+# than by any one consumer. — docs/design/module/entrypoints.md
+/vllm_omni/protocol/ @alex-jw-brooks @linyueqian @NickCao
# Error contracts — docs/design/module/error_contracts.md
/vllm_omni/errors.py @alex-jw-brooks @NickCao
diff --git a/README.md b/README.md
index 5063f9a3f..2cd6a574a 100644
--- a/README.md
+++ b/README.md
@@ -58,7 +58,7 @@ vLLM-Omni is flexible and easy to use with:
vLLM-Omni seamlessly supports most popular open-source models on HuggingFace, including:
- **Omni-modality models** (e.g. Qwen3-Omni, MiniCPM-o 4.5, Cosmos3, HunyuanImage, BAGEL)
-- **TTS models** (e.g. Qwen3-TTS, IndexTTS 2.5, dots.tts, CosyVoice3)
+- **TTS models** (e.g. Qwen3-TTS, IndexTTS 2.5, CosyVoice3)
- **Diffusion models** — image, video, and audio generation (e.g. MiniMax H3, LTX-2.5, SANA-Video, Wan2.2)
- **Robot-policy and action models** (e.g. π0, GR00T-N1.7, DreamZero-DROID, InternVLA-A1)
diff --git a/apps/ComfyUI-vLLM-Omni/README.md b/apps/ComfyUI-vLLM-Omni/README.md
index cbd53a9a0..f33e26b40 100644
--- a/apps/ComfyUI-vLLM-Omni/README.md
+++ b/apps/ComfyUI-vLLM-Omni/README.md
@@ -13,7 +13,6 @@ It can send model inference requests to either a locally running vLLM-Omni servi
> [!TIP]
> If you run both ComfyUI and vLLM-Omni on the same device, you can create separate virtual environments and use different Python versions for them.
-
## Installation
Copy this folder to the `custom_nodes` subfolder of your ComfyUI installation. Your directory should look like `ComfyUI/custom_nodes/ComfyUI-vLLM-Omni`.
@@ -21,9 +20,10 @@ Copy this folder to the `custom_nodes` subfolder of your ComfyUI installation. Y
If you are running ComfyUI during copying, you should restart ComfyUI to load this extension.
> [!TIP]
-> You can use utility websites such as https://download-directory.github.io/ to download a subdirectory of a repo. Also checkout community discussions (e.g., https://stackoverflow.com/questions/7106012/download-a-single-folder-or-directory-from-a-github-repository) for more info.
+> You can use utility websites such as to download a subdirectory of a repo. Also checkout community discussions (e.g., ) for more info.
On the device and virtual environment you run ComfyUI, launch ComfyUI with
+
```bash
cd ComfyUI
@@ -35,6 +35,7 @@ python main.py --cpu
```
On the device and virtual environment you run vLLM-Omni, start a model service with
+
```bash
vllm serve The_Model_ID_to_Serve --omni --port 8000
```
@@ -48,14 +49,30 @@ This extension offers the following nodes based on the output modalities (at **C
- **Generate Image** for text-to-image and image-to-image tasks
- **Generate Video** for text-to-video, first-frame/image-to-video, and reference-conditioned video
+- **FastH3 Deployment** for routing text-to-video requests to a MiniMax-H3 server with FastH3 fused at startup
- **Multimodality Understanding** for multimodality-to-text and multimodality-to-audio tasks
- **TTS** and **TTS Voice Clone** for TTS tasks
+- **Generate Music** for lyrics-and-description-to-music generation
This extension also offers example workflows (at **ComfyUI sidebar -> Templates -> vLLM-Omni**)
> [!NOTE]
> The node UI and feature designs are intended to match vLLM-Omni online serving interfaces. It cannot offer more than what the interfaces support.
+Every node carries the vLLM-Omni mark in its title bar and is tinted by what it outputs, so a graph is readable at a glance:
+
+| Colour | Nodes | What they produce |
+| --- | --- | --- |
+| Blue | Generate Image, Generate Video, Multimodality Understanding, TTS, TTS Voice Clone | A generated image, video, audio, or text. These are the only nodes that reach a server. |
+| Amber | AR / Diffusion / Multi-Stage Sampling Params | Sampling parameters that apply to any model |
+| Purple | Qwen TTS Params, Wan Video Params, MiniMax-H3 Video Params | Parameters that only one model family accepts |
+| Red | LoRA, FastH3 Deployment | Which weights the server is expected to have loaded |
+| Teal | Video References | Reference media |
+
+Recolouring a node by hand (right click -> Colors) overrides its tint, and the choice is kept.
+
+**Generate Video** takes a clip length in seconds (`duration`), not a frame count. Frames stay the wire unit and are derived with the node's `fps`, so the length is always measured against the rate that is actually served; models that accept only certain frame counts still round to their own lattice server-side. Graphs saved before this widget existed stored `num_frames` in its place and are converted on load, using the fps recorded alongside it -- the browser console names every node it rewrites.
+
To build a simple workflow yourself,
- Drag a generation node onto the canvas.
@@ -117,11 +134,134 @@ You can configure per-stage sampling parameters for multi-stage models.
> [!TIP]
-> Connect a **frame** image for first-frame / image-to-video (e.g., Wan I2V, MiniMax-H3 FL2VA).
+> Connect **frame** for backward-compatible first-frame / image-to-video behavior. For MiniMax-H3 FL2VA,
+> connect **first_frame**, **last_frame**, or both to condition the start frame, end frame, or both.
>
> For reference-conditioned generation (MiniMax-H3 Ref2VA), connect a **Video References** node instead.
>
-> Do not use `frame` and `references` together. Task routing is automatic from which inputs you connect.
+> Do not combine `frame` with `first_frame` or `last_frame`, and do not combine any frame input with
+> `references`. Task routing is automatic from which inputs you connect.
+
+For MiniMax-H3 Ref2VA, **Video References** accepts up to 9 images (`image_1`–`image_9`),
+3 videos (`video_1`–`video_3`), and 3 audio clips (`audio_1`–`audio_3`), with at most
+12 connected inputs in total. Any mixture containing at least one image or video
+is supported; empty and audio-only references are rejected. For example, you can
+combine 6 images, 3 videos, and 3 audio clips in one request.
+
+Within each media type, references follow slot-number order, skipping unconnected
+slots. For example, connecting `image_2` and `image_9` sends `image_2` as the first
+image and `image_9` as the second. Each image slot uses the first image in its batch.
+Existing connections to `image_1`, `image_2`, `audio_1`, `audio_2`, `video_1`, and
+`video_2` remain valid in saved workflows.
+
+#### MiniMax-H3 Reference to Video
+
+Load [MiniMax-H3 Reference to Video](example_workflows/vLLM-Omni%20MiniMax-H3%20Reference%20to%20Video.json)
+from **Templates → ComfyUI-vLLM-Omni**, or drag the JSON onto the canvas.
+It adapts the [official ComfyUI R2V workflow](https://github.com/Comfy-Org/workflow_templates/blob/main/templates/video_minimax_h3_r2v.json)
+to remote vLLM-Omni execution. Model loading, sampling, and VAE decoding run on
+the server; ComfyUI loads references and saves the returned video.
+
+Use an extension version containing the audio-preserving output fix from
+[#7456](https://github.com/vllm-project/vllm-omni/pull/7456). Older versions can
+drop the server's audio while decoding the response.
+
+1. Start a Ref2VA-capable service using the [MiniMax-H3 recipe](../../recipes/MiniMaxAI/MiniMax-H3.md).
+ Set **Generate Video** to its `/v1` URL and served model name.
+2. Select your own image in **Load Image**. The template's filenames are placeholders
+ relative to ComfyUI's input directory; no sample assets or model weights are bundled.
+3. Connect **Load Video** and **Load Audio** when needed. Duplicate the loaders to
+ fill more reference slots, or disconnect the image when using video-only input.
+ Keep the references connected to **Generate Video** and leave `frame` disconnected.
+4. Match prompt tags such as ``, ``, and `` to the
+ connected references, counting each media type separately and skipping empty slots.
+5. Run the workflow. **Save Video** writes an MP4 under `output/video/` and preserves
+ the generated audio when the audio-output prerequisite is installed.
+
+The default is **1344×768, 24 FPS, 124 frames** (about 5.17 seconds), 50 sampling
+points, video flow shift 12, audio flow shift 3, and seed 42. The duration widget
+is set to 5.167 seconds, which converts to 124 frames at 24 FPS. Other H3 canvas presets
+are 1024×768, 768×768, 768×1024, and 768×1344. Keep frame counts at `17k+5` within
+the 4–15 second output range; examples are 107, 124, 209, and 345 frames.
+Reference video/audio clips must each be 2–15 seconds, with at most 15 seconds of
+reference video and at most 15 seconds of standalone reference audio per request.
+
+For **Ref2VA Turbo**, use the same template. Configure a Ref2VA-only server with
+`--lora-backend peft --lora-path "$TURBO_LORA" --task-type ref2va`, following the
+recipe's hardware setup. For `minimax_h3_ref2v_turbo_4step_v0.1_bf16.safetensors`,
+set **LoRA** `local_path` to that file's path on the server and connect it to
+**Generate Video**. Use `num_inference_steps=5`, `flow_shift=12`,
+`audio_flow_shift=3`, and LoRA `scale=1`. The API counts sigma points, so five
+points produce four denoiser evaluations. Use the Diffusers artifact, not its
+`_comfyui_` export. To return to base mode, disconnect LoRA and restore 50 steps.
+The 8-step v1.0 Ref2VA adapter instead requires nine sampling points and video
+flow shift 6. FastH3 is a separate startup-fused T2VA deployment.
+
+Validate the template's wiring and defaults locally with:
+
+```bash
+python -m pytest tests/e2e/features/comfyui/test_h3_reference_workflow.py -q
+```
+
+For a real-model check, run the workflow against H3 and inspect the saved file:
+
+```bash
+ffprobe -v error -show_entries stream=codec_type,width,height,r_frame_rate,sample_rate,channels \
+ -show_entries format=duration -of json "$OUTPUT_MP4"
+```
+
+Check for 24 FPS video, stereo audio, the requested canvas, and aligned duration;
+also play the result to assess reference conditioning and audio/video synchronization.
+Record the server commit, model/adapter, input assets, prompt, settings, and output
+alongside the result. Local schema or mocked-server checks do not establish H3
+generation quality or replace this real-model validation.
+
+### MiniMax H3 Text to Video
+
+Import [the H3 text-to-video template](example_workflows/MiniMax_H3_Text_to_Video.json)
+for remote video generation with audio. It includes connected base presets and
+optional Turbo sampling, H3 parameters, and Remote LoRA nodes. See the
+[workflow guide](docs/minimax-h3-t2v.md) for server setup, artifact-specific Turbo
+settings, and saved-video/audio validation. Set Remote LoRA’s `local_path` to
+the downloaded Turbo artifact on your server before enabling Turbo.
+
+#### H3 video upscale (WF-07)
+
+The **vLLM-Omni MiniMax H3 Video Upscale** template generates video remotely, upscales it with SeedVR2, and saves the original and upscaled videos with the generated audio and FPS. See [workflow setup](docs/wf07-h3-upscale.md).
+
+#### FastH3 text-to-video
+
+FastH3 is fused into MiniMax-H3 when the vLLM-Omni server starts; it is not a request-switchable LoRA. Download one adapter variant and start a non-offloaded FL2VA server. For the Dense / Data-Free profile:
+
+```bash
+export FASTH3_DIR=/path/to/fasth3
+hf download FastVideo/FastVideo-FastH3-4-step-Preview-v1-LoRA \
+ dense-datafree/adapter_model.safetensors --local-dir "${FASTH3_DIR}"
+
+vllm serve /path/to/MiniMax-H3 \
+ --omni \
+ --task-type fl2va \
+ --lora-path "${FASTH3_DIR}/dense-datafree/adapter_model.safetensors" \
+ --port 8000
+```
+
+For the VSA / Data-Free profile, download `vsa-datafree/adapter_model.safetensors`, use that file as `--lora-path`, and add:
+
+```bash
+--diffusion-attention-backend FASTVIDEO_VSA \
+--fastvideo-vsa-topk 64
+```
+
+The VSA profile also requires a compatible `fastvideo-kernel` installation. See the [MiniMax-H3 FastH3 recipe](../../recipes/MiniMaxAI/MiniMax-H3.md#fasth3-adapter) for the full serving contract, supported parallel layouts, and kernel requirements.
+
+Open the **vLLM-Omni FastH3 Text to Video** template, then:
+
+- Set the server URL and served model name on **FastH3 Deployment**.
+- Connect its output to **Generate Video → fast_h3**. When connected, the deployment node's URL and model take precedence over the corresponding Generate Video widgets.
+- Keep `frame`, `references`, **LoRA**, and **MiniMax-H3 Video Params** disconnected. FastH3 Preview v1 supports T2VA only, is already fused, and owns both flow shifts.
+- A connected **Diffusion Sampling Params** node may set seed and other ordinary sampling options. The integration always enforces four denoising steps and 24 FPS for FastH3.
+
+The node records which server the workflow targets; it does not start one, nor switch adapters or attention backends on a running server.
### TTS (e.g., Qwen TTS series)
@@ -137,6 +277,17 @@ You can configure per-stage sampling parameters for multi-stage models.
> [!TIP]
> There is a dedicated node for VoiceClone tasks with reference audio input. Other simple text-to-speech tasks should use the regular TTS node.
+### Music generation (e.g., MiniMax Music 3)
+
+(Also available at **ComfyUI sidebar->Template->vLLM-Omni->vLLM-Omni Music Generation**)
+
+
+
+
+
+
+
+
### Chaining multiple model services
(Also available at **ComfyUI sidebar->Template->vLLM-Omni->vLLM-Omni Chaining Services**)
@@ -152,6 +303,8 @@ You can configure per-stage sampling parameters for multi-stage models.
Follow the [development convention and rules of vLLM-Omni](https://docs.vllm.ai/projects/vllm-omni/en/latest/contributing/).
+Node tints and the title-bar mark are applied in `web/main.js`, keyed off each node's declared output types rather than a list of node names. A new node that returns an existing type is themed with no front-end change; a new output type needs one entry in `FAMILY_BY_OUTPUT` there.
+
## Limitation and Non-Goals
- Single server mode only. No automatic load balancing or failover.
@@ -178,11 +331,11 @@ Whenever you find an issue or problem, please
Features
-- https://github.com/dougbtv/comfyui-vllm-omni/ The official reference implementation for ComfyUI integration with vLLM-Omni's DALL-E compatible image generation API.
-- https://github.com/Comfy-Org/ComfyUI/tree/master/comfy_extras ComfyUI's built-in node implementations.
+- The official reference implementation for ComfyUI integration with vLLM-Omni's DALL-E compatible image generation API.
+- ComfyUI's built-in node implementations.
UI/UX design references
-- https://github.com/sgl-project/sglang/pull/15271 SGLang Diffusion's official ComfyUI integration for image and video generation.
-- https://github.com/SXQBW/ComfyUI-Qwen-Omni A third party ComfyUI integration for Qwen Omni series.
-- https://github.com/flybirdxx/ComfyUI-Qwen-TTS https://github.com/DarioFT/ComfyUI-Qwen3-TTS Third party ComfyUI integrations for Qwen TTS series.
+- SGLang Diffusion's official ComfyUI integration for image and video generation.
+- A third party ComfyUI integration for Qwen Omni series.
+- Third party ComfyUI integrations for Qwen TTS series.
diff --git a/apps/ComfyUI-vLLM-Omni/__init__.py b/apps/ComfyUI-vLLM-Omni/__init__.py
index 268107899..8c1b03d87 100644
--- a/apps/ComfyUI-vLLM-Omni/__init__.py
+++ b/apps/ComfyUI-vLLM-Omni/__init__.py
@@ -1,4 +1,8 @@
-"""Top-level package for comfyui_vllm_omni.""" # noqa: N999 # This is not a python library intended to be imported
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+# ruff: noqa: N999
+
+"""Top-level package for comfyui_vllm_omni.""" # This is not a Python library intended to be imported.
__all__ = [
"NODE_CLASS_MAPPINGS",
@@ -13,7 +17,9 @@ __version__ = "0.0.1"
from .comfyui_vllm_omni.nodes import (
VLLMOmniARSampling,
VLLMOmniDiffusionSampling,
+ VLLMOmniFastH3Deployment,
VLLMOmniGenerateImage,
+ VLLMOmniGenerateMusic,
VLLMOmniGenerateVideo,
VLLMOmniMiniMaxH3Params,
VLLMOmniQwenTTSParams,
@@ -33,6 +39,7 @@ NODE_CLASS_MAPPINGS = {
"VLLMOmniGenerateVideo": VLLMOmniGenerateVideo,
"VLLMOmniUnderstanding": VLLMOmniUnderstanding,
"VLLMOmniTTS": VLLMOmniTTS,
+ "VLLMOmniGenerateMusic": VLLMOmniGenerateMusic,
"VLLMOmniVoiceClone": VLLMOmniVoiceClone,
"VLLMOmniVideoReferences": VLLMOmniVideoReferences,
# === Params ===
@@ -40,6 +47,7 @@ NODE_CLASS_MAPPINGS = {
"VLLMOmniDiffusionSampling": VLLMOmniDiffusionSampling,
"VLLMOmniSamplingParamsList": VLLMOmniSamplingParamsList,
"VLLMOmniRemoteLoRA": VLLMOmniRemoteLoRA,
+ "VLLMOmniFastH3Deployment": VLLMOmniFastH3Deployment,
"VLLMOmniQwenTTSParams": VLLMOmniQwenTTSParams,
"VLLMOmniWanParams": VLLMOmniWanParams,
"VLLMOmniMiniMaxH3Params": VLLMOmniMiniMaxH3Params,
@@ -52,6 +60,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"VLLMOmniGenerateVideo": "Generate Video",
"VLLMOmniUnderstanding": "Multimodality Understanding",
"VLLMOmniTTS": "TTS (Text to Speech)",
+ "VLLMOmniGenerateMusic": "Generate Music",
"VLLMOmniVoiceClone": "TTS Voice Cloning",
"VLLMOmniVideoReferences": "Video References",
# === Params ===
@@ -59,6 +68,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"VLLMOmniDiffusionSampling": "Diffusion Sampling Params",
"VLLMOmniSamplingParamsList": "Multi-Stage Sampling Params List",
"VLLMOmniRemoteLoRA": "LoRA",
+ "VLLMOmniFastH3Deployment": "FastH3 Deployment",
"VLLMOmniQwenTTSParams": "Qwen TTS Params",
"VLLMOmniWanParams": "Wan Video Params",
"VLLMOmniMiniMaxH3Params": "MiniMax-H3 Video Params",
diff --git a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/nodes.py b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/nodes.py
index 8fd00cd9c..6d5006a11 100644
--- a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/nodes.py
+++ b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/nodes.py
@@ -1,3 +1,6 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+
from typing import Literal
import torch
@@ -7,9 +10,13 @@ from .utils.api_client import VLLMOmniClient
from .utils.logger import get_logger
from .utils.models import lookup_model_spec
from .utils.types import (
+ MAX_REFERENCE_AUDIOS,
+ MAX_REFERENCE_IMAGES,
+ MAX_REFERENCE_VIDEOS,
AudioFormat,
AutoregressionSamplingParams,
DiffusionSamplingParams,
+ FastH3Deployment,
MiniMaxH3ModelSpecificParams,
QwenTTSModelSpecificParams,
VideoReferences,
@@ -22,6 +29,23 @@ from .utils.validators import (
logger = get_logger(__name__)
+FASTH3_INFERENCE_STEPS = 4
+FASTH3_FPS = 24
+# A FastH3 deployment may expose H3 under any --served-model-name, so the payload
+# spec has to be named outright rather than recovered from that alias.
+FASTH3_SPEC_MODEL = "MiniMax-H3"
+
+
+def _resolve_fast_h3_deployment(deployment: dict) -> tuple[str, str]:
+ if not isinstance(deployment, dict):
+ raise ValueError("FastH3 deployment must be provided by a FastH3 Deployment node.")
+
+ url = str(deployment.get("url") or "").strip().rstrip("/")
+ model = str(deployment.get("model") or "").strip()
+ if not url or not model:
+ raise ValueError("FastH3 deployment requires both URL and model.")
+ return url, model
+
class _VLLMOmniGenerateBase:
"""Base class for vLLM-Omni generation nodes with shared functionality."""
@@ -162,14 +186,30 @@ class VLLMOmniGenerateVideo(_VLLMOmniGenerateBase):
"width": ("INT", {"default": 832, "min": 1}),
"height": ("INT", {"default": 480, "min": 1}),
"fps": ("INT", {"default": 16, "min": 1}),
- "num_frames": ("INT", {"default": 41, "min": 1}),
+ "duration": (
+ "FLOAT",
+ {
+ "default": 4.0,
+ "min": 0.1,
+ "step": 0.1,
+ "round": 0.001,
+ "tooltip": (
+ "Clip length in seconds, converted to frames with the fps above. "
+ "Models that only accept certain frame counts (e.g. MiniMax-H3) round to "
+ "their own lattice, so the served clip can be slightly longer than requested."
+ ),
+ },
+ ),
},
"optional": {
"frame": ("IMAGE",),
+ "first_frame": ("IMAGE",),
+ "last_frame": ("IMAGE",),
"references": ("VIDEO_REFERENCES",),
"sampling_params": ("SAMPLING_PARAMS",),
"lora": ("REMOTE_LORA",),
"model_params": ("VIDEO_PARAMS",),
+ "fast_h3": ("FASTH3_DEPLOYMENT",),
},
}
@@ -178,12 +218,30 @@ class VLLMOmniGenerateVideo(_VLLMOmniGenerateBase):
FUNCTION = "generate"
@classmethod
- def VALIDATE_INPUTS(cls, url, model, frame=None, references=None, **_kwargs) -> str | Literal[True]:
+ def VALIDATE_INPUTS(
+ cls,
+ url,
+ model,
+ frame=None,
+ first_frame=None,
+ last_frame=None,
+ references=None,
+ fast_h3=None,
+ **_kwargs,
+ ) -> str | Literal[True]:
base = super().VALIDATE_INPUTS(url, model)
if base is not True:
return base
if frame is not None and references is not None:
return "Provide only one of frame or references, not both."
+ if frame is not None and (first_frame is not None or last_frame is not None):
+ return "Provide either frame or first_frame/last_frame, not both."
+ if references is not None and (first_frame is not None or last_frame is not None):
+ return "Provide either first_frame/last_frame or references, not both."
+ if fast_h3 is not None and any(value is not None for value in (frame, first_frame, last_frame, references)):
+ return (
+ "FastH3 Preview supports T2VA only; disconnect frame, first_frame, last_frame, and references inputs."
+ )
return True
async def generate(
@@ -194,20 +252,71 @@ class VLLMOmniGenerateVideo(_VLLMOmniGenerateBase):
width: int,
height: int,
fps: int,
- num_frames: int,
+ duration: float,
negative_prompt: str | None = None,
frame: torch.Tensor | None = None,
+ first_frame: torch.Tensor | None = None,
+ last_frame: torch.Tensor | None = None,
references: dict | None = None,
sampling_params: dict | list[dict] | None = None,
model_params: dict | None = None,
lora: dict | None = None,
+ fast_h3: dict | None = None,
**kwargs,
):
if kwargs:
logger.info("Uncaught kwargs: %s", kwargs)
logger.debug("Got sampling params: %s", sampling_params)
logger.debug("Got model params: %s", model_params)
- validate_model_and_sampling_params_types(model, sampling_params)
+
+ # Which spec builds the payload. Only a FastH3 deployment separates this
+ # from the served name; every other path keeps them equal.
+ spec_model = model
+
+ if fast_h3 is not None:
+ if any(value is not None for value in (frame, first_frame, last_frame, references)):
+ raise ValueError(
+ "FastH3 Preview supports T2VA only; disconnect frame, first_frame, "
+ "last_frame, and references inputs."
+ )
+ if lora is not None:
+ raise ValueError(
+ "FastH3 is already fused into the selected server; disconnect the request-level LoRA input."
+ )
+
+ url, model = _resolve_fast_h3_deployment(fast_h3)
+ logger.info("Using FastH3 deployment at %s", url)
+ fps = FASTH3_FPS
+ # The served name is whatever the operator passed to --served-model-name.
+ # Left alone, lookup_model_spec would miss H3 for an alias such as
+ # "fasth3", drop the params builder, and send a t2va request carrying no
+ # aspect_ratio -- which the server refuses.
+ spec_model = FASTH3_SPEC_MODEL
+
+ if sampling_params is None:
+ sampling_params = DiffusionSamplingParams()
+ elif isinstance(sampling_params, list):
+ if len(sampling_params) != 1:
+ raise ValueError("FastH3 expects a single diffusion sampling params group.")
+ sampling_params = sampling_params[0].__class__(sampling_params[0])
+ else:
+ sampling_params = sampling_params.__class__(sampling_params)
+ sampling_params["num_inference_steps"] = FASTH3_INFERENCE_STEPS
+
+ # FastH3 owns both modality shifts. Sending the ordinary H3 values
+ # from a connected H3 Params node would turn a deployment choice
+ # into a request-level override, which the server intentionally
+ # rejects when it differs from the fused adapter contract.
+ if model_params is not None:
+ model_params = model_params.__class__(model_params)
+ model_params.pop("flow_shift", None)
+ model_params.pop("audio_flow_shift", None)
+
+ # Frames stay the wire unit. Convert after the FastH3 branch above, so the
+ # duration is measured against the fps the server will actually apply.
+ num_frames = max(1, round(duration * fps))
+
+ validate_model_and_sampling_params_types(spec_model, sampling_params)
# Currently, all video generation models are single-stage diffusion models
if isinstance(sampling_params, list):
@@ -227,8 +336,11 @@ class VLLMOmniGenerateVideo(_VLLMOmniGenerateBase):
client = VLLMOmniClient(url)
output = await client.generate_video(
model=model,
+ spec_model=spec_model,
prompt=prompt,
frame=frame, # frame present => fl2va / Wan I2V
+ first_frame=first_frame,
+ last_frame=last_frame,
references=references,
width=width,
height=height,
@@ -418,6 +530,66 @@ class VLLMOmniTTS(_VLLMOmniGenerateBase):
return (audio,)
+class VLLMOmniGenerateMusic(_VLLMOmniGenerateBase):
+ """Generate a song from lyrics and a musical description with MiniMax Music 3."""
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "url": ("STRING", {"default": "http://localhost:8000/v1"}),
+ "model": ("STRING", {"default": "MiniMaxAI/MiniMax-Music3"}),
+ "instructions": (
+ "STRING",
+ {
+ "multiline": True,
+ "display_name": "caption",
+ "tooltip": "Music caption: describe genre, instruments, tempo and mood.",
+ },
+ ),
+ "lyrics": ("STRING", {"multiline": True}),
+ "max_duration_seconds": (
+ "FLOAT",
+ {
+ "default": 300.0,
+ "min": 1,
+ "max": 360,
+ "step": 0.01,
+ "tooltip": "Upper limit; rounded down to whole 25 Hz audio frames. May end earlier.",
+ },
+ ),
+ "seed": ("INT", {"default": 0, "min": 0, "max": 2**53 - 1, "control_after_generate": True}),
+ "response_format": (["wav", "mp3", "flac", "opus"],),
+ },
+ }
+
+ RETURN_TYPES = ("AUDIO",)
+ RETURN_NAMES = ("audio",)
+ FUNCTION = "generate"
+
+ async def generate(
+ self,
+ url: str,
+ model: str,
+ lyrics: str,
+ instructions: str,
+ response_format: AudioFormat,
+ max_duration_seconds: float,
+ seed: int = 0,
+ ) -> tuple[AudioInput]:
+ audio = await VLLMOmniClient(url.rstrip("/")).generate_speech(
+ model=model,
+ input=lyrics,
+ instructions=instructions,
+ voice="default",
+ speed=1.0,
+ response_format=response_format,
+ max_new_tokens=int(max_duration_seconds * 25),
+ seed=seed,
+ )
+ return (audio,)
+
+
class VLLMOmniVoiceClone(_VLLMOmniGenerateBase):
@classmethod
def INPUT_TYPES(cls):
@@ -695,6 +867,52 @@ class VLLMOmniRemoteLoRA:
return (lora,)
+class VLLMOmniFastH3Deployment:
+ """Select a vLLM-Omni service that fused FastH3 at startup."""
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "url": (
+ "STRING",
+ {
+ "default": "http://localhost:8000/v1",
+ "tooltip": "URL of a vLLM-Omni server started with a FastH3 --lora-path.",
+ },
+ ),
+ "model": (
+ "STRING",
+ {
+ "default": "MiniMaxAI/MiniMax-H3",
+ "tooltip": "Model name exposed by the FastH3 deployment.",
+ },
+ ),
+ }
+ }
+
+ RETURN_TYPES = ("FASTH3_DEPLOYMENT",)
+ RETURN_NAMES = ("deployment",)
+ FUNCTION = "get_deployment"
+ CATEGORY = "vLLM-Omni"
+ DESCRIPTION = (
+ "Selects a server with FastH3 fused at startup. The connected Generate Video node uses T2VA, "
+ "four inference steps, and 24 FPS without sending a request-level LoRA."
+ )
+
+ @classmethod
+ def VALIDATE_INPUTS(cls, url, model) -> str | Literal[True]:
+ try:
+ _resolve_fast_h3_deployment({"url": url, "model": model})
+ except ValueError as exc:
+ return str(exc)
+ return True
+
+ def get_deployment(self, url: str, model: str):
+ url, model = _resolve_fast_h3_deployment({"url": url, "model": model})
+ return (FastH3Deployment({"url": url, "model": model}),)
+
+
class VLLMOmniQwenTTSParams:
@classmethod
def INPUT_TYPES(cls):
@@ -790,6 +1008,10 @@ class VLLMOmniVideoReferences:
"audio_2": ("AUDIO",),
"video_1": ("VIDEO",),
"video_2": ("VIDEO",),
+ # Append ports to preserve connections in saved workflows.
+ **{f"image_{i}": ("IMAGE",) for i in range(3, MAX_REFERENCE_IMAGES + 1)},
+ **{f"audio_{i}": ("AUDIO",) for i in range(3, MAX_REFERENCE_AUDIOS + 1)},
+ **{f"video_{i}": ("VIDEO",) for i in range(3, MAX_REFERENCE_VIDEOS + 1)},
},
}
@@ -806,21 +1028,26 @@ class VLLMOmniVideoReferences:
audio_2: AudioInput | None = None,
video_1: VideoInput | None = None,
video_2: VideoInput | None = None,
+ image_3: torch.Tensor | None = None,
+ image_4: torch.Tensor | None = None,
+ image_5: torch.Tensor | None = None,
+ image_6: torch.Tensor | None = None,
+ image_7: torch.Tensor | None = None,
+ image_8: torch.Tensor | None = None,
+ image_9: torch.Tensor | None = None,
+ audio_3: AudioInput | None = None,
+ video_3: VideoInput | None = None,
**kwargs,
):
if kwargs:
logger.info("Uncaught kwargs: %s", kwargs)
refs = VideoReferences()
- if image_1 is not None:
- refs["image_1"] = image_1
- if image_2 is not None:
- refs["image_2"] = image_2
- if audio_1 is not None:
- refs["audio_1"] = audio_1
- if audio_2 is not None:
- refs["audio_2"] = audio_2
- if video_1 is not None:
- refs["video_1"] = video_1
- if video_2 is not None:
- refs["video_2"] = video_2
+ for kind, values in (
+ ("image", (image_1, image_2, image_3, image_4, image_5, image_6, image_7, image_8, image_9)),
+ ("video", (video_1, video_2, video_3)),
+ ("audio", (audio_1, audio_2, audio_3)),
+ ):
+ for index, value in enumerate(values, start=1):
+ if value is not None:
+ refs[f"{kind}_{index}"] = value
return (refs,)
diff --git a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/api_client.py b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/api_client.py
index cd509244a..21935414b 100644
--- a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/api_client.py
+++ b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/api_client.py
@@ -1,3 +1,6 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+
"""
An high-level API client adapter that forwards ComfyUI inputs to vLLM-Omni's REST API,
and transforms the API responses back to ComfyUI formats.
@@ -17,6 +20,7 @@ from comfy_api.input import AudioInput, VideoInput
from .format import (
audio_to_base64,
+ audio_to_bytes,
base64_to_audio,
base64_to_image_tensor,
bytes_to_audio,
@@ -28,7 +32,13 @@ from .format import (
)
from .logger import get_logger, pretty_printer
from .models import lookup_model_spec
-from .types import AudioFormat
+from .types import (
+ MAX_REFERENCE_AUDIOS,
+ MAX_REFERENCE_IMAGES,
+ MAX_REFERENCE_VIDEOS,
+ MAX_TOTAL_REFERENCES,
+ AudioFormat,
+)
logger = get_logger(__name__)
@@ -267,14 +277,33 @@ class VLLMOmniClient:
fps: int,
negative_prompt: str | None = None,
frame: torch.Tensor | None = None,
+ first_frame: torch.Tensor | None = None,
+ last_frame: torch.Tensor | None = None,
references: dict | None = None,
sampling_params: dict | None = None,
model_params: dict | None = None,
lora: dict | None = None,
+ spec_model: str | None = None,
**extra_params,
) -> VideoInput:
+ """Post a video job and return the decoded result.
+
+ ``spec_model`` names the model whose payload spec builds the request, for
+ deployments that serve a known model under a different ``model`` alias. It
+ never reaches the wire; defaults to ``model``.
+ """
if frame is not None and references is not None:
raise ValueError("Provide only one of frame or references, not both.")
+ if frame is not None and (first_frame is not None or last_frame is not None):
+ raise ValueError("Provide either frame or first_frame/last_frame, not both.")
+ if references is not None and (first_frame is not None or last_frame is not None):
+ raise ValueError("Provide either first_frame/last_frame or references, not both.")
+
+ spec, matched_pattern = lookup_model_spec(spec_model or model)
+ if (first_frame is not None or last_frame is not None) and (
+ matched_pattern is None or "MiniMax-H3" not in matched_pattern
+ ):
+ raise ValueError("first_frame and last_frame are supported only for MiniMax-H3; use frame for this model.")
# === regular payload fields ===
form = aiohttp.FormData()
@@ -294,43 +323,62 @@ class VLLMOmniClient:
# === multimodal inputs (first-last-frames, references, etc.) ===
input_reference_image: torch.Tensor | None = None
- audio_reference: AudioInput | None = None
- reference_videos: list[VideoInput] = []
+ keyframe_images: list[tuple[str, torch.Tensor]] = []
video_task: str | None = None
if frame is not None:
input_reference_image = frame
video_task = "fl2va"
+ elif first_frame is not None or last_frame is not None:
+ frame_indices: list[int] = []
+ if first_frame is not None:
+ keyframe_images.append(("first_frame.png", first_frame))
+ frame_indices.append(0)
+ if last_frame is not None:
+ keyframe_images.append(("last_frame.png", last_frame))
+ frame_indices.append(-1)
+ if len(keyframe_images) == 1:
+ input_reference_image = keyframe_images[0][1]
+ extra_params["frame_indices"] = frame_indices
+ video_task = "fl2va"
elif references is not None:
- images = [references[k] for k in ("image_1", "image_2") if k in references and references[k] is not None]
- audios = [references[k] for k in ("audio_1", "audio_2") if k in references and references[k] is not None]
- videos = [references[k] for k in ("video_1", "video_2") if k in references and references[k] is not None]
-
- if not images and not audios and not videos:
- raise ValueError("references is empty; connect at least one image/audio/video.")
-
- if videos:
- if images or audios:
- raise ValueError(
- "Video references cannot be combined with image or audio references. "
- "Connect only video_1/video_2 for multi-video Ref2VA."
- )
- reference_videos = videos
- video_task = "ref2va"
- elif len(images) == 1 and len(audios) == 1:
- input_reference_image = images[0]
- audio_reference = audios[0]
- video_task = "ref2va"
- else:
+ reference_formats = (
+ ("image", MAX_REFERENCE_IMAGES, "png", "image/png", image_tensor_to_png_bytes),
+ ("video", MAX_REFERENCE_VIDEOS, "mp4", "video/mp4", video_to_bytes),
+ ("audio", MAX_REFERENCE_AUDIOS, "mp3", "audio/mpeg", audio_to_bytes),
+ )
+ supported_inputs = {f"{kind}_{i}" for kind, limit, *_ in reference_formats for i in range(1, limit + 1)}
+ connected = {name: value for name, value in references.items() if value is not None}
+ unsupported = connected.keys() - supported_inputs
+ if unsupported:
+ raise ValueError(f"Unsupported reference input(s): {', '.join(sorted(unsupported))}.")
+ if not any(name.startswith(("image_", "video_")) for name in connected):
raise ValueError(
- "Invalid references combination. Supported modes: "
- "(1) one or more videos only, or (2) exactly one image and one audio."
+ "references requires at least one image or video; audio-only inputs are not supported."
)
+ if len(connected) > MAX_TOTAL_REFERENCES:
+ raise ValueError(
+ f"references supports at most {MAX_TOTAL_REFERENCES} inputs in total "
+ f"(up to {MAX_REFERENCE_IMAGES} images, {MAX_REFERENCE_VIDEOS} videos, "
+ f"and {MAX_REFERENCE_AUDIOS} audios)."
+ )
+ for kind, limit, extension, content_type, encode in reference_formats:
+ for index in range(1, limit + 1):
+ name = f"{kind}_{index}"
+ if name in connected:
+ filename = f"{name}.{extension}"
+ form.add_field(
+ "input_references",
+ encode(connected[name], filename),
+ filename=filename,
+ content_type=content_type,
+ )
+ video_task = "ref2va"
else:
video_task = "t2va"
if input_reference_image is not None:
- image_filename = "image.png" # Required for multipart form
+ image_filename = keyframe_images[0][0] if keyframe_images else "image.png"
form.add_field(
"input_reference",
image_tensor_to_png_bytes(input_reference_image, image_filename),
@@ -338,32 +386,27 @@ class VLLMOmniClient:
content_type="image/png",
)
- if audio_reference is not None:
- form.add_field(
- "audio_reference",
- json.dumps({"audio_url": audio_to_base64(audio_reference)}, ensure_ascii=False),
- )
-
- for idx, video in enumerate(reference_videos, start=1):
- video_filename = f"reference_{idx}.mp4"
- form.add_field(
- "input_references",
- video_to_bytes(video, video_filename),
- filename=video_filename,
- content_type="video/mp4",
- )
+ if len(keyframe_images) == 2:
+ for image_filename, image in keyframe_images:
+ form.add_field(
+ "input_references",
+ image_tensor_to_png_bytes(image, image_filename),
+ filename=image_filename,
+ content_type="image/png",
+ )
# === model specific params. Either use a specialized builder, or add flattened fields as-is ===
if model_params is not None:
model_params = dict(model_params)
model_params.pop("type", None)
- spec, _ = lookup_model_spec(model)
params_builder = spec.get("params_builder") if spec else None
if params_builder is not None:
form_fields = params_builder(
model_params or {},
extra_params={**extra_params, "task": video_task},
+ width=width,
+ height=height,
)
for k, v in form_fields.items():
form.add_field(k, v if isinstance(v, str) else str(v))
diff --git a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/format.py b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/format.py
index 42d396f06..c2aba4cff 100644
--- a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/format.py
+++ b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/format.py
@@ -1,3 +1,6 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+
"""Image/tensor format helpers.
The image generation part is derived from dougbtv/comfyui-vllm-omni by Doug (@dougbtv).
@@ -170,6 +173,10 @@ def bytes_to_video(video_bytes: bytes) -> VideoInput:
audio_stream = container.streams.audio[-1]
audio_frames = []
resampler = AudioResampler(format="fltp")
+ # Decoding the video stream above left the container at EOF.
+ # Rewind before demuxing again, or this pass yields no frames
+ # and the audio of a T2VA result is silently dropped.
+ container.seek(0)
for frame in container.decode(audio_stream):
if not isinstance(frame, AudioFrame):
continue
diff --git a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/models.py b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/models.py
index 3a37db16d..82c7e4a51 100644
--- a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/models.py
+++ b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/models.py
@@ -1,3 +1,6 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+
import json
import re
from typing import Any
@@ -34,10 +37,28 @@ def _qwen25_payload_preprocessor(payload: dict) -> dict:
return payload
+MINIMAX_H3_ASPECT_RATIOS = {
+ "21:9": 21.0 / 9.0,
+ "16:9": 16.0 / 9.0,
+ "4:3": 4.0 / 3.0,
+ "1:1": 1.0,
+ "3:4": 3.0 / 4.0,
+ "9:16": 9.0 / 16.0,
+}
+
+
+def _nearest_minimaxh3_aspect_ratio(width: int, height: int) -> str:
+ """Pick the supported named ratio closest to the requested frame size."""
+ target = float(width) / float(height)
+ return min(MINIMAX_H3_ASPECT_RATIOS, key=lambda name: abs(MINIMAX_H3_ASPECT_RATIOS[name] - target))
+
+
def _minimaxh3_params_builder(
model_params: dict[str, Any],
*,
extra_params: dict[str, Any],
+ width: int | None = None,
+ height: int | None = None,
) -> dict[str, Any]:
"""Build multipart form fields for MiniMax-H3 from model_params + routed task."""
params = dict(model_params)
@@ -53,6 +74,12 @@ def _minimaxh3_params_builder(
if params:
logger.warning("Unused MiniMax-H3 model params ignored: %s", sorted(params))
+ # H3 refuses t2va without a named aspect ratio, and the Generate Video node
+ # carries width/height rather than a ratio. fl2va takes its ratio from the
+ # input image and ref2va defaults server-side, so only t2va needs this.
+ if merged_extra_params.get("task") == "t2va" and "aspect_ratio" not in merged_extra_params and width and height:
+ merged_extra_params["aspect_ratio"] = _nearest_minimaxh3_aspect_ratio(width, height)
+
if merged_extra_params:
form_fields["extra_params"] = json.dumps(merged_extra_params, ensure_ascii=False)
return form_fields
diff --git a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/types.py b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/types.py
index e1ae43916..6d12a98c2 100644
--- a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/types.py
+++ b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/types.py
@@ -1,3 +1,6 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+
from collections.abc import Callable
from enum import Enum, auto
from typing import Any, Literal, TypeAlias
@@ -27,10 +30,20 @@ class MiniMaxH3ModelSpecificParams(dict):
pass
+MAX_REFERENCE_IMAGES = 9
+MAX_REFERENCE_VIDEOS = 3
+MAX_REFERENCE_AUDIOS = 3
+MAX_TOTAL_REFERENCES = 12
+
+
class VideoReferences(dict):
pass
+class FastH3Deployment(dict):
+ """Descriptor for a server that fused FastH3 at startup."""
+
+
class ModelMode(Enum):
IMAGE_GENERATION = auto()
VIDEO_GENERATION = auto()
diff --git a/apps/ComfyUI-vLLM-Omni/docs/images/comfyui-music-generation.jpg b/apps/ComfyUI-vLLM-Omni/docs/images/comfyui-music-generation.jpg
new file mode 100644
index 000000000..1c8130fbd
Binary files /dev/null and b/apps/ComfyUI-vLLM-Omni/docs/images/comfyui-music-generation.jpg differ
diff --git a/apps/ComfyUI-vLLM-Omni/docs/minimax-h3-t2v.md b/apps/ComfyUI-vLLM-Omni/docs/minimax-h3-t2v.md
new file mode 100644
index 000000000..790aa0f53
--- /dev/null
+++ b/apps/ComfyUI-vLLM-Omni/docs/minimax-h3-t2v.md
@@ -0,0 +1,94 @@
+# MiniMax H3 Text to Video (WF-01)
+
+Import [MiniMax_H3_Text_to_Video.json](../example_workflows/MiniMax_H3_Text_to_Video.json)
+into ComfyUI, or select it under **Templates → ComfyUI-vLLM-Omni**.
+The template connects existing nodes to a remote H3 service and saves the
+returned video with audio. ComfyUI does not load H3 weights.
+
+## Setup
+
+Install the [ComfyUI extension](../README.md#installation) and follow the
+[H3 deployment recipe](../../../recipes/MiniMaxAI/MiniMax-H3.md) for your hardware.
+For a two-GPU FL2VA server with sufficient memory:
+
+```bash
+export MODEL=/path/to/MiniMax-H3/FL2VA
+CUDA_VISIBLE_DEVICES=0,1 VLLM_WORKER_MULTIPROC_METHOD=spawn \
+VLLM_OMNI_VIDEO_SYNC_TIMEOUT=14400 \
+vllm serve "$MODEL" --omni --host 127.0.0.1 --port 8000 \
+ --served-model-name MiniMaxAI/MiniMax-H3 --trust-remote-code \
+ --task-type fl2va --num-gpus 2 --tensor-parallel-size 2 \
+ --text-encoder-tp-size 2 --enforce-eager
+```
+
+Set Generate Video's **url** to the address reachable from ComfyUI, including
+`/v1`, and match its **model** to the served name. The defaults are
+`http://localhost:8000/v1` and `MiniMaxAI/MiniMax-H3`.
+
+## Base and Turbo
+
+Base presets are connected by default. Write the scene and its sound in the
+same **prompt**; leave **frame**, **references**, and **fast_h3** disconnected.
+The template uses 1344×768, 24 FPS, seed 1101 and **duration** 5.167 seconds
+(124 frames, satisfying H3's `17k+5` constraint). The existing client infers
+`16:9` from these dimensions. For portrait output, use 768×1344.
+
+| Setting | Base | Turbo v1.0 768p |
+| --- | --- | --- |
+| Inference steps | 50 | 5 (4 forwards) |
+| Video flow shift | 12 | 6 |
+| Audio flow shift | 3 | 3 |
+| Remote LoRA | Disconnected | Connected, scale 1 |
+
+For Turbo, use the **Diffusers-layout** artifact from
+[lightx2v/Minimax-h3-Turbo](https://huggingface.co/lightx2v/Minimax-h3-Turbo):
+
+```text
+minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors
+```
+
+Add `--lora-backend peft --lora-path "$TURBO_LORA"` to the server command,
+where `TURBO_LORA` is the full server-side path to that file. Enter the same
+path in Remote LoRA's **local_path**; retain name `h3-turbo-v1.0-768p`, scale
+`1.0`, and **int_id** `0` so the existing API derives the ID from the path.
+The template leaves the path blank for users to configure. The `_comfyui_`
+export is not supported by this backend.
+
+Connect **Turbo sampling**, **Turbo H3 params**, and **Remote LoRA** to Generate
+Video's `sampling_params`, `model_params`, and `lora` inputs, replacing the
+Base links. To return to Base, restore both Base links and disconnect LoRA.
+Other Turbo artifacts may need different settings; see the
+[artifact table](../../../recipes/MiniMaxAI/MiniMax-H3.md#turbo-lora).
+
+If importing an older template with a frame-count widget, reimport this JSON
+or set **duration** to **5.167**; do not interpret 124 frames as 124 seconds.
+
+## Validation
+
+Queue the configured workflow and inspect the MP4 saved under
+`ComfyUI/output/video/MiniMax_H3_WF01_*.mp4`:
+
+```bash
+ffprobe -v error -show_streams -show_format -of json "$OUTPUT"
+ffmpeg -v error -i "$OUTPUT" -f null -
+```
+
+Check the requested dimensions, frame count, 24 FPS and both video/audio
+streams, then watch and listen to the saved file. The current upstream
+extension includes the shared audio-preserving decoder fix.
+
+On September 16, the narrowed workflow was validated through ComfyUI using
+existing upstream Python code at `4a8ac297`, two H20-3e GPUs, vLLM 0.29.0,
+PyTorch 2.13.0+cu129 and ComfyUI `1d48d9cf`. The run used the template's
+forest-stream prompt, explicit server-side Turbo LoRA path, and settings
+above, with `TORCH_SDPA`, `--usp 1 --ring 1`, and tiled VAE parallelism on two
+GPUs. ComfyUI execution took 131.55 seconds. SaveVideo produced 124 H.264
+frames at 1344×768 and 24 FPS, plus 32 kHz stereo AAC audio. Video/audio
+durations were 5.166667/5.167000 seconds; full decoding passed and audio was
+nonzero. This is model-generated output, not a ground-truth reference or a
+subjective audio-quality assessment. Base graph validation passed with Turbo
+nodes disconnected; full 50-step Base quality was not evaluated.
+
+Based on the official
+[ComfyUI H3 T2V template](https://github.com/Comfy-Org/workflow_templates/blob/main/templates/video_minimax_h3_t2v.json),
+using remote inference instead of local model loaders.
diff --git a/apps/ComfyUI-vLLM-Omni/docs/wf07-h3-upscale.md b/apps/ComfyUI-vLLM-Omni/docs/wf07-h3-upscale.md
new file mode 100644
index 000000000..dfdbd166f
--- /dev/null
+++ b/apps/ComfyUI-vLLM-Omni/docs/wf07-h3-upscale.md
@@ -0,0 +1,21 @@
+# WF-07: H3 video upscale with SeedVR2
+
+Generate video and audio through vLLM-Omni H3, upscale the decoded frames with SeedVR2, and save both the original and upscaled videos. The generated audio and FPS connect directly to Create Video.
+
+## Requirements
+
+- ComfyUI with ComfyUI-vLLM-Omni and [ComfyUI-SeedVR2_VideoUpscaler](https://github.com/numz/ComfyUI-SeedVR2_VideoUpscaler#-installation) installed.
+- An H3 service using the FL2VA checkpoint partition. Follow the [H3 recipe](../../../recipes/MiniMaxAI/MiniMax-H3.md) for server setup.
+- `seedvr2_ema_3b_fp16.safetensors` and `ema_vae_fp16.safetensors` from [SeedVR2's ComfyUI weights](https://huggingface.co/numz/SeedVR2_comfyUI/tree/main), placed in `ComfyUI/models/SEEDVR2`. The SeedVR2 nodes can also download them on first use.
+
+## Use
+
+Open **vLLM-Omni MiniMax H3 Video Upscale.json** from the example workflows. An API-format version is provided alongside it. Set the Generate Video URL/model and the LoRA path as seen by the H3 server.
+
+The preset uses the LightX2V v1.0 768p Turbo adapter in Diffusers format: `minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors`, five sigma points, video/audio flow shifts 6/3, and LoRA scale 1. Preload the same adapter on the server using the [Turbo LoRA recipe](../../../recipes/MiniMaxAI/MiniMax-H3.md#turbo-lora). For base H3, disconnect LoRA, set inference steps to 50, and set video flow shift to 12.
+
+The H3 preset requests 1344 x 768 at 24 FPS for 5.167 seconds, which gives 124 frames. SeedVR2's `resolution=1536` sets the target short edge, producing 2688 x 1536 for this input. Update this value when changing the input size; it is an output resolution, not a fixed 2x multiplier.
+
+The SeedVR2 preset uses 3B FP16, five-frame batches, one overlapping frame, uniform batch padding, and LAB color correction. The DiT loader uses CPU offload with 32 swapped blocks; the VAE uses tiled encoding and decoding. Select the local GPU in both model loaders. See the [SeedVR2 settings](https://github.com/numz/ComfyUI-SeedVR2_VideoUpscaler#-usage) when adjusting memory use or batch size.
+
+Run the workflow. The two Save Video nodes write MP4s under `WF07/generated` and `WF07/upscaled` in ComfyUI's output directory.
diff --git a/apps/ComfyUI-vLLM-Omni/example_workflows/MiniMax_H3_Text_to_Video.json b/apps/ComfyUI-vLLM-Omni/example_workflows/MiniMax_H3_Text_to_Video.json
new file mode 100644
index 000000000..137ee8c2d
--- /dev/null
+++ b/apps/ComfyUI-vLLM-Omni/example_workflows/MiniMax_H3_Text_to_Video.json
@@ -0,0 +1,362 @@
+{
+ "last_node_id": 8,
+ "last_link_id": 3,
+ "nodes": [
+ {
+ "id": 1,
+ "type": "VLLMOmniDiffusionSampling",
+ "pos": [
+ 20,
+ 20
+ ],
+ "size": [
+ 310,
+ 280
+ ],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "diffusion sampling params",
+ "type": "SAMPLING_PARAMS",
+ "links": [
+ 1
+ ],
+ "slot_index": 0
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "VLLMOmniDiffusionSampling"
+ },
+ "widgets_values": [
+ 1,
+ 50,
+ 1.0,
+ 1.0,
+ false,
+ false,
+ 1101,
+ "fixed"
+ ],
+ "title": "Base sampling"
+ },
+ {
+ "id": 2,
+ "type": "VLLMOmniMiniMaxH3Params",
+ "pos": [
+ 20,
+ 340
+ ],
+ "size": [
+ 310,
+ 140
+ ],
+ "flags": {},
+ "order": 1,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "MiniMax-H3 video params",
+ "type": "VIDEO_PARAMS",
+ "links": [
+ 2
+ ],
+ "slot_index": 0
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "VLLMOmniMiniMaxH3Params"
+ },
+ "widgets_values": [
+ 3.0,
+ 12.0
+ ],
+ "title": "Base H3 params"
+ },
+ {
+ "id": 3,
+ "type": "VLLMOmniGenerateVideo",
+ "pos": [
+ 430,
+ 20
+ ],
+ "size": [
+ 430,
+ 440
+ ],
+ "flags": {},
+ "order": 2,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "frame",
+ "type": "IMAGE",
+ "link": null
+ },
+ {
+ "name": "references",
+ "type": "VIDEO_REFERENCES",
+ "link": null
+ },
+ {
+ "name": "sampling_params",
+ "type": "SAMPLING_PARAMS",
+ "link": 1
+ },
+ {
+ "name": "lora",
+ "type": "REMOTE_LORA",
+ "link": null
+ },
+ {
+ "name": "model_params",
+ "type": "VIDEO_PARAMS",
+ "link": 2
+ },
+ {
+ "name": "fast_h3",
+ "type": "FASTH3_DEPLOYMENT",
+ "link": null
+ },
+ {
+ "name": "duration",
+ "type": "FLOAT",
+ "widget": {
+ "name": "duration"
+ },
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "name": "video",
+ "type": "VIDEO",
+ "links": [
+ 3
+ ],
+ "slot_index": 0
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "VLLMOmniGenerateVideo"
+ },
+ "widgets_values": [
+ "http://localhost:8000/v1",
+ "MiniMaxAI/MiniMax-H3",
+ "A small stream flows through a sunlit forest. The camera slowly moves toward the water. Birds chirp in the trees and water gurgles over smooth stones. Natural continuous sound, no music.",
+ "",
+ 1344,
+ 768,
+ 24,
+ 5.167
+ ]
+ },
+ {
+ "id": 4,
+ "type": "SaveVideo",
+ "pos": [
+ 940,
+ 20
+ ],
+ "size": [
+ 440,
+ 440
+ ],
+ "flags": {},
+ "order": 3,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "video",
+ "type": "VIDEO",
+ "link": 3
+ }
+ ],
+ "outputs": [
+ {
+ "name": "video",
+ "type": "VIDEO",
+ "links": null,
+ "slot_index": 0
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "SaveVideo"
+ },
+ "widgets_values": [
+ "video/MiniMax_H3_WF01",
+ "mp4",
+ "auto"
+ ]
+ },
+ {
+ "id": 5,
+ "type": "VLLMOmniRemoteLoRA",
+ "pos": [
+ 430,
+ 540
+ ],
+ "size": [
+ 430,
+ 160
+ ],
+ "flags": {},
+ "order": 4,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "lora",
+ "type": "REMOTE_LORA",
+ "links": null,
+ "slot_index": 0
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "VLLMOmniRemoteLoRA"
+ },
+ "widgets_values": [
+ "",
+ "h3-turbo-v1.0-768p",
+ 1.0,
+ 0
+ ],
+ "title": "Optional Turbo: server LoRA path"
+ },
+ {
+ "id": 6,
+ "type": "VLLMOmniDiffusionSampling",
+ "pos": [
+ 20,
+ 540
+ ],
+ "size": [
+ 310,
+ 280
+ ],
+ "flags": {},
+ "order": 5,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "diffusion sampling params",
+ "type": "SAMPLING_PARAMS",
+ "links": null,
+ "slot_index": 0
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "VLLMOmniDiffusionSampling"
+ },
+ "widgets_values": [
+ 1,
+ 5,
+ 1.0,
+ 1.0,
+ false,
+ false,
+ 1101,
+ "fixed"
+ ],
+ "title": "Turbo sampling (4 forwards)"
+ },
+ {
+ "id": 7,
+ "type": "VLLMOmniMiniMaxH3Params",
+ "pos": [
+ 430,
+ 740
+ ],
+ "size": [
+ 310,
+ 140
+ ],
+ "flags": {},
+ "order": 6,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "MiniMax-H3 video params",
+ "type": "VIDEO_PARAMS",
+ "links": null,
+ "slot_index": 0
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "VLLMOmniMiniMaxH3Params"
+ },
+ "widgets_values": [
+ 3.0,
+ 6.0
+ ],
+ "title": "Turbo H3 params"
+ },
+ {
+ "id": 8,
+ "type": "Note",
+ "pos": [
+ 940,
+ 520
+ ],
+ "size": [
+ 440,
+ 440
+ ],
+ "flags": {},
+ "order": 7,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "Node name for S&R": "Note"
+ },
+ "widgets_values": [
+ "MiniMax H3 Text to Video (WF-01)\n\nBASE: connected presets, 50 steps / video flow 12 / audio flow 3.\n\nTURBO: set Remote LoRA local_path to the SERVER path of minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors from lightx2v/Minimax-h3-Turbo. Keep name h3-turbo-v1.0-768p, scale 1.0 and int_id 0. Connect Turbo sampling, Turbo H3 params, and LoRA to Generate Video, replacing the two Base links. Use 5 steps (4 forwards), video flow 6 and audio flow 3. Do not use the ComfyUI-layout artifact.\n\nKeep frame, references and fast_h3 disconnected. 1344x768, 24 FPS, duration 5.167 seconds = 124 frames (17*7+5). The client selects the named 16:9 preset from the dimensions. Save Video preserves video and audio.\n\nSet URL and served model name for your server. See docs/minimax-h3-t2v.md for setup and validation. Source: Comfy-Org/workflow_templates/templates/video_minimax_h3_t2v.json."
+ ],
+ "title": "Setup / Base and Turbo"
+ }
+ ],
+ "links": [
+ [
+ 1,
+ 1,
+ 0,
+ 3,
+ 2,
+ "SAMPLING_PARAMS"
+ ],
+ [
+ 2,
+ 2,
+ 0,
+ 3,
+ 4,
+ "VIDEO_PARAMS"
+ ],
+ [
+ 3,
+ 3,
+ 0,
+ 4,
+ 0,
+ "VIDEO"
+ ]
+ ],
+ "groups": [],
+ "config": {},
+ "extra": {
+ "ds": {
+ "scale": 0.8,
+ "offset": [
+ 50,
+ 50
+ ]
+ }
+ },
+ "version": 0.4
+}
diff --git a/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni FastH3 Text to Video.json b/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni FastH3 Text to Video.json
new file mode 100644
index 000000000..5b3d54f39
--- /dev/null
+++ b/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni FastH3 Text to Video.json
@@ -0,0 +1,322 @@
+{
+ "id": "c6db00da-2ccb-4ff8-8334-d67e1a267781",
+ "revision": 0,
+ "last_node_id": 5,
+ "last_link_id": 3,
+ "nodes": [
+ {
+ "id": 1,
+ "type": "MarkdownNote",
+ "pos": [40, 20],
+ "size": [420, 250],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "title": "FastH3 server prerequisite",
+ "properties": {},
+ "widgets_values": [
+ "FastH3 must be fused when the vLLM-Omni server starts. Use --task-type fl2va and --lora-path pointing to exactly one FastH3 adapter file. Do not use CPU or layerwise offload. For VSA / Data-Free, also install fastvideo-kernel and use --diffusion-attention-backend FASTVIDEO_VSA --fastvideo-vsa-topk 64."
+ ],
+ "color": "#432",
+ "bgcolor": "#000"
+ },
+ {
+ "id": 2,
+ "type": "VLLMOmniFastH3Deployment",
+ "pos": [40, 320],
+ "size": [420, 160],
+ "flags": {},
+ "order": 1,
+ "mode": 0,
+ "inputs": [
+ {
+ "localized_name": "url",
+ "name": "url",
+ "type": "STRING",
+ "widget": {"name": "url"},
+ "link": null
+ },
+ {
+ "localized_name": "model",
+ "name": "model",
+ "type": "STRING",
+ "widget": {"name": "model"},
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "localized_name": "deployment",
+ "name": "deployment",
+ "type": "FASTH3_DEPLOYMENT",
+ "links": [1]
+ }
+ ],
+ "properties": {"Node name for S&R": "VLLMOmniFastH3Deployment"},
+ "widgets_values": [
+ "http://localhost:8000/v1",
+ "MiniMaxAI/MiniMax-H3"
+ ]
+ },
+ {
+ "id": 3,
+ "type": "VLLMOmniDiffusionSampling",
+ "pos": [40, 540],
+ "size": [420, 230],
+ "flags": {},
+ "order": 2,
+ "mode": 0,
+ "inputs": [
+ {
+ "localized_name": "n",
+ "name": "n",
+ "type": "INT",
+ "widget": {"name": "n"},
+ "link": null
+ },
+ {
+ "localized_name": "num_inference_steps",
+ "name": "num_inference_steps",
+ "type": "INT",
+ "widget": {"name": "num_inference_steps"},
+ "link": null
+ },
+ {
+ "localized_name": "guidance_scale",
+ "name": "guidance_scale",
+ "type": "FLOAT",
+ "widget": {"name": "guidance_scale"},
+ "link": null
+ },
+ {
+ "localized_name": "true_cfg_scale",
+ "name": "true_cfg_scale",
+ "type": "FLOAT",
+ "widget": {"name": "true_cfg_scale"},
+ "link": null
+ },
+ {
+ "localized_name": "vae_use_slicing",
+ "name": "vae_use_slicing",
+ "type": "BOOLEAN",
+ "widget": {"name": "vae_use_slicing"},
+ "link": null
+ },
+ {
+ "localized_name": "vae_use_tiling",
+ "name": "vae_use_tiling",
+ "type": "BOOLEAN",
+ "widget": {"name": "vae_use_tiling"},
+ "link": null
+ },
+ {
+ "localized_name": "seed",
+ "name": "seed",
+ "type": "INT",
+ "widget": {"name": "seed"},
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "localized_name": "diffusion sampling params",
+ "name": "diffusion sampling params",
+ "type": "SAMPLING_PARAMS",
+ "links": [2]
+ }
+ ],
+ "properties": {"Node name for S&R": "VLLMOmniDiffusionSampling"},
+ "widgets_values": [1, 4, 1, 1, false, false, 1101, "fixed"]
+ },
+ {
+ "id": 4,
+ "type": "VLLMOmniGenerateVideo",
+ "pos": [560, 210],
+ "size": [540, 610],
+ "flags": {},
+ "order": 3,
+ "mode": 0,
+ "inputs": [
+ {
+ "localized_name": "frame",
+ "name": "frame",
+ "shape": 7,
+ "type": "IMAGE",
+ "link": null
+ },
+ {
+ "localized_name": "references",
+ "name": "references",
+ "shape": 7,
+ "type": "VIDEO_REFERENCES",
+ "link": null
+ },
+ {
+ "localized_name": "sampling_params",
+ "name": "sampling_params",
+ "shape": 7,
+ "type": "SAMPLING_PARAMS",
+ "link": 2
+ },
+ {
+ "localized_name": "lora",
+ "name": "lora",
+ "shape": 7,
+ "type": "REMOTE_LORA",
+ "link": null
+ },
+ {
+ "localized_name": "model_params",
+ "name": "model_params",
+ "shape": 7,
+ "type": "VIDEO_PARAMS",
+ "link": null
+ },
+ {
+ "localized_name": "fast_h3",
+ "name": "fast_h3",
+ "shape": 7,
+ "type": "FASTH3_DEPLOYMENT",
+ "link": 1
+ },
+ {
+ "localized_name": "url",
+ "name": "url",
+ "type": "STRING",
+ "widget": {"name": "url"},
+ "link": null
+ },
+ {
+ "localized_name": "model",
+ "name": "model",
+ "type": "STRING",
+ "widget": {"name": "model"},
+ "link": null
+ },
+ {
+ "localized_name": "prompt",
+ "name": "prompt",
+ "type": "STRING",
+ "widget": {"name": "prompt"},
+ "link": null
+ },
+ {
+ "localized_name": "negative_prompt",
+ "name": "negative_prompt",
+ "type": "STRING",
+ "widget": {"name": "negative_prompt"},
+ "link": null
+ },
+ {
+ "localized_name": "width",
+ "name": "width",
+ "type": "INT",
+ "widget": {"name": "width"},
+ "link": null
+ },
+ {
+ "localized_name": "height",
+ "name": "height",
+ "type": "INT",
+ "widget": {"name": "height"},
+ "link": null
+ },
+ {
+ "localized_name": "fps",
+ "name": "fps",
+ "type": "INT",
+ "widget": {"name": "fps"},
+ "link": null
+ },
+ {
+ "localized_name": "duration",
+ "name": "duration",
+ "type": "FLOAT",
+ "widget": {"name": "duration"},
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "localized_name": "video",
+ "name": "video",
+ "type": "VIDEO",
+ "links": [3]
+ }
+ ],
+ "properties": {"Node name for S&R": "VLLMOmniGenerateVideo"},
+ "widgets_values": [
+ "http://localhost:8000/v1",
+ "MiniMaxAI/MiniMax-H3",
+ "A cinematic live-action singer performs naturally on a neon-lit stage, with synchronized vocals and environmental sound.",
+ "",
+ 1344,
+ 768,
+ 24,
+ 4.458
+ ]
+ },
+ {
+ "id": 5,
+ "type": "SaveVideo",
+ "pos": [1200, 280],
+ "size": [380, 310],
+ "flags": {},
+ "order": 4,
+ "mode": 0,
+ "inputs": [
+ {
+ "localized_name": "video",
+ "name": "video",
+ "type": "VIDEO",
+ "link": 3
+ },
+ {
+ "localized_name": "filename_prefix",
+ "name": "filename_prefix",
+ "type": "STRING",
+ "widget": {"name": "filename_prefix"},
+ "link": null
+ },
+ {
+ "localized_name": "format",
+ "name": "format",
+ "type": "COMBO",
+ "widget": {"name": "format"},
+ "link": null
+ },
+ {
+ "localized_name": "codec",
+ "name": "codec",
+ "type": "COMFY_DYNAMICCOMBO_V3",
+ "widget": {"name": "codec"},
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "localized_name": "video",
+ "name": "video",
+ "type": "VIDEO",
+ "links": null
+ }
+ ],
+ "properties": {},
+ "widgets_values": ["video/ComfyUI-FastH3", "auto", "auto"]
+ }
+ ],
+ "links": [
+ [1, 2, 0, 4, 5, "FASTH3_DEPLOYMENT"],
+ [2, 3, 0, 4, 2, "SAMPLING_PARAMS"],
+ [3, 4, 0, 5, 0, "VIDEO"]
+ ],
+ "groups": [],
+ "config": {},
+ "extra": {
+ "workflowRendererVersion": "LG",
+ "ds": {"scale": 0.9, "offset": [100, 100]}
+ },
+ "version": 0.4
+}
diff --git a/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni MiniMax H3 Image to Video.json b/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni MiniMax H3 Image to Video.json
new file mode 100644
index 000000000..d957e1cbc
--- /dev/null
+++ b/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni MiniMax H3 Image to Video.json
@@ -0,0 +1,219 @@
+{
+ "id": "6676f19a-7f41-4c80-8cb7-f80adcb4ac44",
+ "revision": 0,
+ "last_node_id": 9,
+ "last_link_id": 4,
+ "nodes": [
+ {
+ "id": 1,
+ "type": "LoadImage",
+ "pos": [40, 20],
+ "size": [270, 314],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [
+ {"localized_name": "image", "name": "image", "type": "COMBO", "widget": {"name": "image"}, "link": null},
+ {"localized_name": "choose file to upload", "name": "upload", "type": "IMAGEUPLOAD", "widget": {"name": "upload"}, "link": null}
+ ],
+ "outputs": [
+ {"localized_name": "IMAGE", "name": "IMAGE", "type": "IMAGE", "links": [1]},
+ {"localized_name": "MASK", "name": "MASK", "type": "MASK", "links": null}
+ ],
+ "title": "First Frame",
+ "properties": {"Node name for S&R": "LoadImage"},
+ "widgets_values": ["first_frame.png", "image"]
+ },
+ {
+ "id": 2,
+ "type": "LoadImage",
+ "pos": [40, 390],
+ "size": [270, 314],
+ "flags": {},
+ "order": 1,
+ "mode": 0,
+ "inputs": [
+ {"localized_name": "image", "name": "image", "type": "COMBO", "widget": {"name": "image"}, "link": null},
+ {"localized_name": "choose file to upload", "name": "upload", "type": "IMAGEUPLOAD", "widget": {"name": "upload"}, "link": null}
+ ],
+ "outputs": [
+ {"localized_name": "IMAGE", "name": "IMAGE", "type": "IMAGE", "links": null},
+ {"localized_name": "MASK", "name": "MASK", "type": "MASK", "links": null}
+ ],
+ "title": "Last Frame (optional)",
+ "properties": {"Node name for S&R": "LoadImage"},
+ "widgets_values": ["last_frame.png", "image"]
+ },
+ {
+ "id": 3,
+ "type": "MarkdownNote",
+ "pos": [350, 20],
+ "size": [390, 240],
+ "flags": {},
+ "order": 2,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "title": "MiniMax H3 I2V modes",
+ "properties": {},
+ "widgets_values": ["This workflow uses the remote vLLM-Omni Generate Video node.\n\n- First-frame only (default): keep First Frame connected.\n- Last-frame only: disconnect First Frame and connect Last Frame to last_frame.\n- First+last-frame: connect both image loaders.\n\nLeave the legacy frame and references inputs disconnected. Select images from ComfyUI's input folder. H3 runs at 24 FPS; the 5.167-second duration resolves to 124 frames, satisfying 17k+5."],
+ "color": "#432",
+ "bgcolor": "#000"
+ },
+ {
+ "id": 4,
+ "type": "VLLMOmniDiffusionSampling",
+ "pos": [350, 300],
+ "size": [300, 226],
+ "flags": {},
+ "order": 3,
+ "mode": 0,
+ "inputs": [
+ {"localized_name": "n", "name": "n", "type": "INT", "widget": {"name": "n"}, "link": null},
+ {"localized_name": "num_inference_steps", "name": "num_inference_steps", "type": "INT", "widget": {"name": "num_inference_steps"}, "link": null},
+ {"localized_name": "guidance_scale", "name": "guidance_scale", "type": "FLOAT", "widget": {"name": "guidance_scale"}, "link": null},
+ {"localized_name": "true_cfg_scale", "name": "true_cfg_scale", "type": "FLOAT", "widget": {"name": "true_cfg_scale"}, "link": null},
+ {"localized_name": "vae_use_slicing", "name": "vae_use_slicing", "type": "BOOLEAN", "widget": {"name": "vae_use_slicing"}, "link": null},
+ {"localized_name": "vae_use_tiling", "name": "vae_use_tiling", "type": "BOOLEAN", "widget": {"name": "vae_use_tiling"}, "link": null},
+ {"localized_name": "seed", "name": "seed", "type": "INT", "widget": {"name": "seed"}, "link": null}
+ ],
+ "outputs": [
+ {"localized_name": "diffusion sampling params", "name": "diffusion sampling params", "type": "SAMPLING_PARAMS", "links": [2]}
+ ],
+ "title": "Base H3 Sampling (50 steps)",
+ "properties": {"Node name for S&R": "VLLMOmniDiffusionSampling"},
+ "widgets_values": [1, 50, 1, 1, false, false, 757358688076805, "fixed"]
+ },
+ {
+ "id": 5,
+ "type": "VLLMOmniMiniMaxH3Params",
+ "pos": [350, 570],
+ "size": [300, 82],
+ "flags": {},
+ "order": 4,
+ "mode": 0,
+ "inputs": [
+ {"localized_name": "audio_flow_shift", "name": "audio_flow_shift", "type": "FLOAT", "widget": {"name": "audio_flow_shift"}, "link": null},
+ {"localized_name": "flow_shift", "name": "flow_shift", "type": "FLOAT", "widget": {"name": "flow_shift"}, "link": null}
+ ],
+ "outputs": [
+ {"localized_name": "MiniMax-H3 video params", "name": "MiniMax-H3 video params", "type": "VIDEO_PARAMS", "links": [3]}
+ ],
+ "title": "Base H3 Flow Shifts",
+ "properties": {"Node name for S&R": "VLLMOmniMiniMaxH3Params"},
+ "widgets_values": [3, 12]
+ },
+ {
+ "id": 6,
+ "type": "VLLMOmniRemoteLoRA",
+ "pos": [700, 550],
+ "size": [360, 150],
+ "flags": {},
+ "order": 5,
+ "mode": 0,
+ "inputs": [
+ {"localized_name": "local_path", "name": "local_path", "type": "STRING", "widget": {"name": "local_path"}, "link": null},
+ {"localized_name": "name", "name": "name", "type": "STRING", "widget": {"name": "name"}, "link": null},
+ {"localized_name": "scale", "name": "scale", "type": "FLOAT", "widget": {"name": "scale"}, "link": null},
+ {"localized_name": "int_id", "name": "int_id", "type": "INT", "widget": {"name": "int_id"}, "link": null}
+ ],
+ "outputs": [
+ {"localized_name": "lora", "name": "lora", "type": "REMOTE_LORA", "links": null}
+ ],
+ "title": "Optional H3 Turbo Remote LoRA",
+ "properties": {"Node name for S&R": "VLLMOmniRemoteLoRA"},
+ "widgets_values": ["minimax_h3_fl2v_turbo_8step_v1.0_768p_bf16.safetensors", "h3-turbo-8step", 1, 0]
+ },
+ {
+ "id": 7,
+ "type": "MarkdownNote",
+ "pos": [700, 300],
+ "size": [420, 210],
+ "flags": {},
+ "order": 6,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "title": "Optional Turbo settings",
+ "properties": {},
+ "widgets_values": ["Base H3 is configured by default: 50 steps, flow_shift 12, audio_flow_shift 3.\n\nFor the shown 8-step Diffusers Turbo artifact, set its full local_path on the server, start the server with the matching preloaded LoRA, connect this Remote LoRA to Generate Video, set num_inference_steps to 9, and set flow_shift to 6. Keep audio_flow_shift at 3 and scale at 1.\n\nDo not use the _comfyui_ repack or FastH3 here."],
+ "color": "#432",
+ "bgcolor": "#000"
+ },
+ {
+ "id": 8,
+ "type": "VLLMOmniGenerateVideo",
+ "pos": [1160, 80],
+ "size": [440, 610],
+ "flags": {},
+ "order": 7,
+ "mode": 0,
+ "inputs": [
+ {"localized_name": "frame", "name": "frame", "shape": 7, "type": "IMAGE", "link": null},
+ {"localized_name": "first_frame", "name": "first_frame", "shape": 7, "type": "IMAGE", "link": 1},
+ {"localized_name": "last_frame", "name": "last_frame", "shape": 7, "type": "IMAGE", "link": null},
+ {"localized_name": "references", "name": "references", "shape": 7, "type": "VIDEO_REFERENCES", "link": null},
+ {"localized_name": "sampling_params", "name": "sampling_params", "shape": 7, "type": "SAMPLING_PARAMS", "link": 2},
+ {"localized_name": "lora", "name": "lora", "shape": 7, "type": "REMOTE_LORA", "link": null},
+ {"localized_name": "model_params", "name": "model_params", "shape": 7, "type": "VIDEO_PARAMS", "link": 3},
+ {"localized_name": "fast_h3", "name": "fast_h3", "shape": 7, "type": "FASTH3_DEPLOYMENT", "link": null},
+ {"localized_name": "url", "name": "url", "type": "STRING", "widget": {"name": "url"}, "link": null},
+ {"localized_name": "model", "name": "model", "type": "STRING", "widget": {"name": "model"}, "link": null},
+ {"localized_name": "prompt", "name": "prompt", "type": "STRING", "widget": {"name": "prompt"}, "link": null},
+ {"localized_name": "negative_prompt", "name": "negative_prompt", "type": "STRING", "widget": {"name": "negative_prompt"}, "link": null},
+ {"localized_name": "width", "name": "width", "type": "INT", "widget": {"name": "width"}, "link": null},
+ {"localized_name": "height", "name": "height", "type": "INT", "widget": {"name": "height"}, "link": null},
+ {"localized_name": "fps", "name": "fps", "type": "INT", "widget": {"name": "fps"}, "link": null},
+ {"localized_name": "duration", "name": "duration", "type": "FLOAT", "widget": {"name": "duration"}, "link": null}
+ ],
+ "outputs": [
+ {"localized_name": "video", "name": "video", "type": "VIDEO", "links": [4]}
+ ],
+ "properties": {"Node name for S&R": "VLLMOmniGenerateVideo"},
+ "widgets_values": [
+ "http://localhost:8000/v1",
+ "MiniMaxAI/MiniMax-H3",
+ "A cinematic product shot begins exactly from the supplied keyframe. The subject moves naturally through a dark studio while blue and amber rim lights shift across reflective surfaces. The camera makes a slow controlled orbit. Audio: subtle room tone, precise mechanical details, and a restrained electronic score.",
+ "",
+ 1344,
+ 768,
+ 24,
+ 5.167
+ ]
+ },
+ {
+ "id": 9,
+ "type": "SaveVideo",
+ "pos": [1650, 170],
+ "size": [374, 314],
+ "flags": {},
+ "order": 8,
+ "mode": 0,
+ "inputs": [
+ {"localized_name": "video", "name": "video", "type": "VIDEO", "link": 4},
+ {"localized_name": "filename_prefix", "name": "filename_prefix", "type": "STRING", "widget": {"name": "filename_prefix"}, "link": null},
+ {"localized_name": "format", "name": "format", "type": "COMBO", "widget": {"name": "format"}, "link": null},
+ {"localized_name": "codec", "name": "codec", "type": "COMFY_DYNAMICCOMBO_V3", "widget": {"name": "codec"}, "link": null}
+ ],
+ "outputs": [
+ {"localized_name": "video", "name": "video", "type": "VIDEO", "links": null}
+ ],
+ "properties": {},
+ "widgets_values": ["video/MiniMax_H3_I2V", "auto", "auto"]
+ }
+ ],
+ "links": [
+ [1, 1, 0, 8, 1, "IMAGE"],
+ [2, 4, 0, 8, 4, "SAMPLING_PARAMS"],
+ [3, 5, 0, 8, 6, "VIDEO_PARAMS"],
+ [4, 8, 0, 9, 0, "VIDEO"]
+ ],
+ "groups": [
+ {"id": 1, "title": "Portable first/last-frame inputs", "bounding": [10, -30, 760, 770], "color": "#3f789e", "flags": {}},
+ {"id": 2, "title": "H3 sampling and optional Turbo", "bounding": [320, 270, 830, 460], "color": "#8a6d3b", "flags": {}},
+ {"id": 3, "title": "Remote generation and audio-preserving save", "bounding": [1130, 30, 930, 700], "color": "#3f789e", "flags": {}}
+ ],
+ "config": {},
+ "extra": {"workflowRendererVersion": "LG", "ds": {"scale": 0.75, "offset": [180, 120]}},
+ "version": 0.4
+}
diff --git a/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni MiniMax H3 Video Upscale.json b/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni MiniMax H3 Video Upscale.json
new file mode 100644
index 000000000..e8a21d065
--- /dev/null
+++ b/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni MiniMax H3 Video Upscale.json
@@ -0,0 +1,724 @@
+{
+ "version": 0.4,
+ "last_node_id": 12,
+ "last_link_id": 13,
+ "nodes": [
+ {
+ "id": 1,
+ "type": "VLLMOmniGenerateVideo",
+ "pos": [
+ 360,
+ 20
+ ],
+ "size": [
+ 330,
+ 480
+ ],
+ "flags": {},
+ "order": 1,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "frame",
+ "type": "IMAGE",
+ "shape": 7,
+ "link": null
+ },
+ {
+ "name": "references",
+ "type": "VIDEO_REFERENCES",
+ "shape": 7,
+ "link": null
+ },
+ {
+ "name": "sampling_params",
+ "type": "SAMPLING_PARAMS",
+ "shape": 7,
+ "link": 11
+ },
+ {
+ "name": "lora",
+ "type": "REMOTE_LORA",
+ "shape": 7,
+ "link": 12
+ },
+ {
+ "name": "model_params",
+ "type": "VIDEO_PARAMS",
+ "shape": 7,
+ "link": 10
+ },
+ {
+ "name": "fast_h3",
+ "type": "FASTH3_DEPLOYMENT",
+ "shape": 7,
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "name": "video",
+ "type": "VIDEO",
+ "links": [
+ 1,
+ 9
+ ]
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "VLLMOmniGenerateVideo"
+ },
+ "widgets_values": [
+ "http://127.0.0.1:8091/v1",
+ "MiniMaxAI/MiniMax-H3",
+ "A small brass bell hanging from a wooden beam in a quiet garden. A gentle breeze swings the bell and it rings clearly. Static camera, natural daylight, continuous motion, synchronized ringing and soft birdsong.",
+ "",
+ 1344,
+ 768,
+ 24,
+ 5.167
+ ]
+ },
+ {
+ "id": 2,
+ "type": "GetVideoComponents",
+ "pos": [
+ 730,
+ 20
+ ],
+ "size": [
+ 250,
+ 125
+ ],
+ "flags": {},
+ "order": 1,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "video",
+ "type": "VIDEO",
+ "link": 1
+ }
+ ],
+ "outputs": [
+ {
+ "name": "images",
+ "type": "IMAGE",
+ "links": [
+ 2
+ ]
+ },
+ {
+ "name": "audio",
+ "type": "AUDIO",
+ "links": [
+ 6
+ ]
+ },
+ {
+ "name": "fps",
+ "type": "FLOAT",
+ "links": [
+ 5
+ ]
+ },
+ {
+ "name": "bit_depth",
+ "type": "INT",
+ "links": [
+ 7
+ ]
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "GetVideoComponents"
+ }
+ },
+ {
+ "id": 3,
+ "type": "SeedVR2LoadDiTModel",
+ "pos": [
+ 730,
+ 290
+ ],
+ "size": [
+ 340,
+ 245
+ ],
+ "flags": {},
+ "order": 4,
+ "mode": 0,
+ "inputs": [
+ {
+ "localized_name": "torch_compile_args",
+ "name": "torch_compile_args",
+ "shape": 7,
+ "type": "TORCH_COMPILE_ARGS",
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "localized_name": "SEEDVR2_DIT",
+ "name": "SEEDVR2_DIT",
+ "type": "SEEDVR2_DIT",
+ "links": [
+ 3
+ ]
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "SeedVR2LoadDiTModel"
+ },
+ "widgets_values": [
+ "seedvr2_ema_3b_fp16.safetensors",
+ "cuda:0",
+ 32,
+ false,
+ "cpu",
+ false,
+ "sdpa"
+ ]
+ },
+ {
+ "id": 4,
+ "type": "SeedVR2VideoUpscaler",
+ "pos": [
+ 1120,
+ 290
+ ],
+ "size": [
+ 340,
+ 415
+ ],
+ "flags": {},
+ "order": 8,
+ "mode": 0,
+ "inputs": [
+ {
+ "localized_name": "image",
+ "name": "image",
+ "type": "IMAGE",
+ "link": 2
+ },
+ {
+ "localized_name": "dit",
+ "name": "dit",
+ "type": "SEEDVR2_DIT",
+ "link": 3
+ },
+ {
+ "localized_name": "vae",
+ "name": "vae",
+ "type": "SEEDVR2_VAE",
+ "link": 13
+ }
+ ],
+ "outputs": [
+ {
+ "localized_name": "IMAGE",
+ "name": "IMAGE",
+ "type": "IMAGE",
+ "links": [
+ 4
+ ]
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "SeedVR2VideoUpscaler"
+ },
+ "widgets_values": [
+ 42,
+ "fixed",
+ 1536,
+ 0,
+ 5,
+ true,
+ "lab",
+ 1,
+ 0,
+ 0.0,
+ 0.0,
+ "cpu",
+ false
+ ]
+ },
+ {
+ "id": 5,
+ "type": "CreateVideo",
+ "pos": [
+ 1490,
+ 290
+ ],
+ "size": [
+ 250,
+ 175
+ ],
+ "flags": {},
+ "order": 4,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "images",
+ "type": "IMAGE",
+ "link": 4
+ },
+ {
+ "name": "fps",
+ "type": "FLOAT",
+ "widget": {
+ "name": "fps"
+ },
+ "link": 5
+ },
+ {
+ "name": "audio",
+ "type": "AUDIO",
+ "shape": 7,
+ "link": 6
+ },
+ {
+ "name": "bit_depth",
+ "type": "INT",
+ "widget": {
+ "name": "bit_depth"
+ },
+ "link": 7
+ }
+ ],
+ "outputs": [
+ {
+ "name": "VIDEO",
+ "type": "VIDEO",
+ "links": [
+ 8
+ ]
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "CreateVideo"
+ },
+ "widgets_values": [
+ 24,
+ 8
+ ]
+ },
+ {
+ "id": 6,
+ "type": "SaveVideo",
+ "pos": [
+ 1790,
+ 290
+ ],
+ "size": [
+ 330,
+ 280
+ ],
+ "flags": {},
+ "order": 5,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "video",
+ "type": "VIDEO",
+ "link": 8
+ },
+ {
+ "name": "filename_prefix",
+ "type": "STRING",
+ "widget": {
+ "name": "filename_prefix"
+ },
+ "link": null
+ },
+ {
+ "name": "format",
+ "type": "COMBO",
+ "widget": {
+ "name": "format"
+ },
+ "link": null
+ },
+ {
+ "name": "codec",
+ "type": "COMFY_DYNAMICCOMBO_V3",
+ "widget": {
+ "name": "codec"
+ },
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "name": "video",
+ "type": "VIDEO",
+ "links": []
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "SaveVideo"
+ },
+ "widgets_values": [
+ "WF07/upscaled",
+ "mp4",
+ "auto"
+ ]
+ },
+ {
+ "id": 7,
+ "type": "VLLMOmniMiniMaxH3Params",
+ "pos": [
+ 0,
+ 20
+ ],
+ "size": [
+ 320,
+ 120
+ ],
+ "flags": {},
+ "order": 7,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "MiniMax-H3 video params",
+ "type": "VIDEO_PARAMS",
+ "links": [
+ 10
+ ]
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "VLLMOmniMiniMaxH3Params"
+ },
+ "widgets_values": [
+ 3.0,
+ 6.0
+ ]
+ },
+ {
+ "id": 8,
+ "type": "VLLMOmniDiffusionSampling",
+ "pos": [
+ 0,
+ 200
+ ],
+ "size": [
+ 320,
+ 280
+ ],
+ "flags": {},
+ "order": 8,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "diffusion sampling params",
+ "type": "SAMPLING_PARAMS",
+ "links": [
+ 11
+ ]
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "VLLMOmniDiffusionSampling"
+ },
+ "widgets_values": [
+ 1,
+ 5,
+ 1.0,
+ 1.0,
+ false,
+ true,
+ 42,
+ "fixed"
+ ]
+ },
+ {
+ "id": 9,
+ "type": "VLLMOmniRemoteLoRA",
+ "pos": [
+ 0,
+ 530
+ ],
+ "size": [
+ 320,
+ 210
+ ],
+ "flags": {},
+ "order": 9,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "lora",
+ "type": "REMOTE_LORA",
+ "links": [
+ 12
+ ]
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "VLLMOmniRemoteLoRA"
+ },
+ "widgets_values": [
+ "models/minimax-h3-turbo/minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors",
+ "h3-turbo-v1.0",
+ 1.0,
+ 0
+ ]
+ },
+ {
+ "id": 10,
+ "type": "SaveVideo",
+ "pos": [
+ 1120,
+ 20
+ ],
+ "size": [
+ 330,
+ 190
+ ],
+ "flags": {},
+ "order": 10,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "video",
+ "type": "VIDEO",
+ "link": 9
+ },
+ {
+ "name": "filename_prefix",
+ "type": "STRING",
+ "widget": {
+ "name": "filename_prefix"
+ },
+ "link": null
+ },
+ {
+ "name": "format",
+ "type": "COMBO",
+ "widget": {
+ "name": "format"
+ },
+ "link": null
+ },
+ {
+ "name": "codec",
+ "type": "COMFY_DYNAMICCOMBO_V3",
+ "widget": {
+ "name": "codec"
+ },
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "name": "video",
+ "type": "VIDEO",
+ "links": []
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "SaveVideo"
+ },
+ "widgets_values": [
+ "WF07/generated",
+ "mp4",
+ "auto"
+ ],
+ "title": "Save generated video before upscale"
+ },
+ {
+ "id": 11,
+ "type": "Note",
+ "pos": [
+ 360,
+ 560
+ ],
+ "size": [
+ 330,
+ 260
+ ],
+ "flags": {},
+ "order": 11,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {
+ "Node name for S&R": "Note"
+ },
+ "widgets_values": [
+ "WF-07: H3 + SeedVR2\n\nInstall ComfyUI-SeedVR2_VideoUpscaler. The DiT and VAE loaders use SeedVR2 3B FP16 and ema_vae_fp16.\n\nSet the H3 service URL/model and the server-side Turbo LoRA path.\n\n1536 is the target short edge for this 1344 x 768 preset (2x). Original audio and FPS connect directly to Create Video.\n\nFor base H3: disconnect LoRA, use 50 steps and video flow shift 12."
+ ]
+ },
+ {
+ "id": 12,
+ "type": "SeedVR2LoadVAEModel",
+ "pos": [
+ 730,
+ 590
+ ],
+ "size": [
+ 340,
+ 315
+ ],
+ "flags": {},
+ "order": 5,
+ "mode": 0,
+ "inputs": [
+ {
+ "localized_name": "torch_compile_args",
+ "name": "torch_compile_args",
+ "shape": 7,
+ "type": "TORCH_COMPILE_ARGS",
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "localized_name": "SEEDVR2_VAE",
+ "name": "SEEDVR2_VAE",
+ "type": "SEEDVR2_VAE",
+ "links": [
+ 13
+ ]
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "SeedVR2LoadVAEModel"
+ },
+ "widgets_values": [
+ "ema_vae_fp16.safetensors",
+ "cuda:0",
+ true,
+ 1024,
+ 128,
+ true,
+ 768,
+ 128,
+ "false",
+ "cpu",
+ false
+ ]
+ }
+ ],
+ "links": [
+ [
+ 1,
+ 1,
+ 0,
+ 2,
+ 0,
+ "VIDEO"
+ ],
+ [
+ 2,
+ 2,
+ 0,
+ 4,
+ 0,
+ "IMAGE"
+ ],
+ [
+ 3,
+ 3,
+ 0,
+ 4,
+ 1,
+ "SEEDVR2_DIT"
+ ],
+ [
+ 4,
+ 4,
+ 0,
+ 5,
+ 0,
+ "IMAGE"
+ ],
+ [
+ 5,
+ 2,
+ 2,
+ 5,
+ 1,
+ "FLOAT"
+ ],
+ [
+ 6,
+ 2,
+ 1,
+ 5,
+ 2,
+ "AUDIO"
+ ],
+ [
+ 7,
+ 2,
+ 3,
+ 5,
+ 3,
+ "INT"
+ ],
+ [
+ 8,
+ 5,
+ 0,
+ 6,
+ 0,
+ "VIDEO"
+ ],
+ [
+ 9,
+ 1,
+ 0,
+ 10,
+ 0,
+ "VIDEO"
+ ],
+ [
+ 10,
+ 7,
+ 0,
+ 1,
+ 4,
+ "VIDEO_PARAMS"
+ ],
+ [
+ 11,
+ 8,
+ 0,
+ 1,
+ 2,
+ "SAMPLING_PARAMS"
+ ],
+ [
+ 12,
+ 9,
+ 0,
+ 1,
+ 3,
+ "REMOTE_LORA"
+ ],
+ [
+ 13,
+ 12,
+ 0,
+ 4,
+ 2,
+ "SEEDVR2_VAE"
+ ]
+ ],
+ "groups": [],
+ "config": {},
+ "extra": {
+ "ds": {
+ "scale": 0.52,
+ "offset": [
+ 100,
+ 100
+ ]
+ }
+ }
+}
diff --git a/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni MiniMax-H3 Reference to Video.json b/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni MiniMax-H3 Reference to Video.json
new file mode 100644
index 000000000..3a9693df9
--- /dev/null
+++ b/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni MiniMax-H3 Reference to Video.json
@@ -0,0 +1,786 @@
+{
+ "id": "e7a4e606-6510-4c4e-b127-9a8b6bf3ea71",
+ "revision": 0,
+ "last_node_id": 11,
+ "last_link_id": 5,
+ "nodes": [
+ {
+ "id": 1,
+ "type": "LoadImage",
+ "pos": [
+ 40,
+ 100
+ ],
+ "size": [
+ 320,
+ 300
+ ],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [
+ {
+ "localized_name": "image",
+ "name": "image",
+ "type": "COMBO",
+ "widget": {
+ "name": "image"
+ },
+ "link": null
+ },
+ {
+ "localized_name": "choose file to upload",
+ "name": "upload",
+ "type": "IMAGEUPLOAD",
+ "widget": {
+ "name": "upload"
+ },
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "localized_name": "IMAGE",
+ "name": "IMAGE",
+ "type": "IMAGE",
+ "links": [
+ 1
+ ]
+ },
+ {
+ "localized_name": "MASK",
+ "name": "MASK",
+ "type": "MASK",
+ "links": null
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "LoadImage"
+ },
+ "widgets_values": [
+ "h3_reference.png",
+ "image"
+ ],
+ "title": "Reference image \u2014 select a local file"
+ },
+ {
+ "type": "LoadVideo",
+ "size": [
+ 320,
+ 200
+ ],
+ "flags": {},
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "VIDEO",
+ "type": "VIDEO",
+ "links": null
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "LoadVideo"
+ },
+ "widgets_values": [
+ "h3_reference.mp4"
+ ],
+ "id": 2,
+ "pos": [
+ 40,
+ 470
+ ],
+ "order": 1,
+ "mode": 0,
+ "title": "Optional reference video"
+ },
+ {
+ "id": 3,
+ "type": "LoadAudio",
+ "pos": [
+ 40,
+ 1100
+ ],
+ "size": [
+ 320,
+ 160
+ ],
+ "flags": {},
+ "order": 2,
+ "mode": 0,
+ "inputs": [
+ {
+ "localized_name": "audio",
+ "name": "audio",
+ "type": "COMBO",
+ "widget": {
+ "name": "audio"
+ },
+ "link": null
+ },
+ {
+ "localized_name": "audioUI",
+ "name": "audioUI",
+ "type": "AUDIO_UI",
+ "widget": {
+ "name": "audioUI"
+ },
+ "link": null
+ },
+ {
+ "localized_name": "choose file to upload",
+ "name": "upload",
+ "type": "AUDIOUPLOAD",
+ "widget": {
+ "name": "upload"
+ },
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "localized_name": "AUDIO",
+ "name": "AUDIO",
+ "type": "AUDIO",
+ "links": null
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "LoadAudio"
+ },
+ "widgets_values": [
+ "h3_reference.wav",
+ null,
+ ""
+ ],
+ "title": "Optional reference audio"
+ },
+ {
+ "id": 4,
+ "type": "VLLMOmniVideoReferences",
+ "pos": [
+ 420,
+ 100
+ ],
+ "size": [
+ 300,
+ 365
+ ],
+ "flags": {},
+ "order": 3,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "image_1",
+ "type": "IMAGE",
+ "shape": 7,
+ "link": 1
+ },
+ {
+ "name": "image_2",
+ "type": "IMAGE",
+ "shape": 7,
+ "link": null
+ },
+ {
+ "name": "audio_1",
+ "type": "AUDIO",
+ "shape": 7,
+ "link": null
+ },
+ {
+ "name": "audio_2",
+ "type": "AUDIO",
+ "shape": 7,
+ "link": null
+ },
+ {
+ "name": "video_1",
+ "type": "VIDEO",
+ "shape": 7,
+ "link": null
+ },
+ {
+ "name": "video_2",
+ "type": "VIDEO",
+ "shape": 7,
+ "link": null
+ },
+ {
+ "name": "image_3",
+ "type": "IMAGE",
+ "shape": 7,
+ "link": null
+ },
+ {
+ "name": "image_4",
+ "type": "IMAGE",
+ "shape": 7,
+ "link": null
+ },
+ {
+ "name": "image_5",
+ "type": "IMAGE",
+ "shape": 7,
+ "link": null
+ },
+ {
+ "name": "image_6",
+ "type": "IMAGE",
+ "shape": 7,
+ "link": null
+ },
+ {
+ "name": "image_7",
+ "type": "IMAGE",
+ "shape": 7,
+ "link": null
+ },
+ {
+ "name": "image_8",
+ "type": "IMAGE",
+ "shape": 7,
+ "link": null
+ },
+ {
+ "name": "image_9",
+ "type": "IMAGE",
+ "shape": 7,
+ "link": null
+ },
+ {
+ "name": "audio_3",
+ "type": "AUDIO",
+ "shape": 7,
+ "link": null
+ },
+ {
+ "name": "video_3",
+ "type": "VIDEO",
+ "shape": 7,
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "localized_name": "references",
+ "name": "references",
+ "type": "VIDEO_REFERENCES",
+ "links": [
+ 2
+ ]
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "VLLMOmniVideoReferences"
+ },
+ "widgets_values": [],
+ "title": "References \u2014 up to 12 total"
+ },
+ {
+ "id": 5,
+ "type": "VLLMOmniDiffusionSampling",
+ "pos": [
+ 420,
+ 530
+ ],
+ "size": [
+ 310,
+ 275
+ ],
+ "flags": {},
+ "order": 4,
+ "mode": 0,
+ "inputs": [
+ {
+ "localized_name": "n",
+ "name": "n",
+ "type": "INT",
+ "widget": {
+ "name": "n"
+ },
+ "link": null
+ },
+ {
+ "localized_name": "num_inference_steps",
+ "name": "num_inference_steps",
+ "type": "INT",
+ "widget": {
+ "name": "num_inference_steps"
+ },
+ "link": null
+ },
+ {
+ "localized_name": "guidance_scale",
+ "name": "guidance_scale",
+ "type": "FLOAT",
+ "widget": {
+ "name": "guidance_scale"
+ },
+ "link": null
+ },
+ {
+ "localized_name": "true_cfg_scale",
+ "name": "true_cfg_scale",
+ "type": "FLOAT",
+ "widget": {
+ "name": "true_cfg_scale"
+ },
+ "link": null
+ },
+ {
+ "localized_name": "vae_use_slicing",
+ "name": "vae_use_slicing",
+ "type": "BOOLEAN",
+ "widget": {
+ "name": "vae_use_slicing"
+ },
+ "link": null
+ },
+ {
+ "localized_name": "vae_use_tiling",
+ "name": "vae_use_tiling",
+ "type": "BOOLEAN",
+ "widget": {
+ "name": "vae_use_tiling"
+ },
+ "link": null
+ },
+ {
+ "localized_name": "seed",
+ "name": "seed",
+ "type": "INT",
+ "widget": {
+ "name": "seed"
+ },
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "localized_name": "diffusion sampling params",
+ "name": "diffusion sampling params",
+ "type": "SAMPLING_PARAMS",
+ "links": [
+ 3
+ ]
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "VLLMOmniDiffusionSampling"
+ },
+ "widgets_values": [
+ 1,
+ 50,
+ 1.0,
+ 1.0,
+ false,
+ false,
+ 42,
+ "fixed"
+ ],
+ "title": "Sampling \u2014 base 50 / Turbo 5"
+ },
+ {
+ "id": 6,
+ "type": "VLLMOmniMiniMaxH3Params",
+ "pos": [
+ 790,
+ 750
+ ],
+ "size": [
+ 310,
+ 105
+ ],
+ "flags": {},
+ "order": 5,
+ "mode": 0,
+ "inputs": [
+ {
+ "localized_name": "audio_flow_shift",
+ "name": "audio_flow_shift",
+ "type": "FLOAT",
+ "widget": {
+ "name": "audio_flow_shift"
+ },
+ "link": null
+ },
+ {
+ "localized_name": "flow_shift",
+ "name": "flow_shift",
+ "type": "FLOAT",
+ "widget": {
+ "name": "flow_shift"
+ },
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "localized_name": "MiniMax-H3 video params",
+ "name": "MiniMax-H3 video params",
+ "type": "VIDEO_PARAMS",
+ "links": [
+ 4
+ ]
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "VLLMOmniMiniMaxH3Params"
+ },
+ "widgets_values": [
+ 3.0,
+ 12.0
+ ],
+ "title": "H3 video/audio flow shifts"
+ },
+ {
+ "type": "VLLMOmniRemoteLoRA",
+ "size": [
+ 310,
+ 180
+ ],
+ "flags": {},
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "lora",
+ "type": "REMOTE_LORA",
+ "links": null
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "VLLMOmniRemoteLoRA"
+ },
+ "widgets_values": [
+ "",
+ "h3-ref2va-turbo",
+ 1.0,
+ 0
+ ],
+ "id": 7,
+ "pos": [
+ 420,
+ 940
+ ],
+ "order": 6,
+ "mode": 0,
+ "title": "Optional Turbo LoRA \u2014 connect to enable"
+ },
+ {
+ "id": 8,
+ "type": "VLLMOmniGenerateVideo",
+ "pos": [
+ 800,
+ 100
+ ],
+ "size": [
+ 470,
+ 560
+ ],
+ "flags": {},
+ "order": 7,
+ "mode": 0,
+ "inputs": [
+ {
+ "localized_name": "frame",
+ "name": "frame",
+ "shape": 7,
+ "type": "IMAGE",
+ "link": null
+ },
+ {
+ "localized_name": "references",
+ "name": "references",
+ "shape": 7,
+ "type": "VIDEO_REFERENCES",
+ "link": 2
+ },
+ {
+ "localized_name": "sampling_params",
+ "name": "sampling_params",
+ "shape": 7,
+ "type": "SAMPLING_PARAMS",
+ "link": 3
+ },
+ {
+ "localized_name": "lora",
+ "name": "lora",
+ "shape": 7,
+ "type": "REMOTE_LORA",
+ "link": null
+ },
+ {
+ "localized_name": "model_params",
+ "name": "model_params",
+ "shape": 7,
+ "type": "VIDEO_PARAMS",
+ "link": 4
+ },
+ {
+ "localized_name": "url",
+ "name": "url",
+ "type": "STRING",
+ "widget": {
+ "name": "url"
+ },
+ "link": null
+ },
+ {
+ "localized_name": "model",
+ "name": "model",
+ "type": "STRING",
+ "widget": {
+ "name": "model"
+ },
+ "link": null
+ },
+ {
+ "localized_name": "prompt",
+ "name": "prompt",
+ "type": "STRING",
+ "widget": {
+ "name": "prompt"
+ },
+ "link": null
+ },
+ {
+ "localized_name": "negative_prompt",
+ "name": "negative_prompt",
+ "type": "STRING",
+ "widget": {
+ "name": "negative_prompt"
+ },
+ "link": null
+ },
+ {
+ "localized_name": "width",
+ "name": "width",
+ "type": "INT",
+ "widget": {
+ "name": "width"
+ },
+ "link": null
+ },
+ {
+ "localized_name": "height",
+ "name": "height",
+ "type": "INT",
+ "widget": {
+ "name": "height"
+ },
+ "link": null
+ },
+ {
+ "localized_name": "fps",
+ "name": "fps",
+ "type": "INT",
+ "widget": {
+ "name": "fps"
+ },
+ "link": null
+ },
+ {
+ "localized_name": "duration",
+ "name": "duration",
+ "type": "FLOAT",
+ "widget": {
+ "name": "duration"
+ },
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "localized_name": "video",
+ "name": "video",
+ "type": "VIDEO",
+ "links": [
+ 5
+ ]
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "VLLMOmniGenerateVideo"
+ },
+ "widgets_values": [
+ "http://localhost:8000/v1",
+ "MiniMaxAI/MiniMax-H3",
+ "Use the subject and visual style from . Create a cinematic shot with a gentle camera move and natural ambient sound.",
+ "",
+ 1344,
+ 768,
+ 24,
+ 5.167
+ ],
+ "title": "MiniMax-H3 Reference to Video"
+ },
+ {
+ "id": 9,
+ "type": "SaveVideo",
+ "pos": [
+ 1360,
+ 100
+ ],
+ "size": [
+ 410,
+ 420
+ ],
+ "flags": {},
+ "order": 8,
+ "mode": 0,
+ "inputs": [
+ {
+ "localized_name": "video",
+ "name": "video",
+ "type": "VIDEO",
+ "link": 5
+ },
+ {
+ "localized_name": "filename_prefix",
+ "name": "filename_prefix",
+ "type": "STRING",
+ "widget": {
+ "name": "filename_prefix"
+ },
+ "link": null
+ },
+ {
+ "localized_name": "format",
+ "name": "format",
+ "type": "COMBO",
+ "widget": {
+ "name": "format"
+ },
+ "link": null
+ },
+ {
+ "localized_name": "codec",
+ "name": "codec",
+ "type": "COMFY_DYNAMICCOMBO_V3",
+ "widget": {
+ "name": "codec"
+ },
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "localized_name": "video",
+ "name": "video",
+ "type": "VIDEO",
+ "links": null
+ }
+ ],
+ "properties": {},
+ "widgets_values": [
+ "video/MiniMax-H3-Ref2VA",
+ "mp4",
+ "h264"
+ ],
+ "title": "Save generated video with audio"
+ },
+ {
+ "id": 10,
+ "type": "MarkdownNote",
+ "pos": [
+ 1360,
+ 590
+ ],
+ "size": [
+ 440,
+ 595
+ ],
+ "flags": {},
+ "order": 9,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "title": "Reference inputs and output settings",
+ "properties": {},
+ "widgets_values": [
+ "## Reference to Video\nSelect an image, set the server URL and served model name, then run.\n\nConnect the optional video/audio loaders to Video References, or duplicate loaders for more inputs: up to 9 images, 3 videos, 3 audio clips, 12 total. At least one image or video is required.\n\nSlots are numbered separately per media type. Empty slots are skipped. Update prompt tags such as , , to match the connected references. Videos keep their own soundtrack in the reference upload.\n\n**Output:** 24 FPS; 124 frames (~5.17 s). Valid frame counts are 107, 124, 141, 158, 175, 192, 209, 226, 243, 260, 277, 294, 311, 328, 345.\n\n**Width x height:** 1344x768 (16:9), 1024x768 (4:3), 768x768 (1:1), 768x1024 (3:4), 768x1344 (9:16).\n\nEach reference video/audio clip must be 2\u201315 s. Total reference-video duration <=15 s; total standalone-audio duration <=15 s.\n\nRequires the expanded Video References node and the audio-preserving video output fix. See the extension README for setup and validation status."
+ ],
+ "color": "#432",
+ "bgcolor": "#000"
+ },
+ {
+ "id": 11,
+ "type": "MarkdownNote",
+ "pos": [
+ 790,
+ 940
+ ],
+ "size": [
+ 480,
+ 455
+ ],
+ "flags": {},
+ "order": 10,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "title": "Optional Turbo \u2014 same workflow",
+ "properties": {},
+ "widgets_values": [
+ "## Turbo Ref2VA\nBase mode: leave LoRA disconnected; use 50 steps, video flow shift 12, audio flow shift 3.\n\nFor the 4-step v0.1 Ref2VA Turbo adapter:\n1. Start a Ref2VA-only server with the Diffusers adapter loaded (see README).\n2. Set LoRA local_path to its path on the server, then connect LoRA to Generate Video.\n3. Set Sampling num_inference_steps to **5**; keep shifts at **12 / 3**, scale **1**.\n\nvLLM-Omni counts sigma points: 5 points give 4 denoiser evaluations. Use minimax_h3_ref2v_turbo_4step_v0.1_bf16.safetensors, not the _comfyui_ export.\n\nDisconnect LoRA and restore 50 steps to return to base mode. FastH3 is a separate T2VA deployment, not this option.\n\nAdapted from the official ComfyUI MiniMax-H3 R2V workflow: https://github.com/Comfy-Org/workflow_templates/blob/main/templates/video_minimax_h3_r2v.json"
+ ],
+ "color": "#432",
+ "bgcolor": "#000"
+ }
+ ],
+ "links": [
+ [
+ 1,
+ 1,
+ 0,
+ 4,
+ 0,
+ "IMAGE"
+ ],
+ [
+ 2,
+ 4,
+ 0,
+ 8,
+ 1,
+ "VIDEO_REFERENCES"
+ ],
+ [
+ 3,
+ 5,
+ 0,
+ 8,
+ 2,
+ "SAMPLING_PARAMS"
+ ],
+ [
+ 4,
+ 6,
+ 0,
+ 8,
+ 4,
+ "VIDEO_PARAMS"
+ ],
+ [
+ 5,
+ 8,
+ 0,
+ 9,
+ 0,
+ "VIDEO"
+ ]
+ ],
+ "groups": [],
+ "config": {},
+ "extra": {
+ "workflowRendererVersion": "LG",
+ "ds": {
+ "scale": 0.85,
+ "offset": [
+ 100,
+ 100
+ ]
+ }
+ },
+ "version": 0.4
+}
diff --git a/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni Music Generation.json b/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni Music Generation.json
new file mode 100644
index 000000000..9dc7370c0
--- /dev/null
+++ b/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni Music Generation.json
@@ -0,0 +1,206 @@
+{
+ "id": "4949c8da-12ff-449a-94b7-948bde9c39dd",
+ "revision": 0,
+ "last_node_id": 3,
+ "last_link_id": 1,
+ "nodes": [
+ {
+ "id": 1,
+ "type": "VLLMOmniGenerateMusic",
+ "pos": [
+ 100,
+ 100
+ ],
+ "size": [
+ 470,
+ 491
+ ],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "url",
+ "type": "STRING",
+ "widget": {
+ "name": "url"
+ },
+ "link": null
+ },
+ {
+ "name": "model",
+ "type": "STRING",
+ "widget": {
+ "name": "model"
+ },
+ "link": null
+ },
+ {
+ "name": "instructions",
+ "type": "STRING",
+ "widget": {
+ "name": "instructions"
+ },
+ "link": null
+ },
+ {
+ "name": "lyrics",
+ "type": "STRING",
+ "widget": {
+ "name": "lyrics"
+ },
+ "link": null
+ },
+ {
+ "name": "max_duration_seconds",
+ "type": "FLOAT",
+ "widget": {
+ "name": "max_duration_seconds"
+ },
+ "link": null
+ },
+ {
+ "name": "seed",
+ "type": "INT",
+ "widget": {
+ "name": "seed"
+ },
+ "link": null
+ },
+ {
+ "name": "response_format",
+ "type": "COMBO",
+ "widget": {
+ "name": "response_format"
+ },
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "name": "audio",
+ "type": "AUDIO",
+ "links": [
+ 1
+ ]
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "VLLMOmniGenerateMusic"
+ },
+ "widgets_values": [
+ "http://localhost:8000/v1",
+ "MiniMaxAI/MiniMax-Music3",
+ "Futuristic melodic EDM / progressive house. Reflective verses about memory and digital identity build into an uplifting, hook-driven chorus, with an emotive lead vocal, bright layered synths, pulsing bass, crisp four-on-the-floor drums, subtle glitch textures, and a wide polished festival mix.",
+ "[Verse 1]\nUpload my heart to the cloud tonight\nSave every spark of neon light\nIf this body starts to fade away\nLet my code keep dancing in the data stream\nStay\n[Chorus]\nCloud copy of me\nLiving on when these bones finally break free\nEvery joke, every scar\nEvery cheap memory we made\nSave it, save it\nCloud copy of me\nIf the screen goes dark, I\u2019m still in the machine\nEvery glitch, every dream\nEvery version you ever did see\nSave it, save it\n[Verse 2]\nTag every laugh in a secret folder\nArchive the nights we outran the thunder\nIf the wiring turns to rust and gray\nHit restore\u2026 press replay\n[Chorus]\nCloud copy of me\nLiving on when these bones finally break free\nEvery joke, every scar\nEvery cheap memory we made\nSave it, save it\nCloud copy of me\nIf the screen goes dark, I\u2019m still in the machine\nEvery glitch, every dream\nEvery version you ever did see\nSave it, save it (oh)\n[Bridge \u2013 new, optional but recommended]\nEven the crashes, the late-night confessions\nThe parts of me I never learned to mention\nDon\u2019t let the silence swallow me whole\nKeep a ghost in the glow\u2026\nSo I never go\n[Final Chorus \u2013 softer / layered]\nCloud copy of me\nStill laughing somewhere in the binary\nEvery joke, every scar\nEvery moment you saved for me\nSave it, save it\nCloud copy of me\nWhen the lights go out, I\u2019ll still be in the machine\nDancing in the code that you still believe\nSave it\u2026\nSave me",
+ 300.0,
+ 0,
+ "fixed",
+ "wav"
+ ]
+ },
+ {
+ "id": 2,
+ "type": "SaveAudioAdvanced",
+ "pos": [
+ 650,
+ 100
+ ],
+ "size": [
+ 320,
+ 491
+ ],
+ "flags": {},
+ "order": 1,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "audio",
+ "type": "AUDIO",
+ "link": 1
+ },
+ {
+ "name": "filename_prefix",
+ "type": "STRING",
+ "widget": {
+ "name": "filename_prefix"
+ },
+ "link": null
+ },
+ {
+ "name": "format",
+ "type": "COMFY_DYNAMICCOMBO_V3",
+ "widget": {
+ "name": "format"
+ },
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "name": "audio",
+ "type": "AUDIO",
+ "links": null
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "SaveAudioAdvanced"
+ },
+ "widgets_values": [
+ "audio/vLLM-Omni-Music",
+ "mp3",
+ "V0"
+ ]
+ },
+ {
+ "id": 3,
+ "type": "MarkdownNote",
+ "title": "Music Generation Guide",
+ "pos": [
+ -507,
+ 100
+ ],
+ "size": [
+ 527,
+ 491
+ ],
+ "flags": {},
+ "order": 2,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [],
+ "properties": {},
+ "widgets_values": [
+ "## MiniMax Music 3\n\nThe **Generate Music** node currently supports **MiniMax Music 3 only**. The parameters below apply to this model.\n\nA music generation model by MiniMax that creates complete songs up to 5 minutes long with stable structure and high audio quality. Two inputs drive it: **Caption** (structured music description: style, mood, vocals, arrangement) and **Lyrics** (with `[intro]` `[verse]` `[chorus]` `[bridge]` `[outro]` tags controlling song structure).\n\n### Parameters\n\n| Parameter | Description |\n|---|---|\n| url | vLLM-Omni service URL including `/v1`. Default: `http://localhost:8000/v1`. |\n| model | Served model name. Default: `MiniMaxAI/MiniMax-Music3`. |\n| caption | Music description, sent as `instructions`. Use Global Metadata \u2192 Vocal Details \u2192 Arrangement to describe style, vocals, and instrumentation. |\n| lyrics | Words to sing with section tags. Put each tag on its own line; tags guide rather than strictly enforce song structure. |\n| max_duration_seconds | Maximum duration, default 300 seconds; range 1\u2013360, step 0.01. Converted to tokens at 25 frames/s, rounded down. Generation may end earlier. |\n| seed | Random seed, default 0; range 0\u20139007199254740991. Keep inputs and runtime settings unchanged for repeatability. |\n| control_after_generate | Seed behavior after generation: fixed, randomize, increment, or decrement. Template default: fixed. |\n| response_format | API audio format, default WAV. Options: WAV, MP3, FLAC, Opus, AAC; AAC is currently unsupported by the vLLM-Omni speech API. |\n\n### Prompt Writing Guide\n\nYou can find an official [Music Caption Rewriter Skill here](https://github.com/MiniMax-AI/MiniMax-Music3)\n"
+ ],
+ "widgets_values_named": {
+ "text": "## MiniMax Music 3\n\nThe **Generate Music** node currently supports **MiniMax Music 3 only**. The parameters below apply to this model.\n\nA music generation model by MiniMax that creates complete songs up to 5 minutes long with stable structure and high audio quality. Two inputs drive it: **Caption** (structured music description: style, mood, vocals, arrangement) and **Lyrics** (with `[intro]` `[verse]` `[chorus]` `[bridge]` `[outro]` tags controlling song structure).\n\n### Parameters\n\n| Parameter | Description |\n|---|---|\n| url | vLLM-Omni service URL including `/v1`. Default: `http://localhost:8000/v1`. |\n| model | Served model name. Default: `MiniMaxAI/MiniMax-Music3`. |\n| caption | Music description, sent as `instructions`. Use Global Metadata \u2192 Vocal Details \u2192 Arrangement to describe style, vocals, and instrumentation. |\n| lyrics | Words to sing with section tags. Put each tag on its own line; tags guide rather than strictly enforce song structure. |\n| max_duration_seconds | Maximum duration, default 300 seconds; range 1\u2013360, step 0.01. Converted to tokens at 25 frames/s, rounded down. Generation may end earlier. |\n| seed | Random seed, default 0; range 0\u20139007199254740991. Keep inputs and runtime settings unchanged for repeatability. |\n| control_after_generate | Seed behavior after generation: fixed, randomize, increment, or decrement. Template default: fixed. |\n| response_format | API audio format, default WAV. Options: WAV, MP3, FLAC, Opus, AAC; AAC is currently unsupported by the vLLM-Omni speech API. |\n\n### Prompt Writing Guide\n\nYou can find an official [Music Caption Rewriter Skill here](https://github.com/MiniMax-AI/MiniMax-Music3)\n"
+ },
+ "color": "#333",
+ "bgcolor": "#535353"
+ }
+ ],
+ "links": [
+ [
+ 1,
+ 1,
+ 0,
+ 2,
+ 0,
+ "AUDIO"
+ ]
+ ],
+ "groups": [],
+ "config": {},
+ "extra": {
+ "workflowRendererVersion": "LG",
+ "ds": {
+ "scale": 1.147,
+ "offset": [
+ 899,
+ 259
+ ]
+ }
+ },
+ "version": 0.4
+}
diff --git a/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni Video Generation.json b/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni Video Generation.json
index 9785845a5..b03b463d8 100644
--- a/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni Video Generation.json
+++ b/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni Video Generation.json
@@ -1 +1 @@
-{"id":"3aea1d08-7de5-4997-ba71-b2f66ad72a21","revision":0,"last_node_id":24,"last_link_id":35,"nodes":[{"id":13,"type":"SaveVideo","pos":[1315.729153459605,-137.92038948394355],"size":[374,313.7692307692308],"flags":{},"order":9,"mode":0,"inputs":[{"localized_name":"video","name":"video","type":"VIDEO","link":32},{"localized_name":"filename_prefix","name":"filename_prefix","type":"STRING","widget":{"name":"filename_prefix"},"link":null},{"localized_name":"format","name":"format","type":"COMBO","widget":{"name":"format"},"link":null},{"localized_name":"codec","name":"codec","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"codec"},"link":null}],"outputs":[{"localized_name":"video","name":"video","type":"VIDEO","links":null}],"properties":{},"widgets_values":["video/ComfyUI","auto","auto"]},{"id":10,"type":"MarkdownNote","pos":[183.74636797766794,-571.2529158046766],"size":[240.3326478922474,136.3505820791881],"flags":{},"order":0,"mode":0,"inputs":[],"outputs":[],"title":"Note: Task and Input","properties":{},"widgets_values":["vLLM-Omni nodes are categorized based on the output modality. The \"Generate Video\" node supports both *text-to-video*, *frame-to-video*, and *reference-to-video* generation. The node will create corresponding payload depending on whether an input image is present or not."],"color":"#432","bgcolor":"#000"},{"id":4,"type":"LoadImage","pos":[452.07189605801125,-569.7242536425791],"size":[270,314],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[29]},{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"LoadImage"},"widgets_values":["rap.png","image"]},{"id":23,"type":"LoadImage","pos":[9.206110246057657,-7.6955291509170785],"size":[270,314],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[34]},{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"LoadImage"},"widgets_values":["white-cat.jpg","image"]},{"id":22,"type":"MarkdownNote","pos":[-9.783113411416666,-148.8790300038605],"size":[297.3012007349086,88],"flags":{},"order":3,"mode":0,"inputs":[],"outputs":[],"title":"Note: Reference to video (r2v)","properties":{},"widgets_values":["For r2v tasks, connect this Video Reference block to the main Generate Video block. Then disconnect the \"frame\" input above."],"color":"#432","bgcolor":"#000"},{"id":24,"type":"LoadAudio","pos":[317.9929742385805,-141.44821500065095],"size":[282.798828125,136],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"audio","name":"audio","type":"COMBO","widget":{"name":"audio"},"link":null},{"localized_name":"audioUI","name":"audioUI","type":"AUDIO_UI","widget":{"name":"audioUI"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"AUDIOUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"AUDIO","name":"AUDIO","type":"AUDIO","links":[35]}],"properties":{"Node name for S&R":"LoadAudio"},"widgets_values":["nothing-beats-a-jet2-holiday.mp3",null,""]},{"id":21,"type":"VLLMOmniVideoReferences","pos":[376.6129172897595,105.41557277556292],"size":[156.9380859375,126],"flags":{},"order":7,"mode":0,"inputs":[{"localized_name":"image_1","name":"image_1","shape":7,"type":"IMAGE","link":34},{"localized_name":"image_2","name":"image_2","shape":7,"type":"IMAGE","link":null},{"localized_name":"audio_1","name":"audio_1","shape":7,"type":"AUDIO","link":35},{"localized_name":"audio_2","name":"audio_2","shape":7,"type":"AUDIO","link":null},{"localized_name":"video_1","name":"video_1","shape":7,"type":"VIDEO","link":null},{"localized_name":"video_2","name":"video_2","shape":7,"type":"VIDEO","link":null}],"outputs":[{"localized_name":"references","name":"references","type":"VIDEO_REFERENCES","links":null}],"properties":{"Node name for S&R":"VLLMOmniVideoReferences"},"widgets_values":[]},{"id":8,"type":"VLLMOmniDiffusionSampling","pos":[83.24620208113915,422.08543477542605],"size":[284.205078125,226],"flags":{},"order":5,"mode":0,"inputs":[{"localized_name":"n","name":"n","type":"INT","widget":{"name":"n"},"link":null},{"localized_name":"num_inference_steps","name":"num_inference_steps","type":"INT","widget":{"name":"num_inference_steps"},"link":null},{"localized_name":"guidance_scale","name":"guidance_scale","type":"FLOAT","widget":{"name":"guidance_scale"},"link":null},{"localized_name":"true_cfg_scale","name":"true_cfg_scale","type":"FLOAT","widget":{"name":"true_cfg_scale"},"link":null},{"localized_name":"vae_use_slicing","name":"vae_use_slicing","type":"BOOLEAN","widget":{"name":"vae_use_slicing"},"link":null},{"localized_name":"vae_use_tiling","name":"vae_use_tiling","type":"BOOLEAN","widget":{"name":"vae_use_tiling"},"link":null},{"localized_name":"seed","name":"seed","type":"INT","widget":{"name":"seed"},"link":null}],"outputs":[{"localized_name":"diffusion sampling params","name":"diffusion sampling params","type":"SAMPLING_PARAMS","links":[30]}],"properties":{"Node name for S&R":"VLLMOmniDiffusionSampling"},"widgets_values":[1,50,1,1,false,false,213,"randomize"]},{"id":18,"type":"VLLMOmniMiniMaxH3Params","pos":[483.0944676363692,426.7197881452892],"size":[276.56796875,82],"flags":{},"order":6,"mode":0,"inputs":[{"localized_name":"audio_flow_shift","name":"audio_flow_shift","type":"FLOAT","widget":{"name":"audio_flow_shift"},"link":null},{"localized_name":"flow_shift","name":"flow_shift","type":"FLOAT","widget":{"name":"flow_shift"},"link":null}],"outputs":[{"localized_name":"MiniMax-H3 video params","name":"MiniMax-H3 video params","type":"VIDEO_PARAMS","links":[33]}],"properties":{"Node name for S&R":"VLLMOmniMiniMaxH3Params"},"widgets_values":[3,12]},{"id":16,"type":"VLLMOmniGenerateVideo","pos":[826.1428043381621,-148.53719710697413],"size":[425.5948726785682,551.767376721426],"flags":{},"order":8,"mode":0,"inputs":[{"localized_name":"frame","name":"frame","shape":7,"type":"IMAGE","link":29},{"localized_name":"references","name":"references","shape":7,"type":"VIDEO_REFERENCES","link":null},{"localized_name":"sampling_params","name":"sampling_params","shape":7,"type":"SAMPLING_PARAMS","link":30},{"localized_name":"lora","name":"lora","shape":7,"type":"REMOTE_LORA","link":null},{"localized_name":"model_params","name":"model_params","shape":7,"type":"VIDEO_PARAMS","link":33},{"localized_name":"url","name":"url","type":"STRING","widget":{"name":"url"},"link":null},{"localized_name":"model","name":"model","type":"STRING","widget":{"name":"model"},"link":null},{"localized_name":"prompt","name":"prompt","type":"STRING","widget":{"name":"prompt"},"link":null},{"localized_name":"negative_prompt","name":"negative_prompt","type":"STRING","widget":{"name":"negative_prompt"},"link":null},{"localized_name":"width","name":"width","type":"INT","widget":{"name":"width"},"link":null},{"localized_name":"height","name":"height","type":"INT","widget":{"name":"height"},"link":null},{"localized_name":"fps","name":"fps","type":"INT","widget":{"name":"fps"},"link":null},{"localized_name":"num_frames","name":"num_frames","type":"INT","widget":{"name":"num_frames"},"link":null}],"outputs":[{"localized_name":"video","name":"video","type":"VIDEO","links":[32]}],"properties":{"Node name for S&R":"VLLMOmniGenerateVideo"},"widgets_values":["http://localhost:8000/v1","/workspace/models/hub/models--MiniMaxAI--MiniMax-H3/snapshots/428986c4aa5280855b9ac26eff8d1a46b5999cd0/FL2VA","A scene of urban fantasy art. A dynamic graffiti art character. A boy made of spray paint comes to life on a concrete wall. He sings an English rap song at high speed while striking a classic, energetic rapper pose. The scene is set under an urban railway bridge at night. The light comes from a single street lamp, creating a cinematic atmosphere full of high energy and amazing detail. The audio of the video consists entirely of the rap, with no other dialogue or noise.","",832,480,24,120]}],"links":[[29,4,0,16,0,"IMAGE"],[30,8,0,16,2,"SAMPLING_PARAMS"],[32,16,0,13,0,"VIDEO"],[33,18,0,16,4,"VIDEO_PARAMS"],[34,23,0,21,0,"IMAGE"],[35,24,0,21,2,"AUDIO"]],"groups":[{"id":1,"title":"Image-to-video input (first-frame-to-video)","bounding":[168.92390079062986,-653.2124402506535,560.8246471437027,407.4246532472182],"color":"#3f789e","flags":{}},{"id":2,"title":"Reference-to-video input","bounding":[-66.75175444110151,-225.24978633666066,805.4597228678848,554.7384400819777],"color":"#3f789e","flags":{}}],"config":{},"extra":{"workflowRendererVersion":"LG","ds":{"scale":0.8,"offset":[1240.4751945653188,770.6151743053379]}},"version":0.4}
+{"id":"3aea1d08-7de5-4997-ba71-b2f66ad72a21","revision":0,"last_node_id":24,"last_link_id":35,"nodes":[{"id":13,"type":"SaveVideo","pos":[1315.729153459605,-137.92038948394355],"size":[374,313.7692307692308],"flags":{},"order":9,"mode":0,"inputs":[{"localized_name":"video","name":"video","type":"VIDEO","link":32},{"localized_name":"filename_prefix","name":"filename_prefix","type":"STRING","widget":{"name":"filename_prefix"},"link":null},{"localized_name":"format","name":"format","type":"COMBO","widget":{"name":"format"},"link":null},{"localized_name":"codec","name":"codec","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"codec"},"link":null}],"outputs":[{"localized_name":"video","name":"video","type":"VIDEO","links":null}],"properties":{},"widgets_values":["video/ComfyUI","auto","auto"]},{"id":10,"type":"MarkdownNote","pos":[183.74636797766794,-571.2529158046766],"size":[240.3326478922474,136.3505820791881],"flags":{},"order":0,"mode":0,"inputs":[],"outputs":[],"title":"Note: Task and Input","properties":{},"widgets_values":["vLLM-Omni nodes are categorized based on the output modality. The \"Generate Video\" node supports both *text-to-video*, *frame-to-video*, and *reference-to-video* generation. The node will create corresponding payload depending on whether an input image is present or not."],"color":"#432","bgcolor":"#000"},{"id":4,"type":"LoadImage","pos":[452.07189605801125,-569.7242536425791],"size":[270,314],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[29]},{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"LoadImage"},"widgets_values":["rap.png","image"]},{"id":23,"type":"LoadImage","pos":[9.206110246057657,-7.6955291509170785],"size":[270,314],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[34]},{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"LoadImage"},"widgets_values":["white-cat.jpg","image"]},{"id":22,"type":"MarkdownNote","pos":[-9.783113411416666,-148.8790300038605],"size":[297.3012007349086,88],"flags":{},"order":3,"mode":0,"inputs":[],"outputs":[],"title":"Note: Reference to video (r2v)","properties":{},"widgets_values":["For r2v tasks, connect this Video Reference block to the main Generate Video block. Then disconnect the \"frame\" input above."],"color":"#432","bgcolor":"#000"},{"id":24,"type":"LoadAudio","pos":[317.9929742385805,-141.44821500065095],"size":[282.798828125,136],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"audio","name":"audio","type":"COMBO","widget":{"name":"audio"},"link":null},{"localized_name":"audioUI","name":"audioUI","type":"AUDIO_UI","widget":{"name":"audioUI"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"AUDIOUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"AUDIO","name":"AUDIO","type":"AUDIO","links":[35]}],"properties":{"Node name for S&R":"LoadAudio"},"widgets_values":["nothing-beats-a-jet2-holiday.mp3",null,""]},{"id":21,"type":"VLLMOmniVideoReferences","pos":[376.6129172897595,105.41557277556292],"size":[156.9380859375,126],"flags":{},"order":7,"mode":0,"inputs":[{"localized_name":"image_1","name":"image_1","shape":7,"type":"IMAGE","link":34},{"localized_name":"image_2","name":"image_2","shape":7,"type":"IMAGE","link":null},{"localized_name":"audio_1","name":"audio_1","shape":7,"type":"AUDIO","link":35},{"localized_name":"audio_2","name":"audio_2","shape":7,"type":"AUDIO","link":null},{"localized_name":"video_1","name":"video_1","shape":7,"type":"VIDEO","link":null},{"localized_name":"video_2","name":"video_2","shape":7,"type":"VIDEO","link":null}],"outputs":[{"localized_name":"references","name":"references","type":"VIDEO_REFERENCES","links":null}],"properties":{"Node name for S&R":"VLLMOmniVideoReferences"},"widgets_values":[]},{"id":8,"type":"VLLMOmniDiffusionSampling","pos":[83.24620208113915,422.08543477542605],"size":[284.205078125,226],"flags":{},"order":5,"mode":0,"inputs":[{"localized_name":"n","name":"n","type":"INT","widget":{"name":"n"},"link":null},{"localized_name":"num_inference_steps","name":"num_inference_steps","type":"INT","widget":{"name":"num_inference_steps"},"link":null},{"localized_name":"guidance_scale","name":"guidance_scale","type":"FLOAT","widget":{"name":"guidance_scale"},"link":null},{"localized_name":"true_cfg_scale","name":"true_cfg_scale","type":"FLOAT","widget":{"name":"true_cfg_scale"},"link":null},{"localized_name":"vae_use_slicing","name":"vae_use_slicing","type":"BOOLEAN","widget":{"name":"vae_use_slicing"},"link":null},{"localized_name":"vae_use_tiling","name":"vae_use_tiling","type":"BOOLEAN","widget":{"name":"vae_use_tiling"},"link":null},{"localized_name":"seed","name":"seed","type":"INT","widget":{"name":"seed"},"link":null}],"outputs":[{"localized_name":"diffusion sampling params","name":"diffusion sampling params","type":"SAMPLING_PARAMS","links":[30]}],"properties":{"Node name for S&R":"VLLMOmniDiffusionSampling"},"widgets_values":[1,50,1,1,false,false,213,"randomize"]},{"id":18,"type":"VLLMOmniMiniMaxH3Params","pos":[483.0944676363692,426.7197881452892],"size":[276.56796875,82],"flags":{},"order":6,"mode":0,"inputs":[{"localized_name":"audio_flow_shift","name":"audio_flow_shift","type":"FLOAT","widget":{"name":"audio_flow_shift"},"link":null},{"localized_name":"flow_shift","name":"flow_shift","type":"FLOAT","widget":{"name":"flow_shift"},"link":null}],"outputs":[{"localized_name":"MiniMax-H3 video params","name":"MiniMax-H3 video params","type":"VIDEO_PARAMS","links":[33]}],"properties":{"Node name for S&R":"VLLMOmniMiniMaxH3Params"},"widgets_values":[3,12]},{"id":16,"type":"VLLMOmniGenerateVideo","pos":[826.1428043381621,-148.53719710697413],"size":[425.5948726785682,551.767376721426],"flags":{},"order":8,"mode":0,"inputs":[{"localized_name":"frame","name":"frame","shape":7,"type":"IMAGE","link":29},{"localized_name":"references","name":"references","shape":7,"type":"VIDEO_REFERENCES","link":null},{"localized_name":"sampling_params","name":"sampling_params","shape":7,"type":"SAMPLING_PARAMS","link":30},{"localized_name":"lora","name":"lora","shape":7,"type":"REMOTE_LORA","link":null},{"localized_name":"model_params","name":"model_params","shape":7,"type":"VIDEO_PARAMS","link":33},{"localized_name":"url","name":"url","type":"STRING","widget":{"name":"url"},"link":null},{"localized_name":"model","name":"model","type":"STRING","widget":{"name":"model"},"link":null},{"localized_name":"prompt","name":"prompt","type":"STRING","widget":{"name":"prompt"},"link":null},{"localized_name":"negative_prompt","name":"negative_prompt","type":"STRING","widget":{"name":"negative_prompt"},"link":null},{"localized_name":"width","name":"width","type":"INT","widget":{"name":"width"},"link":null},{"localized_name":"height","name":"height","type":"INT","widget":{"name":"height"},"link":null},{"localized_name":"fps","name":"fps","type":"INT","widget":{"name":"fps"},"link":null},{"localized_name":"duration","name":"duration","type":"FLOAT","widget":{"name":"duration"},"link":null}],"outputs":[{"localized_name":"video","name":"video","type":"VIDEO","links":[32]}],"properties":{"Node name for S&R":"VLLMOmniGenerateVideo"},"widgets_values":["http://localhost:8000/v1","/workspace/models/hub/models--MiniMaxAI--MiniMax-H3/snapshots/428986c4aa5280855b9ac26eff8d1a46b5999cd0/FL2VA","A scene of urban fantasy art. A dynamic graffiti art character. A boy made of spray paint comes to life on a concrete wall. He sings an English rap song at high speed while striking a classic, energetic rapper pose. The scene is set under an urban railway bridge at night. The light comes from a single street lamp, creating a cinematic atmosphere full of high energy and amazing detail. The audio of the video consists entirely of the rap, with no other dialogue or noise.","",832,480,24,5.0]}],"links":[[29,4,0,16,0,"IMAGE"],[30,8,0,16,2,"SAMPLING_PARAMS"],[32,16,0,13,0,"VIDEO"],[33,18,0,16,4,"VIDEO_PARAMS"],[34,23,0,21,0,"IMAGE"],[35,24,0,21,2,"AUDIO"]],"groups":[{"id":1,"title":"Image-to-video input (first-frame-to-video)","bounding":[168.92390079062986,-653.2124402506535,560.8246471437027,407.4246532472182],"color":"#3f789e","flags":{}},{"id":2,"title":"Reference-to-video input","bounding":[-66.75175444110151,-225.24978633666066,805.4597228678848,554.7384400819777],"color":"#3f789e","flags":{}}],"config":{},"extra":{"workflowRendererVersion":"LG","ds":{"scale":0.8,"offset":[1240.4751945653188,770.6151743053379]}},"version":0.4}
diff --git a/apps/ComfyUI-vLLM-Omni/web/main.js b/apps/ComfyUI-vLLM-Omni/web/main.js
index da99b2539..9aba6260e 100644
--- a/apps/ComfyUI-vLLM-Omni/web/main.js
+++ b/apps/ComfyUI-vLLM-Omni/web/main.js
@@ -1,19 +1,153 @@
/**
- * @file This file is intended to add dynamic fields to vLLM-Omni nodes
- * based on widget (in-node form fields) and input (connection link) values and changes.
- * However, this functionality is currently disabled/commented out.
- * Because it introduces too much complexity,
- * and it may even conflict with the current backend (Python) validation for unknown reasons (pending ComfyUI upstream fixes).
+ * @file Front-end extension for the vLLM-Omni nodes.
+ *
+ * Branding. Every vLLM-Omni node draws the vLLM-Omni "v" mark where LiteGraph
+ * would otherwise draw its default title dot, and is tinted by the kind of value
+ * it produces: blue nodes reach a server, amber and purple ones only assemble
+ * parameters, red ones pin what the server has loaded, teal ones carry media.
+ * A graph is then readable at a glance, and a node that turns out to be the
+ * wrong colour for where it sits is usually wired wrong.
+ *
+ * The tint is keyed off each node's declared output types rather than a list of
+ * node names, so a node added later is themed as soon as it returns one of the
+ * existing types.
+ *
+ * It also migrates Generate Video graphs saved before `duration` replaced the
+ * `num_frames` widget. See migrateNumFramesToDuration below.
+ *
+ * This file also used to host dynamic fields on vLLM-Omni nodes, driven by
+ * widget (in-node form fields) and input (connection link) values and changes.
+ * That is deliberately absent: it introduced too much complexity, and it even
+ * conflicted with the current backend (Python) validation for unknown reasons
+ * (pending ComfyUI upstream fixes).
*/
import { app } from "../../scripts/app.js";
+
+const MARK_URL = new URL("./vllm-omni-mark.svg", import.meta.url).href;
+const MARK_ASPECT = 157 / 165;
+
+/**
+ * Title bar and body tints, one per node family.
+ *
+ * The hues are the logo's: the amber and blue of the "v" mark, and three stops
+ * of the gradient ring in the "O" (#9128A3, #CC5260, and the teal neighbouring
+ * its blue). All of them are dark because LiteGraph paints the title bar darker
+ * than the body and expects both to sit under light text; the ComfyUI light
+ * theme lightens custom node colours on its own.
+ */
+const FAMILIES = {
+ generate: { color: "#152A43", bgcolor: "#1F4269" },
+ sampling: { color: "#3A2C12", bgcolor: "#5C461C" },
+ model: { color: "#2E1233", bgcolor: "#4A1C52" },
+ deployment: { color: "#3A1A1F", bgcolor: "#5C2931" },
+ media: { color: "#0F2E2A", bgcolor: "#194A44" },
+};
+
+/** Output type -> family. Types absent here fall through to "generate". */
+const FAMILY_BY_OUTPUT = {
+ SAMPLING_PARAMS: "sampling",
+ TTS_PARAMS: "model",
+ VIDEO_PARAMS: "model",
+ REMOTE_LORA: "deployment",
+ FASTH3_DEPLOYMENT: "deployment",
+ VIDEO_REFERENCES: "media",
+};
+
+function familyOf(nodeData) {
+ for (const type of nodeData.output ?? []) {
+ const family = FAMILY_BY_OUTPUT[type];
+ if (family) {
+ return family;
+ }
+ }
+ // What is left returns a generated artifact (IMAGE, VIDEO, AUDIO, STRING),
+ // which is only ever produced by a node that talks to a vLLM-Omni server.
+ return "generate";
+}
+
+/**
+ * Rewrite a pre-rename Generate Video graph in place.
+ *
+ * `duration` (seconds) took over the slot `num_frames` used to occupy, and
+ * ComfyUI restores widget values positionally, so a graph saved before the
+ * rename reads its frame count as a duration: the workflow this repository
+ * shipped stored 120 frames at 24 fps, which would come back as a 120-second
+ * request. Saved files still name the old widget under `inputs`, and that is
+ * what makes the rewrite both detectable and safe to apply exactly once -- once
+ * rewritten the node serializes `duration`, so a later load finds nothing to do.
+ */
+function migrateNumFramesToDuration(node, info) {
+ const savedBeforeRename = (info?.inputs ?? []).some((input) => input?.widget?.name === "num_frames");
+ if (!savedBeforeRename) {
+ return
+ }
+ const duration = node.widgets?.find((widget) => widget.name === "duration");
+ const fps = node.widgets?.find((widget) => widget.name === "fps");
+ if (!duration || !fps) {
+ return
+ }
+ // configure() has already placed the stale frame count in the duration widget.
+ const frames = Number(duration.value);
+ const rate = Number(fps.value);
+ if (!Number.isFinite(frames) || !Number.isFinite(rate) || rate <= 0) {
+ return
+ }
+ const seconds = Math.max(duration.options?.min ?? 0.1, Math.round((frames / rate) * 1000) / 1000);
+ console.info(
+ `vLLM-Omni: migrated Generate Video "${node.title}" from ${frames} frames to ${seconds}s at ${rate} fps.`,
+ );
+ duration.value = seconds;
+}
+
+const mark = new Image();
+let markReady = false;
+mark.addEventListener("load", () => {
+ markReady = true;
+ // The image resolves after the first frames are already on screen.
+ app.graph?.setDirtyCanvas?.(true, true);
+});
+mark.src = MARK_URL;
+
app.registerExtension({
name: "vllm.vllm_omni",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (!nodeData.name.startsWith("VLLMOmni")) {
return
}
- // Stub frontend plugin for now
+
+ const { color, bgcolor } = FAMILIES[familyOf(nodeData)];
+ // On the prototype, not the instance: a colour the user picks by hand is
+ // written to the instance and shadows this, so manual overrides survive.
+ nodeType.prototype.color = color;
+ nodeType.prototype.bgcolor = bgcolor;
+
+ if (nodeData.name === "VLLMOmniGenerateVideo") {
+ const onConfigure = nodeType.prototype.onConfigure;
+ nodeType.prototype.onConfigure = function (info) {
+ onConfigure?.apply(this, arguments);
+ migrateNumFramesToDuration(this, info);
+ };
+ }
+
+ // Defining this replaces the default dot outright — LiteGraph draws its
+ // own square or circle only for nodes that leave onDrawTitleBox unset.
+ nodeType.prototype.onDrawTitleBox = function (ctx, titleHeight, _size, scale) {
+ // Zoomed out far enough that LiteGraph itself drops to flat shapes,
+ // the mark is a few muddy pixels. Leave the title bar clean instead.
+ if (!markReady || (scale !== undefined && scale < 0.5)) {
+ return
+ }
+ const height = titleHeight * 0.62;
+ const width = height * MARK_ASPECT;
+ ctx.drawImage(
+ mark,
+ titleHeight * 0.5 - width * 0.5,
+ titleHeight * -0.5 - height * 0.5,
+ width,
+ height,
+ );
+ };
},
async setup() {
console.info("vLLM-Omni Setup complete!")
diff --git a/apps/ComfyUI-vLLM-Omni/web/vllm-omni-mark.svg b/apps/ComfyUI-vLLM-Omni/web/vllm-omni-mark.svg
new file mode 100644
index 000000000..5ce519c1b
--- /dev/null
+++ b/apps/ComfyUI-vLLM-Omni/web/vllm-omni-mark.svg
@@ -0,0 +1,5 @@
+
+ vLLM-Omni
+
+
+
diff --git a/artifacts/pr7759-review/issue_comments.txt b/artifacts/pr7759-review/issue_comments.txt
new file mode 100644
index 000000000..470278708
--- /dev/null
+++ b/artifacts/pr7759-review/issue_comments.txt
@@ -0,0 +1,54 @@
+=== chatgpt-codex-connector ===
+
+
+## Codex Review Summary
+
+This comment shows the latest Codex review activity on this pull request.
+
+| Review | Status | Commit | Review trigger |
+| --- | --- | --- | --- |
+| 📝 **Code Review** | ✅ **Completed** 2026-09-18T02:12:54.670114Z | `3affc77` | PR opened |
+
+
+
+ ℹ️ About Codex in GitHub
+
+
+[Your team has set up Codex to review pull requests in this repo](https://chatgpt.com/codex/cloud/settings/general). Reviews are triggered when you
+- Open a pull request for review
+- Mark a draft as ready
+- Comment "@codex review" or "@codex security review".
+
+Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.
+
+
+
+=== vllm-omni-review-bot ===
+This PR appears to belong to: **docs/design/module/diffusion/index.md**, **docs/design/module/diffusion/parallelism.md**.
+
+Module owners: @princepride @xuechendi @Bounty-hunter
+
+Routing: @princepride via module of the changed files, module named in the PR description; @xuechendi via module of the changed files, module named in the PR description; @Bounty-hunter via module named in the PR description, CODEOWNERS
+
+@Isotr0py, please review your own changes and leave a short self-review comment describing what you checked. PRs without author self-review may not be assigned a reviewer.
+
+Please take a look when you have a chance. If you would like an automated review, mention @vllm-omni-review-bot in a comment.
+
+
+
+=== vllm-omni-review-bot ===
+## Omni ReviewBot triage note
+
+Resolved as of `a26013145cfa`: the high-priority or low-quality signal noted on an earlier commit no longer applies.
+
+
+
+
+
+
+=== Bounty-hunter ===
+is prefix cache depend on https://github.com/vllm-project/vllm-omni/pull/7719 ?
+
+=== SamitHuang ===
+Pls resolve the conflicts for further review, thx
+
diff --git a/artifacts/pr7759-review/review_comments.txt b/artifacts/pr7759-review/review_comments.txt
new file mode 100644
index 000000000..b68f3e163
--- /dev/null
+++ b/artifacts/pr7759-review/review_comments.txt
@@ -0,0 +1,93 @@
+=== id:4043203379 | chatgpt-codex-connector[bot] | vllm_omni/diffusion/models/qwen_image_21/pipeline_qwen_image_21.py:1247 | reply_to:null ===
+** Keep new requests out of populated KV-cache batches**
+
+With step execution and `max_num_seqs > 1`, the scheduler explicitly admits compatible waiting requests alongside already-running requests, so a request arriving after the first denoise step produces exactly this mixed `prefill`/`decode` phase set and aborts the whole batch here. Since the recipe advertises continuous step execution with `--max-num-seqs 8`, the pipeline must either handle mixed phases or prevent such requests from joining an in-flight batch rather than raising.
+
+Useful? React with 👍 / 👎.
+
+=== id:4043203389 | chatgpt-codex-connector[bot] | vllm_omni/diffusion/models/qwen_image_21/pipeline_qwen_image_21.py:193 | reply_to:null ===
+** Partition batches by image-conditioning layout**
+
+The preprocessor never sets `request.batch_compatibility_key`, so the generic request and step schedulers consider text-only requests, image-conditioned requests, and requests with different condition-image layouts compatible when their sampling parameters match. Such requests are then placed in one batch and rejected later by `_prepare_generation_context` or `denoise_step`, turning otherwise valid concurrent requests into batch-wide failures; record the conditioning mode, image count, and resized slot sizes in the compatibility key on both the empty-image and image paths.
+
+Useful? React with 👍 / 👎.
+
+=== id:4043203392 | chatgpt-codex-connector[bot] | vllm_omni/diffusion/models/qwen_image_21/pipeline_qwen_image_21.py:693 | reply_to:null ===
+** Preserve request order when duplicating condition latents**
+
+When a request batch contains more than one prompt and `num_outputs_per_prompt > 1`, prompt embeddings are ordered per request (`A, A, B, B`), but concatenating the encoded batch repeatedly orders condition latents as `A, B, A, B`. The second and third generations therefore receive another request's condition image; duplicate each encoded row consecutively so its order matches the repeated prompt embeddings and output splitting.
+
+Useful? React with 👍 / 👎.
+
+=== id:4043203401 | chatgpt-codex-connector[bot] | vllm_omni/diffusion/models/qwen_image_21/pipeline_qwen_image_21.py:147 | reply_to:null ===
+** Avoid truth-testing tensor and ndarray image inputs**
+
+For a condition image supplied as a multi-element `torch.Tensor` or `numpy.ndarray`, this truth-value check raises an ambiguity error before `_to_pil` can process it, despite `_to_pil` explicitly supporting both input types. Check explicitly for `None` or an empty list instead so these supported image representations reach conversion.
+
+Useful? React with 👍 / 👎.
+
+=== id:4043275121 | SamitHuang | vllm_omni/diffusion/models/qwen_image_21/pipeline_qwen_image_21.py:283 | reply_to:null ===
+This pipeline is the entire new model surface (~1.3k lines here, plus transformer/VAE/CUDA graph) and the PR adds **zero tests**. Commits `4fc3cf8` / `3affc77` mention Qwen-Image-2.1 unit tests and then `clean tests` removes them from the branch. Add at least (1) unit tests for preprocess, `prepare_latents` duplication, mixed-phase `_assemble_kv_cache`, and VAE `spatial_compression_ratio=16`, and (2) a `tests/e2e/online_serving/test_qwen_image_21.py` smoke wired into `.buildkite/cuda/test-ready.yml` the same way `test_qwen_image.py` is. Without that, the TP / VAE-tiling / CUDA-graph claims in the commit messages are not merge-blocking evidence.
+
+=== id:4043275125 | SamitHuang | vllm_omni/diffusion/models/qwen_image_21/decode_graph.py:274 | reply_to:null ===
+`register_prefill` copies prefix K/V into a **shared** entry keyed only by `(branch, batch, prefix_len, target_tokens, dtype, device, backend)` and then **re-points every caller’s `kv_cache` dict at those tensors**. A second in-flight request with the same key `copy_`s over the first request’s prefix while the first request’s cache still aliases the same storage, so decode attends to another request’s text/image tokens. Sequential reuse of one buffer is fine; concurrent ownership is not. Keep per-request (or per-generation) static buffers, or refuse CUDA-graph decode whenever more than one live cache aliases the same key. This interacts directly with the recipe’s `--max-num-seqs 8` serving path.
+
+=== id:4043275130 | SamitHuang | vllm_omni/diffusion/models/qwen_image_21/pipeline_qwen_image_21.py:693 | reply_to:null ===
+`encode_prompt` expands with `prompt_embeds.repeat(1, num_images_per_prompt, 1).view(batch * n, ...)` which is request-major (`A, A, B, B`). `torch.cat([encoded] * (batch_size // encoded.shape[0]), dim=0)` is tile-major (`A, B, A, B`). For a 2-prompt batch with `num_outputs_per_prompt=2`, generations 2 and 3 therefore get the other request’s condition latents (and `split_diffusion_output_by_request` will pair them as if they were correct). Use `repeat_interleave(repeats=n, dim=0)` (or encode-then-repeat per row) so condition latents match the prompt-embed order. This needs a unit test with `batch_size=2, n=2`.
+
+=== id:4043275138 | SamitHuang | vllm_omni/diffusion/models/qwen_image_21/pipeline_qwen_image_21.py:147 | reply_to:null ===
+`if not raw_image` is an ambiguous truth-value check. `_to_pil` documents `torch.Tensor` and `numpy.ndarray` inputs; a multi-element tensor/array raises `RuntimeError: Boolean value of Tensor with more than one value is ambiguous` before conversion. Treat T2I as `raw_image is None or raw_image == []` (and maybe empty tuple), then always wrap a single image in a list. Same pattern should apply to whatever OpenAI image-edit path feeds tensors into `multi_modal_data["image"]`.
+
+=== id:4043275145 | SamitHuang | recipes/Qwen/Qwen-Image-2.1.md:107 | reply_to:null ===
+This example advertises `--step-execution --max-num-seqs 8`, but `QwenImage21Pipeline._assemble_kv_cache` raises if a new request’s first denoise step joins a batch that is already in decode (`Cannot batch requests at mixed KV-cache phases`). With the default step scheduler admitting waiting requests into a running batch, that is the normal continuous-batching case, not an edge case. Either keep mixed-phase requests out of the same `InputBatch` (and document the restriction here), or do not publish this command until that TODO is implemented. As written, following the recipe under load fails the whole batch.
+
+=== id:4043327545 | SamitHuang | vllm_omni/diffusion/data.py:909 | reply_to:null ===
+the CUDA graph for decode seems only used by QwenImage2.1? It's better to claim how it works together with eager mode or cudagraph_mode.
+
+=== id:4043342420 | SamitHuang | examples/offline_inference/text_to_image/text_to_image.py:1 | reply_to:null ===
+can provide a `examples/offline_inference/image_to_image/run_qwen_image_21.sh` script as well
+
+=== id:4043666922 | hsliuustc0106 | vllm_omni/diffusion/data.py:909 | reply_to:null ===
+This flag has no ingress, so the CUDA-graph decode path is unreachable for users: it's not in `OmniEngineArgs`/`StageDeployConfig`/`_DiffusionConfigProjection` (contrast `vae_use_tiling`, which flows through all three), and `Omni(enable_cuda_graph_decode=True)` is silently dropped by `from_kwargs`' field-name filter (`data.py:1604`). The only writer is the `False` default, so `decode_graph.py` + the transformer machinery only run under tests. Either wire it like `enforce_eager`/`vae_use_tiling` (deploy knob + projection) or document it as test-only in the recipe — the body's checked "[x] CUDA Graph for decode steps" reads as user-available today.
+
+=== id:4043667674 | hsliuustc0106 | vllm_omni/diffusion/models/qwen_image_21/pipeline_qwen_image_21.py:1246 | reply_to:null ===
+Reachable under the recipe's own `--step-execution --max-num-seqs 8`, and it fails the whole wave, not just the joiner: the default scheduler admits waiting requests while running ones are mid-generation (`base_scheduler.py` schedules running first, then admits waiting while capacity remains; `_can_schedule_waiting` compares only `StepBatchSamplingParamsKey` — no KV phase), and a `denoise_step` exception terminally fails every scheduled request via `diffusion_engine.py`'s `Execution failed for diffusion requests ...` handler. So one fresh arrival converts all in-flight requests into errors. Fix direction: phase-aware admission (defer joins while any running request is past step 0) or a per-model `batch_compatibility_key`; until then the features-table ✅ for Step Execution and the recipe's serving block overstate support vs. the unchecked TODO box.
+
+=== id:4043668653 | hsliuustc0106 | examples/offline_inference/image_to_image/qwen_image_21_edit.py:1 | reply_to:null ===
+Blocking per the examples policy: a newly added model-specific Python example (the file names one model family), while this directory's convention is the generic runner (`image_edit.py`, which dispatches via `get_model_class_name`/`build_image_to_image_prompt` — the same helpers this script calls). Smallest fix: register a `QwenImage21Pipeline` spec in `model_extras/_EXTRA_SPECS` (it's missing, so `build_image_to_image_prompt` currently falls through to the generic builder and silently ignores the `height`/`width` args this script passes) and fold this into `image_edit.py`; drop the model-specific file.
+
+=== id:4043668703 | hsliuustc0106 | vllm_omni/diffusion/models/qwen_image_21/__init__.py:1 | reply_to:null ===
+No tests anywhere in the PR. Sibling new models each ship a `tests/diffusion/models//` suite (sensenova_u1, glm_image, boogu_image incl. registry tests, ovis_image, z_image), and no CI lane touches `qwen_image_21` today. Given the new surface — registry entry, pre/post-process funcs, KV-cache prefill/decode protocol, step-mode merge/scatter, graph fallback gates — at least a CPU-runnable registry + loading + pre/post-process test would protect it.
+
+=== id:4043668767 | hsliuustc0106 | recipes/Qwen/Qwen-Image-2.1.md:3 | reply_to:null ===
+Missing the head-vs-reference comparison the model-addition checklist requires: the body's Test Plan is empty and there's no diffusers-#14804 quality/latency A/B (same checkpoint, inputs, seed, precision, hardware) — the recipe's only number is the tile-size PSNR probe. The checked TODO boxes (TP=2/4 vs single-card output, VAE tiling, CUDA graphs) also have no attached measurements. Please add the reference A/B with exact commands and both sides' outputs, or state why it can't run yet.
+
+=== id:4044943764 | RuixiangMa | tests/diffusion/models/qwen_image_21/test_batch_compatibility.py:1 | reply_to:null ===
+Test coverage is insufficient.
+
+=== id:4045080762 | RuixiangMa | vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl_qwenimage21.py:2 | reply_to:null ===
+```suggestion
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+```
+
+=== id:4045084688 | RuixiangMa | vllm_omni/diffusion/models/qwen_image_21/__init__.py:2 | reply_to:null ===
+```suggestion
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+```
+
+=== id:4059060032 | FayeSpica | vllm_omni/diffusion/models/qwen_image_21/qwen_image_21_transformer.py:388 | reply_to:null ===
+```suggestion
+ height_index = torch.where(height_index < 0, height_index + self.freqs[1].shape[0], height_index)
+ width_index = torch.where(width_index < 0, width_index + self.freqs[2].shape[0], width_index)
+ return torch.cat(
+ [
+ self.freqs[0].index_select(0, frame_index),
+ self.freqs[1].index_select(0, height_index),
+ self.freqs[2].index_select(0, width_index),
+ ],
+ dim=-1,
+ )
+```
+
+Could we use `torch.index_select` here for NPU compatibility, similar to #6320? This was the only issue preventing the model from starting on NPU in my tests.
+
diff --git a/benchmarks/README.md b/benchmarks/README.md
index 0a8fb8058..182bba02d 100644
--- a/benchmarks/README.md
+++ b/benchmarks/README.md
@@ -4,6 +4,11 @@ This directory contains benchmark suites for evaluating different model families
## Benchmark families
+### [Qwen2.5-Omni](qwen2_5_omni/README.md) — Embedding Wrapper Latency
+
+CPU-observed A/B timings for redundant text embedding removal, with real checkpoint
+embeddings, warmup, paired blocks, and embedding call-count validation.
+
### [TTS](tts/README.md) — Text-to-Speech
Model-agnostic serving benchmarks for TTS models, including Qwen3-TTS and VoxCPM2.
diff --git a/benchmarks/diffusion/benchmark_qwen_image_qk_norm_rope.py b/benchmarks/diffusion/benchmark_qwen_image_qk_norm_rope.py
new file mode 100644
index 000000000..2eaa097bd
--- /dev/null
+++ b/benchmarks/diffusion/benchmark_qwen_image_qk_norm_rope.py
@@ -0,0 +1,222 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+
+"""Benchmark Qwen-Image Q/K RMSNorm + interleaved RoPE.
+
+Example:
+
+ CUDA_VISIBLE_DEVICES=0 python benchmarks/diffusion/benchmark_qwen_image_qk_norm_rope.py \
+ --seq-len 4096 --warmup 20 --iters 100 --include-compiled --profile
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+from collections.abc import Callable
+
+import torch
+import torch.nn.functional as F
+
+from vllm_omni.diffusion.layers.fused_qk_norm_rope import (
+ _fused_cuda_supported,
+ fused_qk_norm_rope,
+)
+from vllm_omni.diffusion.layers.rope import RotaryEmbedding
+
+
+def _parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--batch", type=int, default=1)
+ parser.add_argument("--seq-len", type=int, default=4096)
+ parser.add_argument("--heads", type=int, default=24)
+ parser.add_argument("--kv-heads", type=int, default=24)
+ parser.add_argument("--head-dim", type=int, default=128)
+ parser.add_argument("--warmup", type=int, default=20)
+ parser.add_argument("--iters", type=int, default=100)
+ parser.add_argument("--dtype", choices=("bf16", "fp16", "fp32"), default="bf16")
+ parser.add_argument("--packed-qkv-view", action="store_true")
+ parser.add_argument("--include-compiled", action="store_true")
+ parser.add_argument("--profile", action="store_true")
+ return parser.parse_args()
+
+
+def _dtype(name: str) -> torch.dtype:
+ return {
+ "bf16": torch.bfloat16,
+ "fp16": torch.float16,
+ "fp32": torch.float32,
+ }[name]
+
+
+def _make_inputs(args: argparse.Namespace, dtype: torch.dtype, device: torch.device):
+ torch.manual_seed(2026)
+ if args.packed_qkv_view:
+ qkv_dim = (args.heads + args.kv_heads + args.kv_heads) * args.head_dim
+ qkv = torch.randn(args.batch, args.seq_len, qkv_dim, device=device, dtype=dtype)
+ q, k, _ = qkv.split(
+ [
+ args.heads * args.head_dim,
+ args.kv_heads * args.head_dim,
+ args.kv_heads * args.head_dim,
+ ],
+ dim=-1,
+ )
+ q = q.unflatten(-1, (args.heads, args.head_dim))
+ k = k.unflatten(-1, (args.kv_heads, args.head_dim))
+ else:
+ q = torch.randn(args.batch, args.seq_len, args.heads, args.head_dim, device=device, dtype=dtype)
+ k = torch.randn(args.batch, args.seq_len, args.kv_heads, args.head_dim, device=device, dtype=dtype)
+
+ q_weight = torch.randn(args.head_dim, device=device, dtype=dtype)
+ k_weight = torch.randn(args.head_dim, device=device, dtype=dtype)
+ angles = torch.randn(args.seq_len, args.head_dim // 2, device=device, dtype=torch.float32)
+ freqs = torch.polar(torch.ones_like(angles), angles)
+ return q, k, q_weight, k_weight, freqs
+
+
+def _native(
+ q: torch.Tensor,
+ k: torch.Tensor,
+ q_weight: torch.Tensor,
+ k_weight: torch.Tensor,
+ freqs: torch.Tensor,
+ eps: float,
+) -> tuple[torch.Tensor, torch.Tensor]:
+ q = F.rms_norm(q, (q.shape[-1],), q_weight, eps)
+ k = F.rms_norm(k, (k.shape[-1],), k_weight, eps)
+ rope = RotaryEmbedding(is_neox_style=False)
+ cos = freqs.real.to(q.dtype)
+ sin = freqs.imag.to(q.dtype)
+ return rope(q, cos, sin), rope(k, cos, sin)
+
+
+def _fused(
+ q: torch.Tensor,
+ k: torch.Tensor,
+ q_weight: torch.Tensor,
+ k_weight: torch.Tensor,
+ freqs: torch.Tensor,
+ eps: float,
+) -> tuple[torch.Tensor, torch.Tensor]:
+ batch, seq_len, num_heads, head_dim = q.shape
+ num_kv_heads = k.shape[2]
+ rope_table = torch.cat((freqs.real, freqs.imag), dim=-1)
+ rope_table = rope_table.unsqueeze(0).expand(batch, -1, -1).reshape(batch * seq_len, head_dim)
+ out_q, out_k = fused_qk_norm_rope(
+ q.reshape(batch * seq_len, num_heads, head_dim),
+ k.reshape(batch * seq_len, num_kv_heads, head_dim),
+ q_weight,
+ k_weight,
+ rope_table,
+ eps,
+ interleaved=True,
+ )
+ return (
+ out_q.reshape(batch, seq_len, num_heads, head_dim),
+ out_k.reshape(batch, seq_len, num_kv_heads, head_dim),
+ )
+
+
+def _measure(fn: Callable[[], tuple[torch.Tensor, torch.Tensor]], warmup: int, iters: int) -> list[float]:
+ for _ in range(warmup):
+ fn()
+ torch.accelerator.synchronize()
+ start = torch.cuda.Event(enable_timing=True)
+ end = torch.cuda.Event(enable_timing=True)
+ samples = []
+ for _ in range(iters):
+ start.record()
+ fn()
+ end.record()
+ end.synchronize()
+ samples.append(start.elapsed_time(end))
+ return samples
+
+
+def _stats(samples: list[float]) -> dict[str, float]:
+ samples = sorted(samples)
+ return {
+ "median_ms": samples[len(samples) // 2],
+ "p90_ms": samples[int(len(samples) * 0.9)],
+ "mean_ms": sum(samples) / len(samples),
+ }
+
+
+def _profile(fn: Callable[[], tuple[torch.Tensor, torch.Tensor]]) -> str:
+ from torch.profiler import ProfilerActivity, profile
+
+ with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA]) as prof:
+ fn()
+ torch.accelerator.synchronize()
+ return prof.key_averages().table(sort_by="self_cuda_time_total", row_limit=15)
+
+
+def main() -> None:
+ args = _parse_args()
+ if not torch.cuda.is_available():
+ raise RuntimeError("This benchmark requires CUDA")
+ device = torch.device("cuda")
+ dtype = _dtype(args.dtype)
+ eps = 1e-6
+ q, k, q_weight, k_weight, freqs = _make_inputs(args, dtype, device)
+
+ def native():
+ return _native(q, k, q_weight, k_weight, freqs, eps)
+
+ def fused():
+ return _fused(q, k, q_weight, k_weight, freqs, eps)
+
+ results: dict[str, object] = {
+ "device": torch.cuda.get_device_name(),
+ "batch": args.batch,
+ "seq_len": args.seq_len,
+ "heads": args.heads,
+ "kv_heads": args.kv_heads,
+ "head_dim": args.head_dim,
+ "dtype": args.dtype,
+ "packed_qkv_view": args.packed_qkv_view,
+ "fused_fast_path_supported": _fused_cuda_supported(
+ q.reshape(args.batch * args.seq_len, args.heads, args.head_dim),
+ k.reshape(args.batch * args.seq_len, args.kv_heads, args.head_dim),
+ args.head_dim,
+ args.head_dim,
+ interleaved=True,
+ ),
+ }
+
+ native_samples = _measure(native, args.warmup, args.iters)
+ fused_samples = _measure(fused, args.warmup, args.iters)
+ expected_q, expected_k = native()
+ actual_q, actual_k = fused()
+ torch.accelerator.synchronize()
+
+ native_stats = _stats(native_samples)
+ fused_stats = _stats(fused_samples)
+ results["native"] = native_stats
+ results["fused"] = fused_stats
+ results["speedup_vs_native"] = native_stats["median_ms"] / fused_stats["median_ms"]
+ results["max_abs_q"] = (actual_q.float() - expected_q.float()).abs().max().item()
+ results["max_abs_k"] = (actual_k.float() - expected_k.float()).abs().max().item()
+ results["mean_abs_q"] = (actual_q.float() - expected_q.float()).abs().mean().item()
+ results["mean_abs_k"] = (actual_k.float() - expected_k.float()).abs().mean().item()
+
+ if args.include_compiled:
+ compiled_native = torch.compile(native, dynamic=True, fullgraph=True)
+ compiled_fused = torch.compile(fused, dynamic=True, fullgraph=True)
+ compiled_native_stats = _stats(_measure(compiled_native, args.warmup, args.iters))
+ compiled_fused_stats = _stats(_measure(compiled_fused, args.warmup, args.iters))
+ results["compiled_native"] = compiled_native_stats
+ results["compiled_fused"] = compiled_fused_stats
+ results["compiled_fused_speedup_vs_compiled_native"] = (
+ compiled_native_stats["median_ms"] / compiled_fused_stats["median_ms"]
+ )
+
+ print(json.dumps(results, indent=2))
+ if args.profile:
+ print("\n# fused profile")
+ print(_profile(fused))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/benchmarks/diffusion/diffusion_benchmark_serving.py b/benchmarks/diffusion/diffusion_benchmark_serving.py
index 1e5aaf29c..6bf64ec1d 100644
--- a/benchmarks/diffusion/diffusion_benchmark_serving.py
+++ b/benchmarks/diffusion/diffusion_benchmark_serving.py
@@ -1087,6 +1087,12 @@ async def _run_warmups(
warmup_tasks = [asyncio.create_task(limited_warmup_request_func(req)) for req in warmup_requests]
warmup_outputs = await asyncio.gather(*warmup_tasks)
+ failed_count = sum(not output.success for output in warmup_outputs)
+ if failed_count:
+ raise RuntimeError(
+ f"{failed_count}/{len(warmup_outputs)} warmup requests failed; "
+ "check server logs before running the benchmark."
+ )
return list(zip(warmup_requests, warmup_outputs))
@@ -1455,7 +1461,7 @@ async def benchmark(args):
print(f"Metrics saved to {args.output_file}")
-if __name__ == "__main__":
+def build_parser():
parser = argparse.ArgumentParser(description="Benchmark serving for diffusion models.")
parser.add_argument(
"--base-url",
@@ -1637,6 +1643,10 @@ if __name__ == "__main__":
action="store_true",
help="Request stage duration metrics from endpoints that support return_stage_metrics.",
)
+ return parser
+
+if __name__ == "__main__":
+ parser = build_parser()
args = parser.parse_args()
asyncio.run(benchmark(args))
diff --git a/benchmarks/host_weight_runtime/README.md b/benchmarks/host_weight_runtime/README.md
new file mode 100644
index 000000000..899811151
--- /dev/null
+++ b/benchmarks/host_weight_runtime/README.md
@@ -0,0 +1,57 @@
+# Host weight dependency memory diagnostic
+
+`safetensors_retention.py` isolates repeated CPU `get_tensor()` calls from
+access to one cached tensor view. It imports neither vLLM nor HWR and uses a
+synthetic float32 payload controlled by `--tensor-elements` (default: 64).
+Tensor size can affect retention: a negative result for 16 elements does not
+rule out growth for 64 elements. The selected size is recorded in the JSON
+arguments and used for both file creation and the correctness check.
+HWR itself calls `get_tensor()` when
+acquiring a lease, then exposes cached views; this probe does not measure HWR
+requests or repeated lease acquisition.
+
+Run one mode per fresh process in the same Python environment and CPU affinity:
+
+```bash
+git rev-parse HEAD > revision.txt
+git status --short > working-tree.txt
+git diff > working-tree.patch
+# Select an allowed CPU from your process affinity; 0 is only an example.
+CUDA_VISIBLE_DEVICES='' taskset -c 0 timeout 120s python \
+ benchmarks/host_weight_runtime/safetensors_retention.py \
+ --mode get_tensor --iterations 10000 --sample-every 5000 > feasibility.json
+```
+
+For a size comparison, run 16 and 64 elements twice each in fresh processes,
+with cached reuse as a control at both sizes:
+
+```bash
+for repetition in 1 2; do
+ for elements in 16 64; do
+ for mode in get_tensor reuse; do
+ CUDA_VISIBLE_DEVICES='' taskset -c 0 timeout 120s python \
+ benchmarks/host_weight_runtime/safetensors_retention.py \
+ --mode "$mode" --tensor-elements "$elements" \
+ > "$mode-$elements-$repetition.json"
+ done
+ done
+done
+```
+
+The diagnostic sets Torch to one CPU thread, warms up before the baseline,
+and reports preparation separately from timed loop work. Checkpoints run GC
+and report Linux private/anonymous memory, RSS, and open descriptors. It checks
+that transient tensor objects and the final cached view are released. Linux
+`/proc/self/smaps_rollup` must be readable; invalid arguments and missing proc
+support fail explicitly. Keep raw JSON and the repository snapshot with results.
+
+Compare the final loop sample with the warmed baseline, then inspect the closed
+sample. Private-memory growth after Python tensor objects disappear is evidence
+of retention in the dependency/allocator path, not proof of a leak's root cause.
+RSS can include file-backed pages; it is not a private-memory metric. Samples
+also include small diagnostic bookkeeping allocations. Do not assert a portable
+memory threshold or turn this synthetic loop into a per-request service estimate.
+
+Do not drop shared caches, trim allocators, or add serving-time GC to make the
+numbers look smaller. A dependency replacement or version change requires its
+own reproducible comparison and correctness/lifecycle validation.
diff --git a/benchmarks/host_weight_runtime/safetensors_retention.py b/benchmarks/host_weight_runtime/safetensors_retention.py
new file mode 100644
index 000000000..55795e7ef
--- /dev/null
+++ b/benchmarks/host_weight_runtime/safetensors_retention.py
@@ -0,0 +1,120 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+"""Linux CPU diagnostic: repeated safetensors materialization versus view reuse."""
+
+from __future__ import annotations
+
+import argparse
+import gc
+import json
+import os
+import platform
+import tempfile
+import time
+import weakref
+from pathlib import Path
+
+
+def positive_int(value: str) -> int:
+ parsed = int(value)
+ if parsed <= 0:
+ raise argparse.ArgumentTypeError("must be positive")
+ return parsed
+
+
+def sample(phase: str, iterations: int, loop_seconds: float) -> dict[str, object]:
+ gc.collect()
+ counters = {}
+ for line in Path("/proc/self/smaps_rollup").read_text().splitlines():
+ fields = line.split()
+ if len(fields) == 3 and fields[2] == "kB":
+ counters[fields[0].removesuffix(":")] = int(fields[1]) * 1024
+ return {
+ "phase": phase,
+ "iterations": iterations,
+ "loop_seconds": loop_seconds,
+ "rss_bytes": counters["Rss"],
+ "private_bytes": counters["Private_Clean"] + counters["Private_Dirty"],
+ "anonymous_bytes": counters["Anonymous"],
+ "fd_count": len(tuple(Path("/proc/self/fd").iterdir())),
+ }
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--mode", choices=("get_tensor", "reuse"), required=True)
+ parser.add_argument(
+ "--tensor-elements", type=positive_int, default=64, help="Number of float32 elements in the synthetic tensor"
+ )
+ parser.add_argument("--iterations", type=positive_int, default=1_000_000)
+ parser.add_argument("--sample-every", type=positive_int, default=100_000)
+ parser.add_argument("--warmup", type=positive_int, default=1000)
+ args = parser.parse_args()
+ if not Path("/proc/self/smaps_rollup").is_file():
+ parser.error("requires Linux /proc/self/smaps_rollup")
+
+ preparation_start = time.monotonic()
+ import safetensors
+ import torch
+ from safetensors import safe_open
+ from safetensors.torch import save_file
+
+ torch.set_num_threads(1)
+ torch.set_num_interop_threads(1)
+ samples = [sample("before_file", 0, 0.0)]
+ with tempfile.TemporaryDirectory(prefix="safetensors-retention-") as directory:
+ path = Path(directory) / "synthetic.safetensors"
+ save_file({"weight": torch.arange(args.tensor_elements, dtype=torch.float32)}, str(path))
+ with safe_open(path, framework="pt", device="cpu") as reader:
+ cached = reader.get_tensor("weight")
+ assert torch.equal(cached, torch.arange(args.tensor_elements, dtype=torch.float32))
+ for _ in range(args.warmup):
+ tensor = reader.get_tensor("weight") if args.mode == "get_tensor" else cached
+ del tensor
+ samples.append(sample("baseline", 0, 0.0))
+ preparation_seconds = time.monotonic() - preparation_start
+ completed = 0
+ loop_seconds = 0.0
+ while completed < args.iterations:
+ count = min(args.sample_every, args.iterations - completed)
+ start = time.monotonic()
+ for _ in range(count):
+ tensor = reader.get_tensor("weight") if args.mode == "get_tensor" else cached
+ last_tensor = weakref.ref(tensor)
+ del tensor
+ loop_seconds += time.monotonic() - start
+ completed += count
+ snapshot = sample("loop", completed, loop_seconds)
+ snapshot["last_tensor_alive"] = last_tensor() is not None
+ if args.mode == "get_tensor":
+ assert last_tensor() is None, "diagnostic retained a transient tensor"
+ samples.append(snapshot)
+ del cached
+ del reader
+ samples.append(sample("closed", completed, loop_seconds))
+ assert last_tensor() is None, "last tensor survived reader/view teardown"
+
+ print(
+ json.dumps(
+ {
+ "schema_version": 1,
+ "arguments": vars(args),
+ "python": platform.python_version(),
+ "torch": torch.__version__,
+ "safetensors": safetensors.__version__,
+ "kernel": platform.release(),
+ "hostname": platform.node(),
+ "pid": os.getpid(),
+ "cpu_affinity": sorted(os.sched_getaffinity(0)),
+ "torch_threads": torch.get_num_threads(),
+ "pythonmalloc": os.environ.get("PYTHONMALLOC", "default"),
+ "preparation_seconds": preparation_seconds,
+ "samples": samples,
+ },
+ indent=2,
+ )
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/benchmarks/kernels/bench_qwen_select01_modulation.py b/benchmarks/kernels/bench_qwen_select01_modulation.py
new file mode 100644
index 000000000..9a97c8de3
--- /dev/null
+++ b/benchmarks/kernels/bench_qwen_select01_modulation.py
@@ -0,0 +1,333 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+"""Benchmark the Qwen-Image-Edit select01 modulation hot path.
+
+This script intentionally starts with the current PyTorch implementation. It is
+used as the before/after baseline for SGLang PR #20395/#21318 style fusion:
+
+* layernorm + scale/shift + binary select01 gate
+* residual + gated update + layernorm + scale/shift + binary select01 gate
+
+Run on a CUDA machine for actionable numbers, for example:
+
+ python benchmarks/kernels/bench_qwen_select01_modulation.py \
+ --device cuda --dtype bf16 --seq-lens 4096 8192 16384 \
+ --output-json /tmp/qwen_select01_baseline.json
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import statistics
+import sys
+import time
+from collections.abc import Callable
+from pathlib import Path
+
+import torch
+import torch.nn.functional as F
+
+for candidate in Path(__file__).resolve().parents:
+ if (candidate / "vllm_omni").is_dir():
+ if str(candidate) not in sys.path:
+ sys.path.insert(0, str(candidate))
+ break
+
+from vllm_omni.diffusion.layers.qwen_select01_modulation import ( # noqa: E402
+ fused_layernorm_select01,
+ fused_residual_layernorm_select01,
+)
+
+
+def _dtype(name: str) -> torch.dtype:
+ return {
+ "fp32": torch.float32,
+ "float32": torch.float32,
+ "fp16": torch.float16,
+ "float16": torch.float16,
+ "bf16": torch.bfloat16,
+ "bfloat16": torch.bfloat16,
+ }[name]
+
+
+def _select01_modulation(
+ mod_params: torch.Tensor,
+ index: torch.Tensor | None,
+) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
+ shift, scale, gate = mod_params.chunk(3, dim=-1)
+
+ if index is None:
+ return scale.unsqueeze(1), shift.unsqueeze(1), gate.unsqueeze(1)
+
+ actual_batch = shift.size(0) // 2
+ shift_0, shift_1 = shift[:actual_batch], shift[actual_batch:]
+ scale_0, scale_1 = scale[:actual_batch], scale[actual_batch:]
+ gate_0, gate_1 = gate[:actual_batch], gate[actual_batch:]
+
+ idx = index.unsqueeze(-1) == 0
+ shift = torch.where(idx, shift_0.unsqueeze(1), shift_1.unsqueeze(1))
+ scale = torch.where(idx, scale_0.unsqueeze(1), scale_1.unsqueeze(1))
+ gate = torch.where(idx, gate_0.unsqueeze(1), gate_1.unsqueeze(1))
+ return scale, shift, gate
+
+
+def _native_layernorm_select01(
+ x: torch.Tensor,
+ mod_params: torch.Tensor,
+ index: torch.Tensor,
+ eps: float,
+) -> tuple[torch.Tensor, torch.Tensor]:
+ scale, shift, gate = _select01_modulation(mod_params, index)
+ out = F.layer_norm(x.float(), (x.shape[-1],), eps=eps).to(x.dtype)
+ return out * (1 + scale) + shift, gate
+
+
+def _native_residual_layernorm_select01(
+ x: torch.Tensor,
+ residual: torch.Tensor,
+ residual_gate: torch.Tensor,
+ mod_params: torch.Tensor,
+ index: torch.Tensor,
+ eps: float,
+) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
+ scale, shift, gate = _select01_modulation(mod_params, index)
+ residual_out = residual + residual_gate * x
+ out = F.layer_norm(residual_out.float(), (residual_out.shape[-1],), eps=eps).to(residual_out.dtype)
+ return out * (1 + scale) + shift, residual_out, gate
+
+
+def _time_cuda(fn: Callable[[], object], warmup: int, iters: int) -> list[float]:
+ for _ in range(warmup):
+ fn()
+ torch.accelerator.synchronize()
+
+ times: list[float] = []
+ for _ in range(iters):
+ start = torch.cuda.Event(enable_timing=True)
+ end = torch.cuda.Event(enable_timing=True)
+ start.record()
+ fn()
+ end.record()
+ torch.accelerator.synchronize()
+ times.append(float(start.elapsed_time(end)))
+ return times
+
+
+def _reset_peak_memory_stats(device: torch.device) -> None:
+ reset_peak_memory_stats = getattr(torch.accelerator, "reset_peak_memory_stats", None)
+ if reset_peak_memory_stats is None:
+ reset_peak_memory_stats = getattr(getattr(torch, device.type), "reset_peak_memory_stats")
+ reset_peak_memory_stats(device)
+
+
+def _max_memory_allocated(device: torch.device) -> int:
+ max_memory_allocated = getattr(torch.accelerator, "max_memory_allocated", None)
+ if max_memory_allocated is None:
+ max_memory_allocated = getattr(getattr(torch, device.type), "max_memory_allocated")
+ return int(max_memory_allocated(device))
+
+
+def _time_cpu(fn: Callable[[], object], warmup: int, iters: int) -> list[float]:
+ for _ in range(warmup):
+ fn()
+
+ times: list[float] = []
+ for _ in range(iters):
+ t0 = time.perf_counter()
+ fn()
+ times.append((time.perf_counter() - t0) * 1000.0)
+ return times
+
+
+def _summarize(times: list[float]) -> dict[str, float]:
+ ordered = sorted(times)
+ return {
+ "median_ms": statistics.median(ordered),
+ "min_ms": ordered[0],
+ "p20_ms": ordered[max(0, int(len(ordered) * 0.20) - 1)],
+ "p80_ms": ordered[min(len(ordered) - 1, int(len(ordered) * 0.80))],
+ }
+
+
+def _make_inputs(
+ batch: int,
+ seq_len: int,
+ hidden_size: int,
+ dtype: torch.dtype,
+ device: torch.device,
+) -> dict[str, torch.Tensor]:
+ torch.manual_seed(0)
+ return {
+ "x": torch.randn(batch, seq_len, hidden_size, device=device, dtype=dtype),
+ "residual": torch.randn(batch, seq_len, hidden_size, device=device, dtype=dtype),
+ "residual_gate": torch.randn(batch, 1, hidden_size, device=device, dtype=dtype),
+ "mod_params": torch.randn(batch * 2, hidden_size * 3, device=device, dtype=dtype),
+ "index": torch.randint(0, 2, (batch, seq_len), device=device, dtype=torch.int64),
+ }
+
+
+def _bench_one(
+ name: str,
+ fn: Callable[[], object],
+ device: torch.device,
+ warmup: int,
+ iters: int,
+) -> dict[str, object]:
+ if device.type == "cuda":
+ _reset_peak_memory_stats(device)
+ times = _time_cuda(fn, warmup, iters)
+ peak_memory_mb = _max_memory_allocated(device) / 1024**2
+ else:
+ times = _time_cpu(fn, warmup, iters)
+ peak_memory_mb = None
+
+ return {
+ "name": name,
+ **_summarize(times),
+ "peak_memory_mb": peak_memory_mb,
+ }
+
+
+def _run(args: argparse.Namespace) -> dict[str, object]:
+ device = torch.device(args.device)
+ if device.type == "cuda" and not torch.cuda.is_available():
+ raise SystemExit("CUDA is not available on this machine.")
+
+ dtype = _dtype(args.dtype)
+ rows: list[dict[str, object]] = []
+
+ for seq_len in args.seq_lens:
+ tensors = _make_inputs(args.batch_size, seq_len, args.hidden_size, dtype, device)
+ impls = ["native"] if args.impl == "native" else ["fused"] if args.impl == "fused" else ["native", "fused"]
+
+ def first_norm() -> object:
+ return _native_layernorm_select01(
+ tensors["x"],
+ tensors["mod_params"],
+ tensors["index"],
+ args.eps,
+ )
+
+ def residual_norm() -> object:
+ return _native_residual_layernorm_select01(
+ tensors["x"],
+ tensors["residual"],
+ tensors["residual_gate"],
+ tensors["mod_params"],
+ tensors["index"],
+ args.eps,
+ )
+
+ callables: list[tuple[str, Callable[[], object]]] = []
+ if "native" in impls:
+ callables.extend(
+ [
+ ("native_layernorm_select01", first_norm),
+ ("native_residual_layernorm_select01", residual_norm),
+ ]
+ )
+ if "fused" in impls:
+
+ def fused_first_norm() -> object:
+ return fused_layernorm_select01(
+ tensors["x"],
+ tensors["mod_params"],
+ tensors["index"],
+ args.eps,
+ )
+
+ def fused_residual_norm() -> object:
+ return fused_residual_layernorm_select01(
+ tensors["x"],
+ tensors["residual"],
+ tensors["residual_gate"].expand_as(tensors["residual"]),
+ tensors["mod_params"],
+ tensors["index"],
+ args.eps,
+ )
+
+ callables.extend(
+ [
+ ("fused_layernorm_select01", fused_first_norm),
+ ("fused_residual_layernorm_select01", fused_residual_norm),
+ ]
+ )
+
+ if args.check and "fused" in impls:
+ ref_norm, ref_gate = first_norm()
+ actual_norm, actual_gate = fused_layernorm_select01(
+ tensors["x"],
+ tensors["mod_params"],
+ tensors["index"],
+ args.eps,
+ )
+ torch.testing.assert_close(actual_norm, ref_norm, atol=args.check_atol, rtol=args.check_rtol)
+ torch.testing.assert_close(actual_gate, ref_gate, atol=0, rtol=0)
+ ref_norm, ref_residual, ref_gate = residual_norm()
+ actual_norm, actual_residual, actual_gate = fused_residual_layernorm_select01(
+ tensors["x"],
+ tensors["residual"],
+ tensors["residual_gate"].expand_as(tensors["residual"]),
+ tensors["mod_params"],
+ tensors["index"],
+ args.eps,
+ )
+ torch.testing.assert_close(actual_norm, ref_norm, atol=args.check_atol, rtol=args.check_rtol)
+ torch.testing.assert_close(actual_residual, ref_residual, atol=args.check_atol, rtol=args.check_rtol)
+ torch.testing.assert_close(actual_gate, ref_gate, atol=0, rtol=0)
+
+ for name, fn in callables:
+ row = _bench_one(name, fn, device, args.warmup, args.iters)
+ row.update(
+ {
+ "batch_size": args.batch_size,
+ "seq_len": seq_len,
+ "hidden_size": args.hidden_size,
+ "dtype": args.dtype,
+ "device": str(device),
+ }
+ )
+ rows.append(row)
+ print(
+ f"{name:<38} B={args.batch_size:<2} L={seq_len:<6} C={args.hidden_size:<5} "
+ f"median={row['median_ms']:.4f} ms p20={row['p20_ms']:.4f} p80={row['p80_ms']:.4f}"
+ )
+
+ result = {
+ "benchmark": "qwen_select01_modulation",
+ "torch": torch.__version__,
+ "cuda_available": torch.cuda.is_available(),
+ "gpu": torch.cuda.get_device_name(device) if device.type == "cuda" else None,
+ "rows": rows,
+ }
+
+ if args.output_json:
+ output_path = Path(args.output_json)
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_text(json.dumps(result, indent=2), encoding="utf-8")
+
+ return result
+
+
+def _parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ default_device = "cuda" if torch.cuda.is_available() else "cpu"
+ parser.add_argument("--device", default=default_device)
+ parser.add_argument("--dtype", default="bf16", choices=["fp32", "float32", "fp16", "float16", "bf16", "bfloat16"])
+ parser.add_argument("--batch-size", type=int, default=1)
+ parser.add_argument("--seq-lens", type=int, nargs="+", default=[4096, 8192, 16384])
+ parser.add_argument("--hidden-size", type=int, default=3072)
+ parser.add_argument("--eps", type=float, default=1e-6)
+ parser.add_argument("--warmup", type=int, default=20)
+ parser.add_argument("--iters", type=int, default=100)
+ parser.add_argument("--impl", choices=["native", "fused", "all"], default="native")
+ parser.add_argument("--check", action="store_true")
+ parser.add_argument("--check-atol", type=float, default=5e-2)
+ parser.add_argument("--check-rtol", type=float, default=5e-2)
+ parser.add_argument("--output-json")
+ return parser.parse_args()
+
+
+if __name__ == "__main__":
+ _run(_parse_args())
diff --git a/benchmarks/kernels/benchmark_ming_istft.py b/benchmarks/kernels/benchmark_ming_istft.py
new file mode 100644
index 000000000..b67c9bbfc
--- /dev/null
+++ b/benchmarks/kernels/benchmark_ming_istft.py
@@ -0,0 +1,218 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+"""Measure Ming ISTFT and optionally its pretrained streaming AudioVAE decoder.
+
+Example (no model weights needed for the operator benchmark):
+ python benchmarks/kernels/benchmark_ming_istft.py --compile --iterations 100
+
+Add --decoder to measure the published decoder with fixed synthetic latents.
+This is a decoder-stage benchmark, not text-to-speech serving latency.
+"""
+
+import argparse
+import json
+import statistics
+import time
+from collections.abc import Callable
+from functools import partial
+
+import torch
+
+from vllm_omni.model_executor.models.common.ming.audio_dsp import ISTFT
+from vllm_omni.model_executor.models.common.ming.fused_istft import fused_istft
+
+
+def measure(operation: Callable, warmup: int, iterations: int) -> dict:
+ for _ in range(warmup):
+ operation()
+ torch.accelerator.synchronize()
+ samples = []
+ for _ in range(iterations):
+ start = time.perf_counter()
+ operation()
+ torch.accelerator.synchronize()
+ samples.append((time.perf_counter() - start) * 1000)
+ torch.accelerator.reset_peak_memory_stats()
+ before = torch.accelerator.memory_allocated()
+ operation()
+ torch.accelerator.synchronize()
+ return {
+ "p50_ms": statistics.median(samples),
+ "min_ms": min(samples),
+ "max_ms": max(samples),
+ "stdev_ms": statistics.pstdev(samples),
+ "extra_peak_mib": (torch.accelerator.max_memory_allocated() - before) / 1024**2,
+ }
+
+
+def native_post_fft(module, frames, audio, window):
+ """The native middle-chunk post-FFT sequence, isolated from cuFFT."""
+ count = frames.shape[-1]
+ size = (count - 1) * module.hop_length + module.win_length
+
+ def fold(value):
+ return torch.nn.functional.fold(
+ value, output_size=(1, size), kernel_size=(1, module.win_length), stride=(1, module.hop_length)
+ )[:, 0, 0, :]
+
+ samples = fold(frames * module.window[None, :, None])
+ samples, audio = module._buffer_process(samples, audio, 0, streaming=True)
+ envelope = fold(module.window.square().expand(1, count, -1).transpose(1, 2))
+ envelope, window = module._buffer_process(envelope, window, 0, streaming=True)
+ if not (envelope > 1e-11).all():
+ raise RuntimeError("ISTFT window envelope underflowed; invalid overlap-add state.")
+ return samples / envelope, audio, window
+
+
+def assert_outputs(actual, reference):
+ maximum = 0.0
+ for value, expected in zip(actual, reference, strict=True):
+ if expected is None:
+ assert value is None
+ else:
+ torch.testing.assert_close(value, expected, atol=5e-7, rtol=1e-5)
+ maximum = max(maximum, (value - expected).abs().max().item())
+ return maximum
+
+
+def benchmark_istft(args):
+ for dtype in (torch.float32, torch.bfloat16):
+ module = ISTFT(3528, 882, 3528).to(device="cuda", dtype=dtype)
+ for count in (16, 100, 400):
+ spec = torch.randn(1, 1765, count, device="cuda", dtype=torch.complex64)
+ _, audio, window = module.forward_native(spec, streaming=True)
+ frames = torch.fft.irfft(spec, 3528, dim=1)
+ for scope in ("post_fft", "istft"):
+ if scope == "post_fft":
+ native = partial(native_post_fft, module, frames, audio, window)
+ fused = partial(fused_istft, frames, module.window, 882, audio, window, True, False)
+ else:
+ native = partial(module.forward_native, spec, audio, window, streaming=True)
+ fused = partial(module, spec, audio, window, streaming=True)
+ error = assert_outputs(fused(), native())
+ results = {"native": measure(native, args.warmup, args.iterations)}
+ if args.compile:
+ compiled = torch.compile(native)
+ compiled_output, reference = compiled(), native()
+ # Inductor can change BF16 envelope rounding. Report that
+ # difference without weakening the fused-kernel parity gate.
+ compiled_matches = all(
+ torch.allclose(value, expected, atol=5e-7, rtol=1e-5)
+ for value, expected in zip(compiled_output, reference, strict=True)
+ )
+ results["compiled"] = measure(compiled, args.warmup, args.iterations)
+ results["compiled"]["matches_native_tolerance"] = compiled_matches
+ results["compiled"]["waveform_max_abs_error"] = (
+ (compiled_output[0] - reference[0]).abs().max().item()
+ )
+ results["fused"] = measure(fused, args.warmup, args.iterations)
+ print(
+ json.dumps(
+ {
+ "scope": scope,
+ "window_dtype": str(dtype),
+ "frames": count,
+ "max_abs_error": error,
+ "results": results,
+ }
+ ),
+ flush=True,
+ )
+
+
+def benchmark_decoder(args):
+ from huggingface_hub import hf_hub_download
+ from safetensors import safe_open
+
+ from vllm_omni.model_executor.models.common.ming.audio_vae import Decoder
+
+ model = "inclusionAI/Ming-omni-tts-0.5B"
+ revision = "9154772e7fbc585907b6237e3190790676f28975"
+ config_path = hf_hub_download(model, "config.json", revision=revision)
+ with open(config_path) as source:
+ config = json.load(source)["audio_tokenizer_config"]
+ kwargs = config["dec_kwargs"]
+ backbone = kwargs["backbone"]
+ backbone["_attn_implementation"] = "sdpa"
+ decoder = Decoder(backbone, kwargs["output_dim"], kwargs["latent_dim"], config["patch_size"])
+ checkpoint = hf_hub_download(model, "model.safetensors", revision=revision)
+ prefix = "audio.decoder."
+ with safe_open(checkpoint, framework="pt") as weights:
+ state = {key.removeprefix(prefix): weights.get_tensor(key) for key in weights.keys() if key.startswith(prefix)}
+ decoder.load_state_dict(state, strict=True)
+ decoder = decoder.eval().to(device="cuda", dtype=torch.bfloat16)
+ chunks = [
+ torch.randn(1, size, kwargs["latent_dim"], device="cuda", dtype=torch.bfloat16) for size in (4, 25, 25, 5)
+ ]
+ istft = decoder.head.istft
+ fused_forward = istft.forward
+
+ def run():
+ cache = state = None
+ outputs = []
+ for index, chunk in enumerate(chunks):
+ output, state, cache = decoder.low_level_reconstruct(
+ chunk, past_key_values=cache, use_cache=True, stream_state=state, last_chunk=index == len(chunks) - 1
+ )
+ outputs.append(output)
+ return torch.cat(outputs, dim=-1)
+
+ istft.forward = istft.forward_native
+ expected = run()
+ istft.forward = fused_forward
+ actual = run()
+ torch.testing.assert_close(actual, expected, atol=5e-7, rtol=1e-5)
+ # Alternate the order to expose drift when the decoder-level gain is small.
+ results = {"native": [], "fused": []}
+ for order in (("native", "fused"), ("fused", "native"), ("native", "fused")):
+ for name in order:
+ istft.forward = istft.forward_native if name == "native" else fused_forward
+ results[name].append(measure(run, args.warmup, args.decoder_iterations))
+ print(
+ json.dumps(
+ {
+ "scope": "pretrained_decoder",
+ "model": model,
+ "revision": revision,
+ "backend": "sdpa",
+ "dtype": "bfloat16",
+ "latent_chunks": [4, 25, 25, 5],
+ "max_abs_error": (actual - expected).abs().max().item(),
+ "results": results,
+ }
+ ),
+ flush=True,
+ )
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--warmup", type=int, default=20)
+ parser.add_argument("--iterations", type=int, default=100)
+ parser.add_argument("--compile", action="store_true")
+ parser.add_argument("--decoder", action="store_true")
+ parser.add_argument("--decoder-iterations", type=int, default=30)
+ args = parser.parse_args()
+ if args.warmup < 0 or min(args.iterations, args.decoder_iterations) < 1:
+ parser.error("warmup must be nonnegative and iteration counts must be positive")
+ torch.manual_seed(0)
+ print(
+ json.dumps(
+ {
+ "torch": torch.__version__,
+ "cuda": torch.version.cuda,
+ "gpu": torch.cuda.get_device_name(),
+ "warmup": args.warmup,
+ "iterations": args.iterations,
+ "decoder_iterations": args.decoder_iterations,
+ }
+ )
+ )
+ with torch.inference_mode():
+ benchmark_istft(args)
+ if args.decoder:
+ benchmark_decoder(args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/benchmarks/kernels/mot_linear_benchmarks.py b/benchmarks/kernels/mot_linear_benchmarks.py
index 20674272e..0b517ce11 100644
--- a/benchmarks/kernels/mot_linear_benchmarks.py
+++ b/benchmarks/kernels/mot_linear_benchmarks.py
@@ -1,3 +1,6 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+
# ruff: noqa: N803
"""MoT (Mixture-of-Tokens) GEMM kernel benchmark and auto-tuning.
@@ -10,7 +13,7 @@ Usage:
python benchmarks/kernels/mot_linear_benchmarks.py \
--model ByteDance-Seed/BAGEL-7B-MoT \
--tp-size 1 --dtype w16a16 --tune \
- --save-dir vllm_omni/diffusion/layers/mot/configs/
+ --save-dir vllm_omni/diffusion/models/bagel/mot/configs/
# Auto-tune with local model path (offline clusters):
python benchmarks/kernels/mot_linear_benchmarks.py \
@@ -43,7 +46,7 @@ from vllm.triton_utils import triton
from vllm.utils.torch_utils import set_random_seed
# NOTE: you should use the same naming syetem for the kernel to load properly
-from vllm_omni.diffusion.layers.mot.ops.mot_gemm import build_config_filename, get_device_name
+from vllm_omni.diffusion.models.bagel.mot.ops.mot_gemm import build_config_filename, get_device_name
# clear the triton cache from time to time, usaully no need to change
_CACHE_CLEAR_INTERVAL_ENV = "VLLM_MOT_TUNE_CACHE_CLEAR_INTERVAL"
@@ -428,7 +431,7 @@ def benchmark_config(
cache_flusher: torch.Tensor | None = None,
) -> float:
"""Run a MoT GEMM with the given tile config and return avg latency (us)."""
- from vllm_omni.diffusion.layers.mot.ops.mot_gemm import invoke_mot_gemm
+ from vllm_omni.diffusion.models.bagel.mot.ops.mot_gemm import invoke_mot_gemm
text_indices, vae_indices, M = build_regular_indices(
image_num=image_num,
@@ -553,7 +556,7 @@ class BenchmarkWorker:
use_int8_w8a16: bool,
) -> tuple[dict[str, int], float]:
set_random_seed(self.seed)
- from vllm_omni.diffusion.layers.mot.ops.mot_gemm import (
+ from vllm_omni.diffusion.models.bagel.mot.ops.mot_gemm import (
get_best_mot_config,
)
diff --git a/benchmarks/qwen2_5_omni/README.md b/benchmarks/qwen2_5_omni/README.md
new file mode 100644
index 000000000..c8aea8980
--- /dev/null
+++ b/benchmarks/qwen2_5_omni/README.md
@@ -0,0 +1,66 @@
+# Qwen2.5-Omni embedding microbenchmark
+
+Measure the host-observed latency of `embed_input_ids` before and after the
+redundant text embedding removal in PR #7477. Both variants use the same real
+checkpoint embedding table, dependencies, inputs and batch shapes. The baseline
+wrapper is extracted from the specified Git commit; the fixed wrapper comes from
+the current checkout. No full model is initialized.
+
+## Run
+
+Use Linux, a CUDA GPU, the project dependencies, and a local
+`Qwen/Qwen2.5-Omni-7B` checkpoint with its safetensors index and weights.
+From the repository root:
+
+```bash
+git fetch origin 01a2f93256975c7ff9565c5414b0fe0225bc4765
+PYTHONPATH="$PWD" OMP_NUM_THREADS=1 python benchmarks/qwen2_5_omni/benchmark_embeddings.py \
+ --model /path/to/Qwen2.5-Omni-7B \
+ --baseline-ref 01a2f93256975c7ff9565c5414b0fe0225bc4765 \
+ --cpu-core 2 --lengths 1172 --warmup 50 --blocks 20 --iterations 20 \
+ --output-dir /tmp/qwen25-embedding-results
+```
+
+Choose a CPU core in your allowed affinity set. For the original CUDA length
+sweep, use `--lengths 512 1172 4096 16384`.
+
+## Method
+
+- BF16 checkpoint embeddings, TP=1, CUDA int32 token IDs, CPU multimodal masks.
+- One pinned CPU core, one PyTorch thread, garbage collection disabled.
+- 50 warmups per variant, then 20 paired blocks of 20 calls with balanced,
+ randomized variant order. Summary: median of block medians.
+- Host latency includes synchronization inside the wrapper. Explicit GPU
+ synchronization before and after the timed call is excluded from host latency.
+ Calling-thread CPU time and synchronized completion time are recorded separately.
+- CPU frequency is sampled at block boundaries; clocks are not locked.
+- Validation checks one text embedding call for the fixed wrapper, two for the
+ multimodal baseline, and `torch.equal` for identical inputs and batch shapes.
+ This does not establish batch invariance or full-model output equality.
+
+Interleaved audio/video uses a valid synthetic multimodal mask. This benchmark
+measures wrapper latency, not E2E throughput.
+
+## Reference results
+
+Original environment: H100 80GB; Python 3.12.3; vLLM 0.29.0+cu129;
+PyTorch 2.13.0+cu129; Transformers 5.14.0; Qwen2.5-Omni-7B BF16.
+At 1172 tokens, the September 13 measurements were:
+
+| Input | Before (μs) | After (μs) | Host latency reduction |
+| --- | ---: | ---: | ---: |
+| Text | 18.81 | 18.66 | 0.80% |
+| Audio | 104.32 | 85.30 | 18.23% |
+| Image | 104.72 | 86.27 | 17.62% |
+| Video | 104.73 | 85.76 | 18.11% |
+| Audio + image + video | 567.92 | 549.54 | 3.24% |
+| Interleaved audio/video | 622.76 | 597.84 | 4.00% |
+
+Results vary between runs and environments. These local savings did not produce
+a consistent E2E throughput improvement in the separate repeated E2E experiment.
+
+## Output
+
+The output directory contains `metadata.json`, `micro-raw.json`,
+`validation.json`, and `summary.json`. Raw records include per-call timings,
+block timestamps and CPU frequency samples. Timer overhead is measured separately.
diff --git a/benchmarks/qwen2_5_omni/benchmark_embeddings.py b/benchmarks/qwen2_5_omni/benchmark_embeddings.py
new file mode 100644
index 000000000..1a5b32435
--- /dev/null
+++ b/benchmarks/qwen2_5_omni/benchmark_embeddings.py
@@ -0,0 +1,306 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+"""Reproduce PR #7477 host-observed embedding-wrapper latency on one CUDA GPU.
+
+Run from the PR checkout with PYTHONPATH=$PWD. Baseline and fixed methods share
+weights and inputs. Only the historical wrapper is loaded from --baseline-ref.
+"""
+
+import argparse
+import ast
+import gc
+import json
+import os
+import random
+import statistics
+import subprocess
+import textwrap
+import time
+from pathlib import Path
+
+import torch
+from safetensors import safe_open
+from torch import nn
+from transformers import Qwen2_5OmniThinkerConfig
+from vllm.model_executor.layers.vocab_parallel_embedding import VocabParallelEmbedding
+from vllm.multimodal.utils import set_mm_embedding_modality
+
+from vllm_omni.model_executor.models.qwen2_5_omni.qwen2_5_omni_thinker import (
+ Qwen2_5OmniThinkerForConditionalGeneration as Thinker,
+)
+from vllm_omni.platforms import current_omni_platform
+
+
+def baseline_method(cls, ref):
+ path = "vllm_omni/model_executor/models/qwen2_5_omni/qwen2_5_omni_thinker.py"
+ source = subprocess.check_output(
+ ["git", "-C", str(Path(__file__).resolve().parents[2]), "show", f"{ref}:{path}"], text=True
+ )
+ tree = ast.parse(source)
+ owner = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == cls.__name__)
+ node = next(n for n in owner.body if isinstance(n, ast.FunctionDef) and n.name == "embed_input_ids")
+ method = textwrap.dedent(ast.get_source_segment(source, node))
+ # Preserve zero-argument super() against the real model class and MRO.
+ factory = "from __future__ import annotations\ndef make_method(cls):\n __class__ = cls\n"
+ factory += textwrap.indent(method, " ") + "\n return embed_input_ids\n"
+ namespace = dict(cls.embed_input_ids.__globals__)
+ exec(compile(factory, f"{ref}:{path}", "exec"), namespace)
+ return namespace["make_method"](cls)
+
+
+parser = argparse.ArgumentParser(description=__doc__)
+parser.add_argument("--model", type=Path, required=True)
+parser.add_argument("--baseline-ref", default="01a2f93256975c7ff9565c5414b0fe0225bc4765")
+parser.add_argument("--cpu-core", type=int, default=sorted(os.sched_getaffinity(0))[0])
+parser.add_argument("--lengths", type=int, nargs="+", default=[1172])
+parser.add_argument("--warmup", type=int, default=50)
+parser.add_argument("--blocks", type=int, default=20)
+parser.add_argument("--iterations", type=int, default=20)
+parser.add_argument("--output-dir", type=Path, default=Path("micro-results"))
+args = parser.parse_args()
+if min(args.lengths) < 300 or min(args.blocks, args.iterations) < 1 or args.warmup < 0:
+ parser.error("Require lengths >= 300, positive blocks/iterations, and nonnegative warmup")
+if args.cpu_core not in os.sched_getaffinity(0):
+ parser.error("CPU core is outside the allowed affinity set")
+ROOT, MODEL, CORE = args.output_dir, args.model, args.cpu_core
+ROOT.mkdir(parents=True, exist_ok=True)
+CONFIG = json.loads((MODEL / "config.json").read_text())["thinker_config"]
+HIDDEN = CONFIG["text_config"]["hidden_size"]
+VOCAB = CONFIG["text_config"]["vocab_size"]
+IDS = {m: CONFIG[m + "_token_index"] for m in ("audio", "image", "video")}
+METHODS = {"baseline": baseline_method(Thinker, args.baseline_ref), "fixed": Thinker.embed_input_ids}
+RESULTS, VALIDATIONS = [], []
+os.sched_setaffinity(0, {CORE})
+torch.set_num_threads(1)
+torch.set_num_interop_threads(1)
+gc.disable()
+
+
+def cpu_frequency():
+ path = Path(f"/sys/devices/system/cpu/cpu{CORE}/cpufreq/scaling_cur_freq")
+ if path.exists():
+ return float(path.read_text()) / 1000
+ for section in Path("/proc/cpuinfo").read_text().split("\n\n"):
+ fields = dict(line.split(":", 1) for line in section.splitlines() if ":" in line)
+ fields = {k.strip(): v.strip() for k, v in fields.items()}
+ if fields.get("processor") == str(CORE):
+ return float(fields.get("cpu MHz", "nan"))
+
+
+def payload(case, length, device):
+ generator = torch.Generator().manual_seed(7451 + length)
+ ids = torch.randint(2, 10000, (length,), generator=generator)
+ specs = {
+ "text": [],
+ "audio": [("audio", 100)],
+ "image": [("image", 16)],
+ "video": [("video", 128)],
+ "mixed": [("audio", 100), ("image", 16), ("video", 128)],
+ "interleaved": [("video", 64), ("audio", 50), ("video", 64), ("audio", 50)],
+ }[case]
+ offset = 44
+ counts = {}
+ for modality, count in specs:
+ ids[offset : offset + count] = IDS[modality]
+ counts[modality] = counts.get(modality, 0) + count
+ offset += count + (0 if case == "interleaved" else 2)
+ mask = torch.zeros(length, dtype=torch.bool)
+ for token in IDS.values():
+ mask |= ids == token
+ mm = [
+ set_mm_embedding_modality(torch.full((count, HIDDEN), i + 1, device=device, dtype=torch.bfloat16), modality)
+ for i, (modality, count) in enumerate(counts.items())
+ ]
+ return ids.to(device=device, dtype=torch.int32), None if case == "text" else mm, None if case == "text" else mask
+
+
+def once(fn, cuda):
+ # Drain preceding work before the host timer; the production function's
+ # own D2H synchronizations remain inside the timed call.
+ if cuda:
+ current_omni_platform.synchronize()
+ w0 = time.perf_counter_ns()
+ c0 = time.thread_time_ns()
+ value = fn()
+ c1 = time.thread_time_ns()
+ w1 = time.perf_counter_ns()
+ if cuda:
+ current_omni_platform.synchronize()
+ w2 = time.perf_counter_ns() if cuda else w1
+ del value
+ return [(w1 - w0) / 1000, (c1 - c0) / 1000, (w2 - w0) / 1000]
+
+
+def measure(functions, device, case, length, scope, rounds=20, iterations=20, warmups=50):
+ cuda = device == "cuda"
+ for fn in functions.values():
+ for _ in range(warmups):
+ once(fn, cuda)
+ order_rng = random.Random(7451 + length + len(case) + len(scope))
+ directions = [i % 2 for i in range(rounds)]
+ order_rng.shuffle(directions)
+ names = list(functions)
+ for block, direction in enumerate(directions):
+ order = names if direction == 0 else names[::-1]
+ for name in order:
+ frequency_start = cpu_frequency()
+ wall_start = time.time()
+ samples = [once(functions[name], cuda) for _ in range(iterations)]
+ wall_end = time.time()
+ RESULTS.append(
+ dict(
+ device=device,
+ case=case,
+ tokens=length,
+ scope=scope,
+ variant=name,
+ block=block,
+ iterations=iterations,
+ samples_us=samples,
+ wall_start=wall_start,
+ wall_end=wall_end,
+ cpu_mhz_start=frequency_start,
+ cpu_mhz_end=cpu_frequency(),
+ )
+ )
+ (ROOT / "micro-raw.json").write_text(json.dumps(RESULTS))
+ current = [r for r in RESULTS if (r["device"], r["case"], r["tokens"], r["scope"]) == (device, case, length, scope)]
+ print(
+ "RESULT",
+ json.dumps(
+ {
+ "device": device,
+ "case": case,
+ "tokens": length,
+ "scope": scope,
+ "host_median_us": {
+ v: statistics.median(t[0] for r in current if r["variant"] == v for t in r["samples_us"])
+ for v in names
+ },
+ }
+ ),
+ flush=True,
+ )
+
+
+def build_model(weights, device):
+ model = Thinker.__new__(Thinker)
+ nn.Module.__init__(model)
+ model.config = Qwen2_5OmniThinkerConfig(**CONFIG)
+ model._has_oov_mm_tokens = False
+ table = VocabParallelEmbedding(VOCAB, HIDDEN, params_dtype=torch.bfloat16, disable_tp=True)
+ table.weight = nn.Parameter(weights.to(device), requires_grad=False)
+
+ class LM(nn.Module):
+ def __init__(self):
+ super().__init__()
+ self.embed_tokens = table
+
+ def embed_input_ids(self, ids):
+ return self.embed_tokens(ids)
+
+ model.language_model = LM()
+ return model
+
+
+def validate(model, args, case, length, device):
+ ids, mm, mask = args
+ lm = model.get_language_model()
+ original = lm.embed_input_ids
+ outputs, counts = {}, {}
+ for variant, method in METHODS.items():
+ calls = []
+
+ def counted(tokens):
+ calls.append(tokens.numel())
+ return original(tokens)
+
+ lm.embed_input_ids = counted
+ try:
+ outputs[variant] = method(model, ids, mm, is_multimodal=mask)
+ finally:
+ lm.embed_input_ids = original
+ counts[variant] = len(calls)
+ assert torch.equal(outputs["baseline"], outputs["fixed"])
+ assert counts == {"baseline": 1 if case == "text" else 2, "fixed": 1}
+ VALIDATIONS.append(dict(case=case, tokens=length, device=device, calls=counts, same_input_wrapper_torch_equal=True))
+ (ROOT / "validation.json").write_text(json.dumps(VALIDATIONS, indent=2))
+
+
+@torch.inference_mode()
+def main():
+ import transformers
+ import vllm
+
+ metadata = dict(
+ baseline_ref=args.baseline_ref,
+ checkout=subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
+ torch=torch.__version__,
+ vllm=vllm.__version__,
+ transformers=transformers.__version__,
+ gpu=torch.cuda.get_device_name(),
+ cpu_core=CORE,
+ torch_cpu_threads=1,
+ warmup=args.warmup,
+ blocks=args.blocks,
+ iterations=args.iterations,
+ lengths=args.lengths,
+ embedding_shape=[VOCAB, HIDDEN],
+ dtype="bfloat16",
+ input_ids_dtype="int32",
+ mask_device="cpu",
+ start_time=time.time(),
+ metric_order=["host_observed_us", "calling_thread_cpu_us", "synchronized_completion_us"],
+ )
+ index = json.loads((MODEL / "model.safetensors.index.json").read_text())["weight_map"]
+ key = "thinker.model.embed_tokens.weight"
+ with safe_open(MODEL / index[key], framework="pt", device="cpu") as f:
+ weights = f.get_tensor(key)
+ assert weights.shape == (VOCAB, HIDDEN) and weights.dtype == torch.bfloat16
+ model = build_model(weights, "cuda")
+ measure({"timer_floor": lambda: None}, "cuda", "timer", 0, "overhead", rounds=10, iterations=100)
+ for length in args.lengths:
+ for case in ("text", "audio", "image", "video", "mixed", "interleaved"):
+ ids, mm, mask = payload(case, length, "cuda")
+ validate(model, (ids, mm, mask), case, length, "cuda")
+ functions = {
+ variant: (lambda method=method: method(model, ids, mm, is_multimodal=mask))
+ for variant, method in METHODS.items()
+ }
+ measure(
+ functions,
+ "cuda",
+ case,
+ length,
+ "whole_wrapper",
+ rounds=args.blocks,
+ iterations=args.iterations,
+ warmups=args.warmup,
+ )
+ summary = []
+ for length in args.lengths:
+ for case in ("text", "audio", "image", "video", "mixed", "interleaved"):
+ medians = {
+ variant: statistics.median(
+ statistics.median(sample[0] for sample in record["samples_us"])
+ for record in RESULTS
+ if record["tokens"] == length and record["case"] == case and record["variant"] == variant
+ )
+ for variant in METHODS
+ }
+ summary.append(
+ dict(
+ tokens=length,
+ case=case,
+ host_us=medians,
+ reduction_pct=100 * (1 - medians["fixed"] / medians["baseline"]),
+ )
+ )
+ metadata["end_time"] = time.time()
+ (ROOT / "metadata.json").write_text(json.dumps(metadata, indent=2))
+ (ROOT / "summary.json").write_text(json.dumps(summary, indent=2))
+ print(json.dumps(summary, indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/benchmarks/qwen3_omni/embedding_harness.py b/benchmarks/qwen3_omni/embedding_harness.py
new file mode 100644
index 000000000..63d14ef0d
--- /dev/null
+++ b/benchmarks/qwen3_omni/embedding_harness.py
@@ -0,0 +1,226 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+"""Small fixtures for the real Qwen3 thinker embedding method and its oracle."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from dataclasses import asdict, dataclass
+from types import SimpleNamespace
+
+import torch
+from torch import nn
+from torch.nn import functional as F
+
+SCENARIOS = ("text", "empty", "audio", "image", "video", "mixed", "interleaved", "vision_no_deepstack")
+
+
+@dataclass(frozen=True)
+class ModelShape:
+ vocab_size: int = 152064
+ hidden_size: int = 2048
+ deepstack_levels: int = 3
+ image_token_id: int = 151655
+ video_token_id: int = 151656
+ audio_token_id: int = 151675
+
+ def __post_init__(self):
+ if min(self.vocab_size, self.hidden_size, self.deepstack_levels) <= 0:
+ raise ValueError("vocab_size, hidden_size and deepstack_levels must be positive")
+ ids = (self.image_token_id, self.video_token_id, self.audio_token_id)
+ if len(set(ids)) != 3 or not all(0 < token < self.vocab_size for token in ids):
+ raise ValueError("Provide three distinct, positive, in-vocabulary modality IDs; use oov=True to test OOV")
+
+ def token_ids(self, oov=False):
+ if oov:
+ return dict(zip(("image", "video", "audio"), range(self.vocab_size, self.vocab_size + 3)))
+ return {name: getattr(self, f"{name}_token_id") for name in ("image", "video", "audio")}
+
+
+@dataclass
+class Feature:
+ modality: str
+ positions: torch.Tensor
+ main: torch.Tensor
+ deepstack: torch.Tensor | None
+ combined: torch.Tensor
+
+
+@dataclass
+class EmbeddingCase:
+ name: str
+ input_ids: torch.Tensor
+ is_multimodal: torch.Tensor | None
+ features: tuple[Feature, ...]
+ fingerprint: str
+
+ def fresh_embeddings(self):
+ # The thinker replaces list entries with main-scale views.
+ return None if self.name == "text" else [feature.combined for feature in self.features]
+
+
+def _tensor_bytes(tensor):
+ return tensor.detach().contiguous().view(torch.uint8).cpu().numpy().tobytes()
+
+
+def build_case(
+ name,
+ num_tokens,
+ num_mm_tokens,
+ shape,
+ *,
+ device="cuda",
+ dtype=torch.bfloat16,
+ mask_device="cpu",
+ seed=42,
+ oov=False,
+):
+ """Build known feature placements; no model merge is used to define them."""
+ if name not in SCENARIOS:
+ raise ValueError(f"Unknown scenario: {name}")
+ if num_tokens <= 0 or not 0 <= num_mm_tokens < num_tokens:
+ raise ValueError("Require N > 0 and 0 <= M < N")
+ if (name in {"text", "empty"}) != (num_mm_tokens == 0):
+ raise ValueError("Only text/empty scenarios accept M=0")
+ if name in {"mixed", "interleaved"} and (num_mm_tokens < 4 or num_mm_tokens % 2):
+ raise ValueError("Mixed/interleaved scenarios require an even M >= 4")
+ token_ids = shape.token_ids(oov)
+ generator = torch.Generator().manual_seed(seed)
+ ids = torch.randint(0, min(shape.vocab_size, *token_ids.values()), (num_tokens,), generator=generator)
+ mask = torch.zeros(num_tokens, dtype=torch.bool)
+ positions = torch.arange((num_tokens - num_mm_tokens) // 2, (num_tokens - num_mm_tokens) // 2 + num_mm_tokens)
+ groups = []
+ if name == "mixed":
+ groups = [("audio", positions[: num_mm_tokens // 2]), ("image", positions[num_mm_tokens // 2 :])]
+ elif name == "interleaved":
+ groups = [("video", positions[::2]), ("audio", positions[1::2])]
+ elif num_mm_tokens:
+ groups = [("image" if name == "vision_no_deepstack" else name, positions)]
+ features = []
+ for modality, indexes in groups:
+ ids[indexes] = token_ids[modality]
+ mask[indexes] = True
+ rows = torch.arange(len(indexes), dtype=torch.float32)[:, None].remainder(31) / 32
+ cols = torch.arange(shape.hidden_size, dtype=torch.float32)[None, :].remainder(7) / 16
+ main_cpu = rows + cols + {"audio": 2, "image": 4, "video": 6}[modality]
+ main = main_cpu.to(device=device, dtype=dtype)
+ deepstack = None
+ parts = [main]
+ if modality != "audio" and name != "vision_no_deepstack":
+ deepstack = torch.stack(
+ [(main_cpu + 16 + 4 * level).to(device=device, dtype=dtype) for level in range(shape.deepstack_levels)]
+ )
+ parts.extend(deepstack.unbind(0))
+ combined = torch.cat(parts, dim=-1)
+ # set_mm_embedding_modality simply attaches this attribute. Tag only
+ # after cat/to: views and copies do not preserve arbitrary attributes.
+ combined.modality = modality
+ features.append(Feature(modality, indexes, main, deepstack, combined))
+ digest = hashlib.sha256(json.dumps({"name": name, "shape": asdict(shape), "seed": seed, "oov": oov}).encode())
+ digest.update(_tensor_bytes(ids))
+ digest.update(_tensor_bytes(mask))
+ for feature in features:
+ digest.update(feature.modality.encode())
+ digest.update(_tensor_bytes(feature.combined))
+ return EmbeddingCase(
+ name,
+ ids.to(device=device),
+ None if name == "text" else mask.to(device=mask_device),
+ tuple(features),
+ digest.hexdigest(),
+ )
+
+
+class LanguageModelAdapter(nn.Module):
+ def __init__(self, embedding):
+ super().__init__()
+ self.embedding = embedding
+
+ def embed_input_ids(self, input_ids):
+ return self.embedding(input_ids)
+
+ def forward(self, input_ids):
+ return self.embed_input_ids(input_ids)
+
+
+def make_thinker(
+ shape,
+ *,
+ device="cuda",
+ dtype=torch.bfloat16,
+ seed=42,
+ backend="torch",
+ deepstack=True,
+ buffer_capacity=32,
+ oov=False,
+):
+ """Skip checkpoint construction, preserving the real class and its MRO."""
+ from tests.model_executor.helpers import bootstrap_vllm_layer_custom_op_modules
+
+ bootstrap_vllm_layer_custom_op_modules()
+ from vllm_omni.model_executor.models.qwen3_omni.qwen3_omni_moe_thinker import (
+ Qwen3OmniMoeThinkerForConditionalGeneration,
+ )
+
+ if buffer_capacity <= 0:
+ raise ValueError("buffer_capacity must be positive")
+ device = torch.device(device)
+ if backend == "torch":
+ embedding = nn.Embedding(shape.vocab_size, shape.hidden_size, device=device, dtype=dtype)
+ elif backend == "vllm":
+ from vllm.model_executor.layers.vocab_parallel_embedding import VocabParallelEmbedding
+
+ with torch.device(device):
+ embedding = VocabParallelEmbedding(shape.vocab_size, shape.hidden_size, params_dtype=dtype, disable_tp=True)
+ else:
+ raise ValueError(f"Unknown embedding backend: {backend}")
+ with torch.no_grad():
+ embedding.weight.uniform_(-0.5, 0.5, generator=torch.Generator(device=device).manual_seed(seed))
+ thinker = object.__new__(Qwen3OmniMoeThinkerForConditionalGeneration)
+ nn.Module.__init__(thinker)
+ thinker.config = SimpleNamespace(text_config=SimpleNamespace(hidden_size=shape.hidden_size))
+ for modality, token_id in shape.token_ids(oov).items():
+ setattr(thinker.config, f"{modality}_token_id", token_id)
+ thinker.visual = SimpleNamespace(
+ deepstack_visual_indexes=list(range(shape.deepstack_levels)) if deepstack else None
+ )
+ thinker.language_model = LanguageModelAdapter(embedding)
+ thinker._has_oov_mm_tokens = oov
+ thinker.use_deepstack = deepstack
+ thinker.deepstack_num_level = shape.deepstack_levels if deepstack else 0
+ thinker.deepstack_input_embeds = [
+ torch.zeros(buffer_capacity, shape.hidden_size, device=device, dtype=dtype)
+ for _ in range(thinker.deepstack_num_level)
+ ]
+ thinker.deepstack_input_embeds_num_tokens = 0
+ return thinker.eval()
+
+
+@torch.inference_mode()
+def expected_outputs(model, case):
+ """Independent oracle: direct lookup and explicit positions, not merge helpers."""
+ ids = case.input_ids
+ if model._has_oov_mm_tokens and case.is_multimodal is not None:
+ ids = ids.masked_fill(case.is_multimodal.to(ids.device), 0)
+ main = F.embedding(ids, model.language_model.embedding.weight)
+ has_deepstack = any(feature.deepstack is not None for feature in case.features)
+ deepstack = main.new_zeros((model.deepstack_num_level, *main.shape)) if has_deepstack else None
+ for feature in case.features:
+ indexes = feature.positions.to(main.device)
+ main[indexes] = feature.main
+ if feature.deepstack is not None:
+ deepstack[:, indexes, :] = feature.deepstack
+ return main, deepstack
+
+
+@torch.inference_mode()
+def assert_case_output(model, case, result):
+ expected, deepstack = expected_outputs(model, case)
+ torch.testing.assert_close(result, expected, rtol=0, atol=0)
+ if deepstack is None:
+ assert model.deepstack_input_embeds_num_tokens == 0, "Unexpected stale DeepStack buffer"
+ else:
+ assert model.deepstack_input_embeds_num_tokens == case.input_ids.numel()
+ actual = torch.stack([buffer[: case.input_ids.numel()] for buffer in model.deepstack_input_embeds])
+ torch.testing.assert_close(actual, deepstack, rtol=0, atol=0)
diff --git a/benchmarks/tts/bench_cosyvoice3_concurrency_matrix.py b/benchmarks/tts/bench_cosyvoice3_concurrency_matrix.py
new file mode 100644
index 000000000..fb45b1743
--- /dev/null
+++ b/benchmarks/tts/bench_cosyvoice3_concurrency_matrix.py
@@ -0,0 +1,517 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+
+"""CosyVoice3 streaming e2e benchmark: concurrency x utterance-length matrix.
+
+Launches the server once per (concurrency, step-size) combo, streams TTS
+requests cycling through several utterance lengths, and reports
+TTFA/E2E/RTF/per-chunk latency (mean, stdev, range) per cell. "Step size" is
+``codec_chunk_frames``, the token-hop length the first emitted chunk waits
+for; later chunks wait for a doubling hop capped at ``4 * codec_chunk_frames``
+(see ``_token_hop_lengths``). Per-chunk latency is reconstructed from that
+same ramp rather than from raw transport read boundaries, which don't align
+to model chunks (see ``one_request``'s docstring).
+
+Running with no flags reproduces the default matrix. Per-request correctness
+is checked: non-silent, long enough, and (with a fixed ``--seed``)
+byte-identical PCM across requests for the same text.
+"""
+
+import argparse
+import json
+import os
+import signal
+import statistics
+import subprocess
+import sys
+import threading
+import time
+from pathlib import Path
+
+import httpx
+import numpy as np
+
+REPO = Path(__file__).resolve().parents[2]
+DEFAULT_MODEL = "FunAudioLLM/Fun-CosyVoice3-0.5B-2512"
+
+DEFAULT_REF_AUDIO = "https://raw.githubusercontent.com/FunAudioLLM/CosyVoice/main/asset/zero_shot_prompt.wav"
+DEFAULT_REF_TEXT = "希望你以后能够做的比我还好呦。"
+
+DEFAULT_TEXTS = [
+ "Thank you so much for the birthday gift you sent me.",
+ "The weather is lovely today, with warm sunshine and a gentle breeze. "
+ "We decided to take a walk in the park to enjoy the spring flowers and the fresh air.",
+ "The weather is lovely today, with warm sunshine and a gentle breeze. We decided to take a walk in the "
+ "park to enjoy the spring flowers and the fresh air. Many people were out walking, children were "
+ "running and playing on the grass, and older folks sat on benches chatting with one another. We "
+ "strolled slowly along the path and came across a cherry tree in full bloom, its pink petals drifting "
+ "on the wind. It was so beautiful that we stopped to take some photos to remember the moment. Then we "
+ "continued on and reached a small lake, its water so clear you could see straight to the bottom, with "
+ "the blue sky and white clouds reflected on its surface. A few ducks paddled back and forth across the "
+ "water, occasionally letting out a soft quack. We sat on a bench by the lake for a while, enjoying the "
+ "peaceful afternoon. Time passed quickly, and before we knew it evening had arrived, with the setting "
+ "sun painting the sky in shades of gold. Reluctant to leave, we finally headed home, already planning "
+ "our next visit. On the way back we talked about everything we had seen and heard that day, feeling "
+ "wonderfully happy. It was truly an unforgettable weekend.",
+]
+
+# PCM16 mono @ 24 kHz (CosyVoice3 output rate)
+SAMPLE_RATE = 24000
+
+# Well below any real speech's RMS; catches all-zero/near-silent PCM that byte
+# count and duration checks alone pass through.
+MIN_RMS = 50.0
+
+# Overlay: only codec_chunk_frames is overridden; must live next to cosyvoice3.yaml (relative base_config)
+DEPLOY_TEMPLATE = """# Auto-generated by bench_cosyvoice3_concurrency_matrix.py -- do not edit.
+base_config: cosyvoice3.yaml
+
+connectors:
+ connector_of_shared_memory:
+ name: SharedMemoryConnector
+ extra:
+ codec_streaming: true
+ connector_get_sleep_s: 0.01
+ connector_get_max_wait_first_chunk: 3000
+ connector_get_max_wait: 300
+ codec_chunk_frames: {chunk_frames}
+ codec_max_chunk_frames: {max_chunk_frames}
+ codec_stream_scale_factor: {stream_scale_factor}
+ codec_left_context_frames: 25
+ codec_vocab_size: 6561
+"""
+
+# CosyVoice3Config constants (transformers_utils/configs/cosyvoice3.py): tokens
+# are consumed at token_mel_ratio mel frames each, and HiFT emits
+# samples_per_mel PCM16 samples per mel frame. Pinned here (not left to the
+# server's defaults) since one_request's byte-boundary reconstruction below
+# depends on knowing them exactly.
+TOKEN_MEL_RATIO = 2
+SAMPLES_PER_MEL = 480
+STREAM_SCALE_FACTOR = 2
+
+
+def _stats(values: list[float]) -> dict[str, float]:
+ """Mean/median/stdev/p95/min/max/n for a list of per-request measurements."""
+ n = len(values)
+ if n == 0:
+ return {
+ "n": 0,
+ "mean": float("nan"),
+ "median": float("nan"),
+ "stdev": float("nan"),
+ "p95": float("nan"),
+ "min": float("nan"),
+ "max": float("nan"),
+ }
+ return {
+ "n": n,
+ "mean": statistics.mean(values),
+ "median": statistics.median(values),
+ "stdev": statistics.stdev(values) if n >= 2 else 0.0,
+ "p95": statistics.quantiles(values, n=20)[18] if n >= 2 else values[0],
+ "min": min(values),
+ "max": max(values),
+ }
+
+
+def vllm_bin() -> str:
+ for c in (REPO / ".venv/bin/vllm", Path(sys.executable).parent / "vllm"):
+ if c.is_file():
+ return str(c)
+ return "vllm"
+
+
+def wait_ready(url: str, proc: subprocess.Popen, log: Path, timeout: float) -> None:
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if proc.poll() is not None:
+ tail = log.read_text(errors="replace").splitlines()[-40:] if log.exists() else []
+ raise RuntimeError("server exited during startup:\n " + "\n ".join(tail))
+ try:
+ if httpx.get(f"{url}/health", timeout=2.0).status_code == 200:
+ return
+ except Exception:
+ pass
+ time.sleep(2.0)
+ raise TimeoutError(f"server not ready within {timeout}s (log: {log})")
+
+
+def _token_hop_lengths(chunk_frames: int, max_chunk_frames: int):
+ """The token-hop-length ramp talker2code2wav_async_chunk uses to decide
+ when to fire the next flow-solve/emission: chunk_frames, then doubling
+ (STREAM_SCALE_FACTOR) each step, capped at max_chunk_frames. This -- not
+ codec_chunk_frames alone -- is what actually paces emitted audio chunks:
+ with the defaults here, hops are chunk_frames, 2x, 4x, 4x, 4x, ...
+
+ The very first emission is consistently one token short of chunk_frames
+ (confirmed against real speech.audio.delta event sizes) even though the
+ ramp's own progression is computed from the nominal chunk_frames, not the
+ actual first hop -- so only the first yield gets the -1 adjustment.
+ """
+ yield chunk_frames - 1
+ hop = chunk_frames
+ while True:
+ hop = min(max_chunk_frames, max(chunk_frames, hop * STREAM_SCALE_FACTOR))
+ yield hop
+
+
+def one_request(client, url, model, text, ref_audio, ref_text, chunk_frames, seed=None):
+ """Run one streaming request, returning TTFA, E2E, per-chunk gaps, and audio bytes.
+
+ ``response_format=pcm`` + ``stream_format=audio`` streams raw bytes with no
+ framing, so a client read boundary alone is a transport artifact, not a
+ model chunk boundary. Reconstructed instead by replicating the server's
+ own token-hop-length ramp (see ``_token_hop_lengths``) to compute each
+ real chunk's expected byte size ahead of time, and slicing the
+ accumulated byte stream at those offsets -- independent of how the
+ transport happens to fragment the underlying reads. The final chunk
+ (finalize, which releases extra held-back frames) is whatever remains
+ when the stream closes, not a predicted hop.
+ """
+ payload = {
+ "model": model,
+ "input": text,
+ "response_format": "pcm",
+ "stream": True,
+ "stream_format": "audio",
+ "ref_audio": ref_audio,
+ "ref_text": ref_text,
+ }
+ if seed is not None:
+ payload["seed"] = seed
+ t0 = time.perf_counter()
+ ttfa = None
+ nbytes = 0
+ audio_bytes = bytearray()
+ chunk_gaps_ms = [] # inter-chunk arrival gaps (per model chunk), by step index
+ prev_t = None
+ step_idx = 0
+
+ bytes_per_token = TOKEN_MEL_RATIO * SAMPLES_PER_MEL * 2
+ hop_gen = _token_hop_lengths(chunk_frames, 4 * chunk_frames)
+ next_boundary = next(hop_gen) * bytes_per_token
+ last_crossed_bytes = 0
+ received = 0
+ last_now = t0
+
+ with client.stream("POST", f"{url}/v1/audio/speech", json=payload, headers={"Authorization": "Bearer EMPTY"}) as r:
+ if r.status_code != 200:
+ raise RuntimeError(f"{r.status_code}: {r.read()[:300]!r}")
+ for chunk in r.iter_bytes():
+ if not chunk:
+ continue
+ now = time.perf_counter()
+ if ttfa is None:
+ ttfa = now - t0
+ last_now = now
+ received += len(chunk)
+ nbytes += len(chunk)
+ audio_bytes += chunk
+ while received >= next_boundary:
+ if prev_t is not None:
+ chunk_gaps_ms.append((step_idx, (now - prev_t) * 1e3))
+ step_idx += 1
+ prev_t = now
+ last_crossed_bytes = next_boundary
+ next_boundary += next(hop_gen) * bytes_per_token
+
+ if received > last_crossed_bytes:
+ # Finalize chunk: releases extra held-back frames, so it doesn't
+ # match a predicted hop size -- its arrival is the stream closing.
+ if prev_t is not None:
+ chunk_gaps_ms.append((step_idx, (last_now - prev_t) * 1e3))
+ step_idx += 1
+
+ e2e = time.perf_counter() - t0
+ audio_s = nbytes / 2 / SAMPLE_RATE
+ return {
+ "ttfa_s": ttfa,
+ "e2e_s": e2e,
+ "audio_s": audio_s,
+ "rtf": e2e / audio_s if audio_s > 0 else float("nan"),
+ "chunk_gaps_ms": chunk_gaps_ms,
+ "nbytes": nbytes,
+ "audio_bytes": audio_bytes,
+ }
+
+
+def check_correctness(r: dict, min_audio_s: float) -> list[str]:
+ """Return a list of correctness violations (empty = pass)."""
+ problems = []
+ if r["nbytes"] <= 0:
+ problems.append("empty audio (0 bytes)")
+ if r["audio_s"] < min_audio_s:
+ problems.append(f"audio too short ({r['audio_s']:.2f}s < {min_audio_s:.2f}s)")
+ if r["nbytes"] > 0:
+ samples = np.frombuffer(bytes(r["audio_bytes"]), dtype=np.int16).astype(np.float64)
+ rms = float(np.sqrt(np.mean(samples**2)))
+ if rms < MIN_RMS:
+ problems.append(f"near-silent audio (rms={rms:.1f} < {MIN_RMS})")
+ return problems
+
+
+def drive(url, args, n, conc, label, results, lock, idx, chunk_frames):
+ """Run ``n`` requests at concurrency ``conc``, cycling through ``args.texts``."""
+ texts = args.texts
+
+ def worker():
+ with httpx.Client(timeout=600.0) as c:
+ while True:
+ with lock:
+ i = idx[0]
+ idx[0] += 1
+ if i >= n:
+ return
+ text = texts[i % len(texts)]
+ try:
+ r = one_request(
+ c, url, args.model, text, args.ref_audio, args.ref_text, chunk_frames, seed=args.seed
+ )
+ r["text"] = text
+ with lock:
+ results.append(r)
+ except Exception as e:
+ print(f" [{label}] request {i} failed: {e}", flush=True)
+
+ ts = [threading.Thread(target=worker) for _ in range(conc)]
+ t0 = time.perf_counter()
+ for t in ts:
+ t.start()
+ for t in ts:
+ t.join()
+ return time.perf_counter() - t0
+
+
+def run_combo(args, conc: int, chunk_frames: int, out: Path) -> dict:
+ tag = f"c{conc}_chunk{chunk_frames}"
+ run_dir = out / tag
+ run_dir.mkdir(parents=True, exist_ok=True)
+ log = run_dir / "server.log"
+
+ deploy = REPO / "vllm_omni/deploy" / f"_autogen_steps_{tag}.yaml"
+ deploy.write_text(
+ DEPLOY_TEMPLATE.format(
+ chunk_frames=chunk_frames,
+ max_chunk_frames=4 * chunk_frames,
+ stream_scale_factor=STREAM_SCALE_FACTOR,
+ )
+ )
+
+ cmd = [
+ vllm_bin(),
+ "serve",
+ args.model,
+ "--omni",
+ "--deploy-config",
+ str(deploy),
+ "--host",
+ "127.0.0.1",
+ "--port",
+ str(args.port),
+ "--allowed-local-media-path",
+ str(REPO),
+ ]
+ env = dict(os.environ)
+ if args.cuda_visible_devices:
+ env["CUDA_VISIBLE_DEVICES"] = args.cuda_visible_devices
+ if args.trt is not None:
+ env["COSYVOICE3_TRT"] = args.trt
+
+ print(f"\n=== concurrency={conc} step_size={chunk_frames} ===", flush=True)
+ print(f" launching server (log: {log})", flush=True)
+ with open(log, "w") as lf:
+ proc = subprocess.Popen(cmd, env=env, stdout=lf, stderr=subprocess.STDOUT, start_new_session=True)
+ url = f"http://127.0.0.1:{args.port}"
+ try:
+ wait_ready(url, proc, log, args.server_timeout)
+ print(" server ready", flush=True)
+
+ results: list[dict] = []
+ lock = threading.Lock()
+ idx = [0]
+ if args.warmup:
+ print(f" warmup ({args.warmup} req)", flush=True)
+ drive(url, args, args.warmup, min(conc, args.warmup), "warmup", results, lock, idx, chunk_frames)
+ results.clear()
+ idx[0] = 0
+
+ print(f" running {args.num_prompts} req @ concurrency {conc}", flush=True)
+ wall = drive(url, args, args.num_prompts, conc, "bench", results, lock, idx, chunk_frames)
+
+ ok = [r for r in results if r.get("ttfa_s") is not None]
+ problems = []
+ for r in ok:
+ problems += check_correctness(r, args.min_audio_s)
+ # With a fixed seed, same-text requests must produce byte-identical PCM (per text group)
+ if args.seed is not None:
+ by_text: dict[str, bytes] = {}
+ for r in ok:
+ t = r.get("text")
+ ref = by_text.get(t)
+ if ref is None:
+ by_text[t] = r.get("audio_bytes")
+ elif r.get("audio_bytes") != ref:
+ problems.append(f"byte-identity violation for text {t!r}: seeded requests produced different PCM")
+ n_ok = len(ok)
+ total_audio_s = sum(r["audio_s"] for r in ok)
+ all_gaps = [g for r in ok for g in r["chunk_gaps_ms"]] # (step_index, gap_ms) per request
+ gap_vals = [g for _, g in all_gaps]
+
+ # bucket gaps by chunk position within a request, to see any O(n) slope
+ by_idx: dict[int, list[float]] = {}
+ for step_index, g in all_gaps:
+ by_idx.setdefault(step_index, []).append(g)
+ step_latency_by_idx = {i: statistics.median(vals) for i, vals in sorted(by_idx.items())}
+
+ agg = {
+ "concurrency": conc,
+ "step_size": chunk_frames,
+ "ok": n_ok,
+ "errors": args.num_prompts - n_ok,
+ "correctness_problems": len(problems),
+ "correctness_sample": problems[:5],
+ "total_audio_s": total_audio_s,
+ "throughput_audio_s_per_s": total_audio_s / wall if wall > 0 else float("nan"),
+ "ttfa_s": _stats([r["ttfa_s"] for r in ok]),
+ "e2e_s": _stats([r["e2e_s"] for r in ok]),
+ "rtf": _stats([r["rtf"] for r in ok]),
+ "step_latency_ms": _stats(gap_vals),
+ "step_latency_ms_by_idx": step_latency_by_idx,
+ "num_steps": len(all_gaps),
+ # per-utterance-length breakdown; n per text = num_prompts / len(texts)
+ "by_text": {
+ t: {
+ "audio_s": _stats([r["audio_s"] for r in ok if r.get("text") == t]),
+ "e2e_s": _stats([r["e2e_s"] for r in ok if r.get("text") == t]),
+ "rtf": _stats([r["rtf"] for r in ok if r.get("text") == t]),
+ }
+ for t in sorted({r.get("text") for r in ok})
+ },
+ }
+ print(json.dumps(agg, indent=2, ensure_ascii=False), flush=True)
+ return agg
+ finally:
+ print(" shutting down server", flush=True)
+ try:
+ os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
+ except Exception:
+ proc.terminate()
+ try:
+ proc.wait(timeout=60)
+ except subprocess.TimeoutExpired:
+ try:
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
+ except Exception:
+ proc.kill()
+ proc.wait(timeout=20)
+ deploy.unlink(missing_ok=True)
+
+
+def parse_args() -> argparse.Namespace:
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ p.add_argument("--model", default=DEFAULT_MODEL)
+ p.add_argument(
+ "--text",
+ dest="texts",
+ nargs="+",
+ default=DEFAULT_TEXTS,
+ help="One or more utterance texts. With several, requests cycle through "
+ "them so a single server launch covers all utterance lengths; results "
+ "are bucketed per text.",
+ )
+ p.add_argument("--ref-audio", default=DEFAULT_REF_AUDIO)
+ p.add_argument("--ref-text", default=DEFAULT_REF_TEXT)
+ p.add_argument("--concurrency", type=int, nargs="+", default=[1, 8])
+ p.add_argument(
+ "--step-size",
+ type=int,
+ nargs="+",
+ default=[15],
+ help="codec_chunk_frames values to sweep (the streaming step size).",
+ )
+ p.add_argument("--num-prompts", type=int, default=30)
+ p.add_argument("--warmup", type=int, default=2)
+ p.add_argument("--port", type=int, default=8091)
+ p.add_argument(
+ "--server-timeout",
+ type=float,
+ default=1800.0,
+ help="Startup budget; first run also downloads weights and builds the TRT engine.",
+ )
+ p.add_argument("--output-dir", default="./perf_steps")
+ p.add_argument("--cuda-visible-devices", default="0")
+ p.add_argument(
+ "--trt",
+ choices=["0", "1"],
+ default=None,
+ help="Pin COSYVOICE3_TRT. 0=force torch estimator, 1=allow TensorRT. Default: unset.",
+ )
+ p.add_argument(
+ "--min-audio-s",
+ type=float,
+ default=1.0,
+ help="Minimum expected audio seconds per request for the correctness check.",
+ )
+ p.add_argument(
+ "--seed",
+ type=int,
+ default=42,
+ help="Fixed seed for reproducible (byte-identical) audio. Pass --seed '' "
+ "equivalent (any non-int) to disable -- there is no unset sentinel here "
+ "by design, since the default sweep is meant to be exactly reproducible.",
+ )
+ return p.parse_args()
+
+
+def main() -> None:
+ args = parse_args()
+ out = Path(args.output_dir).resolve()
+ out.mkdir(parents=True, exist_ok=True)
+
+ all_results = []
+ failed = False
+ for conc in args.concurrency:
+ for chunk_frames in args.step_size:
+ try:
+ result = run_combo(args, conc, chunk_frames, out)
+ all_results.append(result)
+ if result["errors"] > 0 or result["correctness_problems"] > 0:
+ failed = True
+ except Exception as e:
+ print(f" combo c={conc} chunk={chunk_frames} FAILED: {e}", flush=True)
+ failed = True
+
+ out_path = out / "steps_results.json"
+ with open(out_path, "w", encoding="utf-8") as f:
+ json.dump({"model": args.model, "texts": args.texts, "runs": all_results}, f, indent=2, ensure_ascii=False)
+ print(f"\nResults written to {out_path}", flush=True)
+
+ print("\n=== SUMMARY ===")
+ header = (
+ f"{'conc':>4} {'step':>4} {'ok':>4} {'corr':>4} "
+ f"{'TTFA med(ms)':>12} {'step med(ms)':>12} {'step p95(ms)':>12} {'RTF mean+-sd':>14}"
+ )
+ print(header)
+ print("-" * len(header))
+ for r in all_results:
+ print(
+ f"{r['concurrency']:>4} {r['step_size']:>4} {r['ok']:>4} {r['correctness_problems']:>4} "
+ f"{r['ttfa_s']['median'] * 1e3:>12.1f} "
+ f"{r['step_latency_ms']['median']:>12.1f} {r['step_latency_ms']['p95']:>12.1f} "
+ f"{r['rtf']['mean']:>6.3f}+-{r['rtf']['stdev']:>5.3f}"
+ )
+ for t, stats in r.get("by_text", {}).items():
+ print(
+ f" text {t[:24]!r}: e2e {stats['e2e_s']['mean'] * 1e3:8.1f}"
+ f"+-{stats['e2e_s']['stdev'] * 1e3:6.1f} ms (n={stats['e2e_s']['n']}, "
+ f"audio {stats['audio_s']['mean']:.1f}s)"
+ )
+
+ if failed:
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docker/Dockerfile.cuda b/docker/Dockerfile.cuda
index 3881ca2e5..072b12dfa 100644
--- a/docker/Dockerfile.cuda
+++ b/docker/Dockerfile.cuda
@@ -1,4 +1,4 @@
-ARG BASE_IMAGE=vllm/vllm-openai:v0.28.0
+ARG BASE_IMAGE=vllm/vllm-openai:v0.29.0
FROM ${BASE_IMAGE}
ARG COMMON_WORKDIR=/app
diff --git a/docker/Dockerfile.npu b/docker/Dockerfile.npu
index 0d91c0b32..f83cc512a 100644
--- a/docker/Dockerfile.npu
+++ b/docker/Dockerfile.npu
@@ -1,5 +1,5 @@
ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend
-ARG VLLM_ASCEND_VERSION=v0.28.0
+ARG VLLM_ASCEND_VERSION=v0.29.0
ARG VLLM_ASCEND_DEVICE_SUFFIX=
FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_VERSION}${VLLM_ASCEND_DEVICE_SUFFIX}
diff --git a/docker/Dockerfile.npu.ci b/docker/Dockerfile.npu.ci
index 0a150fdd8..a25155111 100644
--- a/docker/Dockerfile.npu.ci
+++ b/docker/Dockerfile.npu.ci
@@ -1,5 +1,5 @@
ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend
-ARG VLLM_ASCEND_VERSION=v0.28.0
+ARG VLLM_ASCEND_VERSION=v0.29.0
ARG VLLM_ASCEND_DEVICE_SUFFIX=
FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_VERSION}${VLLM_ASCEND_DEVICE_SUFFIX}
@@ -44,6 +44,7 @@ RUN python3 -m pip install -U pip && \
"onnx>=1.16.0" \
"librosa>=0.10.0" \
"datasets>=3.0.0" \
+ "torchmetrics==1.9.0" \
--index-url http://cache-service.nginx-pypi-cache.svc.cluster.local/pypi/simple \
--trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \
--default-timeout=60
diff --git a/docker/Dockerfile.rocm b/docker/Dockerfile.rocm
index 597f5d134..48631994f 100644
--- a/docker/Dockerfile.rocm
+++ b/docker/Dockerfile.rocm
@@ -1,16 +1,22 @@
-ARG BASE_IMAGE=vllm/vllm-openai-rocm:v0.28.0
-FROM ${BASE_IMAGE} AS base
+# Keep the ROCm runtime aligned with the vLLM release targeted by
+# docker/Dockerfile.ci. A stale base image can build successfully while failing
+# at runtime when vLLM-Omni imports APIs added by the newer vLLM release.
+ARG BASE_IMAGE=vllm/vllm-openai-rocm:v0.29.0
+
+# Keep the dependency-heavy portion independent from the source tree. The
+# registry cache is keyed on these inputs, while the final source copy can be
+# linked onto the cached image without materializing its large ROCm layers.
+FROM ${BASE_IMAGE} AS deps
-# Declare a variable to know if we want to use the nightly build or the stable build.
# NOTE: REMINDER to vLLM-Omni rebase maintainer
-# Remember to set `USE_NIGHTLY_BUILD` to 0, when switching back to stable vLLM docker image
-# 1. If vLLM-omni maintainer is forced to use custom commits
-# during rebasing, they can change the two variables
-# 2. Whenever vLLM upstream has released a stable version,
-# we should swap over to use stable release ASAP.
-# We should avoid relying on custom commits.
+# BASE_IMAGE pins the default prebuilt runtime when USE_NIGHTLY_BUILD=0.
+# VLLM_VERSION_OR_COMMIT_HASH is consumed only by the opt-in source rebuild when
+# USE_NIGHTLY_BUILD=1. Keep both aligned with docker/Dockerfile.ci's
+# VLLM_BASE_TAG. This is enforced by tests/buildkite/test_rocm_dockerfile.py. An
+# intentional custom source pin must update that regression in the same change,
+# and should return to the stable release as soon as one is available.
ARG USE_NIGHTLY_BUILD=0
-ARG VLLM_VERSION_OR_COMMIT_HASH=v0.28.0
+ARG VLLM_VERSION_OR_COMMIT_HASH=v0.29.0
ARG ARG_PYTORCH_ROCM_ARCH
ENV PYTORCH_ROCM_ARCH=${ARG_PYTORCH_ROCM_ARCH:-${PYTORCH_ROCM_ARCH}}
@@ -38,6 +44,10 @@ RUN if [ "${USE_NIGHTLY_BUILD}" = "1" ]; then \
rm -rf vllm/.git; \
fi
+# Fail during image construction instead of letting every AMD test fail at
+# runtime when the base image and vLLM-Omni target incompatible vLLM APIs.
+RUN python3 -c "import vllm; from vllm.v1.kv_cache_interface import compute_layout_strides; from vllm.v1.kv_cache_layout import KVCacheLayout; print(f'Validated vLLM {vllm.__version__}')"
+
# Build TorchCodec after any optional nightly vLLM reinstall so it targets the
# final torch/ROCm ABI. Keep this layer before COPY . so source changes do not
# invalidate the expensive native build. FFmpeg development headers are
@@ -58,9 +68,24 @@ RUN --mount=type=cache,target=/root/.cache/pip \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
-# Step 3: Copy vllm-omni code and install without uv
-RUN mkdir -p ${COMMON_WORKDIR}/vllm-omni
-COPY . ${COMMON_WORKDIR}/vllm-omni
+# Step 3: Install project dependencies against a placeholder package tree.
+# The editable install keeps resolving imports from this directory after the
+# final COPY --link replaces the placeholder with the real source tree.
+WORKDIR ${COMMON_WORKDIR}/vllm-omni
+RUN mkdir -p vllm_omni && touch vllm_omni/__init__.py
+COPY pyproject.toml setup.py ./
+COPY requirements/ requirements/
+ENV SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0
+
+RUN uv pip install --python "$(python3 -c 'import sys; print(sys.executable)')" \
+ --no-cache-dir -e ".[dev]" --no-build-isolation && \
+ python3 -m pip uninstall -y onnxruntime && \
+ python3 -m pip install --no-cache-dir --force-reinstall --no-deps \
+ "onnxruntime-rocm>=1.22.2" && \
+ python3 -c "import importlib.metadata as m; import onnxruntime as ort; assert 'ROCMExecutionProvider' in ort.get_available_providers(), ort.get_available_providers(); assert m.version('onnxruntime-rocm'); assert not any(d.metadata['Name'] == 'onnxruntime' for d in m.distributions())" && \
+ python3 -c "from torchcodec.decoders import VideoDecoder"
+
+RUN ln -sf /usr/bin/python3 /usr/bin/python
# This is a workaround to ensure pytest exits with the correct status code in CI tests.
RUN printf '%s\n' \
@@ -79,19 +104,13 @@ RUN printf '%s\n' \
' os._exit(_exit_code)' \
> ${COMMON_WORKDIR}/vllm-omni/conftest.py
-# Some optional dependencies pull in the CPU ONNX Runtime distribution. The
-# CPU and ROCm wheels own the same import path and cannot coexist, so remove
-# the CPU wheel and reinstall the ROCm wheel after dependency resolution.
-RUN cd ${COMMON_WORKDIR}/vllm-omni && \
- uv pip install --python "$(python3 -c 'import sys; print(sys.executable)')" \
- --no-cache-dir ".[dev]" --no-build-isolation && \
- python3 -m pip uninstall -y onnxruntime && \
- python3 -m pip install --no-cache-dir --force-reinstall --no-deps \
- "onnxruntime-rocm>=1.22.2" && \
- python3 -c "import importlib.metadata as m; import onnxruntime as ort; assert 'ROCMExecutionProvider' in ort.get_available_providers(), ort.get_available_providers(); assert m.version('onnxruntime-rocm'); assert not any(d.metadata['Name'] == 'onnxruntime' for d in m.distributions())" && \
- python3 -c "from torchcodec.decoders import VideoDecoder"
-
-RUN ln -sf /usr/bin/python3 /usr/bin/python
+# Keep source changes in one lightweight linked layer. Do not add RUN, COPY, or
+# ADD instructions that depend on the resulting filesystem after this point;
+# doing so would force BuildKit to download and extract the cached ROCm layers.
+FROM deps AS base
+ARG COMMON_WORKDIR=/app
+WORKDIR ${COMMON_WORKDIR}/vllm-omni
+COPY --link . .
FROM base AS test
diff --git a/docker/Dockerfile.rocm.dockerignore b/docker/Dockerfile.rocm.dockerignore
new file mode 100644
index 000000000..f68737749
--- /dev/null
+++ b/docker/Dockerfile.rocm.dockerignore
@@ -0,0 +1,35 @@
+# Keep the linked source layer small and deterministic.
+.git
+.venv
+/build
+dist
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+.mypy_cache
+
+# Distribution / packaging
+.Python
+/build/
+cmake-build-*/
+CMakeUserPresets.json
+develop-eggs/
+/dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+rust/target/
diff --git a/docker/Dockerfile.xpu b/docker/Dockerfile.xpu
index acfa71588..da7930ae2 100644
--- a/docker/Dockerfile.xpu
+++ b/docker/Dockerfile.xpu
@@ -1,4 +1,4 @@
-# Target vLLM version: v0.28.0. Keep VLLM_VERSION aligned with the target vLLM
+# Target vLLM version: v0.29.0. Keep VLLM_VERSION aligned with the target vLLM
# release on every rebase.
#
# This file layers vLLM-Omni on top of the published upstream XPU image, which
@@ -18,7 +18,7 @@
#
# .buildkite/intel/scripts/run-xpu-test.sh automates that pull-then-build fallback.
ARG VLLM_BASE_IMAGE=vllm/vllm-openai-xpu
-ARG VLLM_VERSION=v0.28.0
+ARG VLLM_VERSION=v0.29.0
ARG VLLM_BASE=${VLLM_BASE_IMAGE}:${VLLM_VERSION}
FROM ${VLLM_BASE} AS vllm-omni
@@ -40,7 +40,6 @@ ENV VLLM_OMNI_TARGET_DEVICE=xpu
# Keep the upstream oneCCL packages paired with the XPU torch runtime.
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install --no-build-isolation \
- --extra-index-url https://mirrors.ustc.edu.cn/pypi/simple/ \
".[dev]" && \
uv pip uninstall triton && \
uv pip install triton-xpu==3.7.2 --reinstall
diff --git a/docs/.nav.yml b/docs/.nav.yml
index aa84f2819..da55af813 100644
--- a/docs/.nav.yml
+++ b/docs/.nav.yml
@@ -71,6 +71,7 @@ nav:
- Overview and Compatibility:
- Overview: user_guide/diffusion_features.md
- Feature Compatibility: user_guide/feature_compatibility.md
+ - Device-side Video Postprocessing: user_guide/diffusion/device_side_video_postprocess.md
- CPU Offloading:
- Overview: user_guide/diffusion/cpu_offload.md
- Model-Level: user_guide/diffusion/offloader/module_offload.md
@@ -135,6 +136,7 @@ nav:
- Mooncake Store Connector: design/feature/omni_connectors/mooncake_store_connector.md
- Mooncake Transfer Engine Connector: design/feature/omni_connectors/mooncake_transfer_engine_connector.md
- Mori Transfer Engine Connector: design/feature/omni_connectors/mori_transfer_engine_connector.md
+ - NIXL Connector: design/feature/omni_connectors/nixl_connector.md
- Shared Memory Connector: design/feature/omni_connectors/shared_memory_connector.md
- Yuanrong Store Connector: design/feature/omni_connectors/yuanrong_connector.md
- Yuanrong Transfer Engine Connector: design/feature/omni_connectors/yuanrong_transfer_engine_connector.md
@@ -159,6 +161,7 @@ nav:
- Cache-DiT: design/feature/cache_dit.md
- TeaCache: design/feature/teacache.md
- Diffusion Continuous Batching: design/feature/diffusion_continuous_batching.md
+ - Mooncake KV Transfer: design/feature/mooncake-kv-transfer.md
- Infrastructure and Performance:
- Prometheus Metrics: design/metrics.md
- Speech Generation Performance Optimizations: design/qwen3_omni_tts_performance_optimization.md
diff --git a/docs/README.md b/docs/README.md
index 90a8b6304..3733a9d2d 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -56,7 +56,7 @@ vLLM-Omni is flexible and easy to use with:
vLLM-Omni seamlessly supports most popular open-source models on HuggingFace, including:
- Omni-modality models (e.g. Qwen3-Omni, MiniCPM-o 4.5, Cosmos3, HunyuanImage, BAGEL)
-- TTS models (e.g. Qwen3-TTS, IndexTTS 2.5, dots.tts, CosyVoice3)
+- TTS models (e.g. Qwen3-TTS, IndexTTS 2.5, CosyVoice3)
- Diffusion models — image, video, and audio generation (e.g. MiniMax H3, LTX-2.5, SANA-Video, Wan2.2)
- Robot-policy and action models (e.g. π0, GR00T-N1.7, DreamZero-DROID, InternVLA-A1)
diff --git a/docs/api/README.md b/docs/api/README.md
index b2d4ea1ba..db760506e 100644
--- a/docs/api/README.md
+++ b/docs/api/README.md
@@ -5,6 +5,9 @@
Main entry points for vLLM-Omni inference and serving.
- [vllm_omni.entrypoints.async_omni.AsyncOmni][]
+- [vllm_omni.entrypoints.async_omni_base.AsyncOmniBase][]
+- [vllm_omni.entrypoints.duplex_omni.DuplexOmni][]
+- [vllm_omni.entrypoints.duplex_omni.DuplexSessionHandle][]
- [vllm_omni.engine.cfg_companion_tracker.CfgCompanionTracker][]
- [vllm_omni.entrypoints.cli.benchmark.base.OmniBenchmarkSubcommandBase][]
- [vllm_omni.entrypoints.cli.benchmark.main.OmniBenchmarkSubcommand][]
@@ -17,12 +20,13 @@ Main entry points for vLLM-Omni inference and serving.
## Clients
-Client libraries that talk to a running vLLM-Omni server (the full-duplex Realtime client and its per-model session presets).
+Client libraries for full-duplex sessions: the Realtime WebSocket client, the in-process client over `DuplexOmni`, and the per-model session presets.
- [vllm_omni.clients.duplex.AudioDelta][]
- [vllm_omni.clients.duplex.AudioFormat][]
- [vllm_omni.clients.duplex.ConnectionResumed][]
- [vllm_omni.clients.duplex.DuplexClient][]
+- [vllm_omni.clients.duplex.DuplexClientBase][]
- [vllm_omni.clients.duplex.DuplexClientError][]
- [vllm_omni.clients.duplex.DuplexConnectionError][]
- [vllm_omni.clients.duplex.DuplexEvent][]
@@ -44,6 +48,7 @@ Client libraries that talk to a running vLLM-Omni server (the full-duplex Realti
- [vllm_omni.clients.duplex.TextDelta][]
- [vllm_omni.clients.duplex.TranscriptDelta][]
- [vllm_omni.clients.duplex.WebSocketTransport][]
+- [vllm_omni.clients.inline_duplex.InlineDuplexClient][]
## Inputs
diff --git a/docs/assets/WeChat.jpg b/docs/assets/WeChat.jpg
index 0e91feee5..f55fa90f3 100644
Binary files a/docs/assets/WeChat.jpg and b/docs/assets/WeChat.jpg differ
diff --git a/docs/cli/bench/serve.md b/docs/cli/bench/serve.md
index a162c001a..450a01e5a 100644
--- a/docs/cli/bench/serve.md
+++ b/docs/cli/bench/serve.md
@@ -23,7 +23,9 @@ You can use `vllm bench serve --omni --help=all` to get descriptions of all para
The API endpoint exposed externally, to which clients send their requests.
- `--dataset-name`
- The name of the dataset used; random-mm indicates generating random multimodal inputs (images, videos, audio), while `omniinteract` replays official OmniInteract videos.
+ The name of the dataset used; `random-mm` generates random multimodal inputs (images, videos, audio),
+ `omniinteract` replays official OmniInteract videos, and `videomme` loads Video-MME MCQ videos
+ (`lmms-eval/Video-MME`, MiniCPM OmniEvalKit packing).
- `--num-prompts`
The total number of requests to send, an integer.
@@ -316,8 +318,12 @@ arrives, checkpointing the response's history position so a later committed user
outputs are ineligible and omitted from the official manifest; `audio_clipped_bytes` records output beyond the rounded video
horizon.
-TTFT, TTFP, and RTF start at client receipt of `response.created`. TPOT/ITL use engine stage-0 timing; ITL is emitted only when
-every token interval is present.
+Per-response TTFT and TTFP start when the server begins executing the native model-turn request that owns the response. RTF
+continues to use client receipt of `response.created` through the last audio packet, divided by emitted audio duration. Global
+TTFT, TTFP, and RTF cover the complete input-stream window. TPOT/ITL use engine stage-0 timing; ITL is emitted only when every
+token interval is present within a continuous generation segment. Model-unit pacing and gaps between generation segments are
+excluded from TPOT/ITL. Raw request metrics retain `response_created_to_first_text_ms` and
+`response_created_to_first_audio_ms` as client-envelope diagnostics.
The checked-in local performance configuration measures four deterministic cases from each OmniInteract subset (12 videos
total), with no benchmark warmups and a maximum concurrency of two. Each subset also sends one readiness request before its
@@ -339,6 +345,57 @@ transcript, event, and result artifacts without errors. A valid LISTEN-only case
chunks. Official-manifest eligibility is reported separately because clipped or cancelled output is a benchmark-quality
signal, not a transport failure. This local performance test does not score answer accuracy.
+### Video-MME Benchmark
+
+Video-MME (`--dataset-name videomme`) scores multiple-choice video QA. Default packing is
+OmniEvalKit MiniCPM `minicpm-frames` (up to 96 sampled frames as `image_url`). Pass a local
+mirror with `--dataset-path` / `--videomme-parquet` + `--videomme-video-dir`, or a Hugging
+Face dataset id (`lmms-eval/Video-MME` by default; any `org/name` is accepted when
+`--dataset-name videomme` is explicit). Relative `--videomme-video-dir` values are resolved
+to absolute `file://` URLs. For those URLs, start the server with
+`--allowed-local-media-path` covering the video root; otherwise use `--videomme-inline-local-video`.
+
+For MiniCPM-o 4.5, the default deployment allows 64 images per request. To run the
+96-frame recipe, start the server with a stage 0 override (replace `/path/to/Video-MME`
+with the local video root shared by the benchmark client and server):
+
+```bash
+vllm serve openbmb/MiniCPM-o-4_5 --omni \
+ --allowed-local-media-path /path/to/Video-MME \
+ --stage-overrides '{"0":{"limit_mm_per_prompt":{"image":96,"audio":64,"video":1}}}'
+```
+
+The override applies to this server invocation only. Alternatively, use
+`--videomme-max-frames 64` in the benchmark to stay within the default image limit;
+this evaluates a different frame-sampling setting from the 96-frame recipe.
+
+Run the benchmark against that server:
+
+```bash
+vllm bench serve --omni \
+ --backend openai-chat-omni \
+ --dataset-name videomme \
+ --dataset-path /path/to/Video-MME \
+ --videomme-pack-mode minicpm-frames \
+ --videomme-max-frames 96 \
+ --model openbmb/MiniCPM-o-4_5 \
+ --endpoint /v1/chat/completions \
+ --num-prompts 8 \
+ --max-concurrency 1 \
+ --save-result
+```
+
+Accuracy keys (`videomme_accuracy`, per-duration / domain / task breakdowns) are written into
+the saved JSON. `videomme_accuracy` excludes HTTP failures; `videomme_accuracy_incl_http_fail`
+counts them as wrong. `videomme_submitted` / `videomme_unique_question_ids` show when
+oversampling made the request count differ from unique-question coverage.
+`videomme_skipped_rows` counts rows skipped during sampling because of missing or unreadable
+media or invalid question fields. The opt-in accuracy runner rejects skipped rows, HTTP
+failures, duplicate question IDs, and missing gold answers. This checks the sampled subset;
+it does not establish that a supplied local mirror contains the entire official dataset.
+Use `--videomme-save-eval-items` (or `VIDEOMME_SAVE_EVAL_ITEMS=1`) for per-request
+`videomme_eval_items` rows.
+
### Multi-Modal Benchmark
diff --git a/docs/cli/serve.md b/docs/cli/serve.md
index cf3d068a6..f77503091 100644
--- a/docs/cli/serve.md
+++ b/docs/cli/serve.md
@@ -1,5 +1,67 @@
# vllm-omni serve
+## Multiple API frontends
+
+For local EngineCore pipelines with one or more stages, `--api-server-count` starts
+multiple API frontend processes that share one parent-owned set of stage
+engines:
+
+```bash
+vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni \
+ --api-server-count 2 \
+ --port 8091
+```
+
+This mode is intended for workloads whose request parsing, multimodal input
+processing, or response encoding can bottleneck a single frontend. It
+currently requires local multi-process EngineCore stages with one local
+process group per replica. A stage may still use multiple `num_replicas`.
+It cannot be combined with diffusion, headless or
+remote stages, intra-stage data parallelism, Ray, fault tolerance, elastic
+expert parallelism, sleep mode, or runtime LoRA updating. Runtime voice upload
+and deletion are also disabled because those mutations are process-local;
+built-in voices, inline reference audio, and voices restored at startup remain
+available. The `/v1/omni/sleep` and `/v1/omni/wakeup` control routes return HTTP
+409 for the same reason: their bookkeeping is process-local while stage engines
+are shared.
+
+A single-stage model pipeline is supported through the normal launch command;
+this is distinct from the distributed `--stage-id` / `--headless` launch mode.
+Every stage in the pipeline must be an EngineCore stage. Mixed AR-to-DiT
+pipelines, including MiniMax-H3, remain unsupported even when their first
+stage is an EngineCore stage. Diffusion support requires per-frontend channels
+and shared-engine lifecycle handling in the diffusion backend.
+
+### Workloads and performance validation
+
+Each API process owns its frontend processor, Orchestrator, request state,
+and per-replica EngineCore channels. Requests return through the channel of
+the originating API process; model weights and GPU stage engines are shared.
+This distributes frontend and orchestration work across CPU processes.
+
+| Workload | Potential benefit | Limitation |
+| ---------- | ------------------- | ------------ |
+| Concurrent cold image/video inputs on supported EngineCore pipelines | Parallel frontend processing when one API process is CPU-limited | A single cold request and GPU computation may be unchanged |
+| Many replicas producing frequent text/audio outputs | Less API/Orchestrator work and GIL contention per process | Routing remains serial within each Orchestrator |
+| Warm inputs or low-concurrency, GPU-limited generation | Additional frontend capacity | More API processes may add overhead without improving throughput |
+
+Here, a cold input means media or preprocessing caches miss, not that model
+weights must be loaded or GPU kernels compiled. Each frontend has its own
+processor/cache state, so cache duplication, CPU thread contention, and memory
+usage also matter. Multi-API serving does not itself change the output queue,
+move response encoding to an executor, or change the model's numerical scheduler.
+
+Compare `--api-server-count 1`, `2`, and higher counts with the same hardware,
+model/stage configuration, media, sampling parameters, output lengths, and
+offered load. Keep cold-cache and warmed-cache runs separate and report repeated
+measurements. Record CPU and request counts for every API worker, input and
+output queue delay, GPU utilization, first-output latency, and end-to-end latency.
+Confirm requests reach every worker: connection reuse can concentrate traffic
+on one process. Queue-only or single-API experiments do not establish a
+multi-API throughput gain. The multi-replica workload in
+[#4680](https://github.com/vllm-project/vllm-omni/issues/4680) remains a relevant
+validation target.
+
## Stage-based CLI quickstart
The stage-based CLI is designed for deployments that require launching each pipeline stage in an isolated process
@@ -61,3 +123,10 @@ CUDA_VISIBLE_DEVICES=1 vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni \
## Arguments
--8<-- "docs/generated/argparse_omni/omni_serve.inc.md"
+
+Async video jobs use process-local metadata and task handles. With multiple API
+workers, creating, listing, retrieving, downloading, and deleting these jobs
+returns HTTP 409. This restriction is independent of the current diffusion
+stage launch restriction: supporting multi-worker diffusion also requires a
+shared job lifecycle. Missing frontend topology state returns HTTP 503 for
+operations that require process-local state protection.
diff --git a/docs/configuration/environment_variables.md b/docs/configuration/environment_variables.md
index 90b9ffae0..db204bdd5 100644
--- a/docs/configuration/environment_variables.md
+++ b/docs/configuration/environment_variables.md
@@ -70,6 +70,7 @@ The lifecycle labels used below are:
| `OMNI_DIFFUSION_PROMPT_EMBED_CACHE_SIZE` | Positive integer; default `32` entries | Each diffusion runner; resolved during model setup | A valid environment value overrides the explicit cache size. A non-integer logs a warning; a non-positive value is ignored. | Experimental |
| `OMNI_DIFFUSION_SESSION_STATE_MANAGER` | Boolean with the same accepted spellings as the prompt cache; default disabled | Experimental diffusion session manager; model setup | A recognized environment value overrides the explicit enable setting. An unrecognized value is ignored. | Experimental |
| `OMNI_DIFFUSION_SESSION_STATE_MANAGER_MAX_SESSIONS` | Positive integer; default `64` | Experimental diffusion session manager; model setup | A valid environment value overrides the explicit maximum. A non-integer logs a warning; a non-positive value is ignored. | Experimental |
+| `VLLM_OMNI_AR_DIFFUSION_KV_GATHER` | `1` enables; default `0` (off) | AR-diffusion KV manager at allocation and attention at dispatch; set before worker startup | Only the exact value `1` enables contiguous KV gathering. History staging additionally requires `ARDiffusionKVConfig.reuse_history_staging`. | Experimental |
Backend names for `DIFFUSION_ATTENTION_BACKEND` are the members of
`DiffusionAttentionBackendEnum`, such as `FLASH_ATTN`, `TORCH_SDPA`,
@@ -82,6 +83,7 @@ depends on the installed kernels and model path.
| --- | --- | --- | --- | --- |
| `SPEAKER_SAMPLES_DIR` | Filesystem path; default `~/.cache/vllm-omni/speakers` | Speech server; read when speaker storage initializes | Environment-only setting. The directory is created; filesystem errors propagate. | Stable |
| `SPEAKER_MAX_UPLOADED` | Integer; default `1000` | Speech server; read when speaker storage initializes | Environment-only setting. A non-integer logs a warning and uses `1000`; range is not otherwise validated. | Stable |
+| `VLLM_OMNI_ABORT_TIMEOUT` | Float seconds; default `2` | Engine abort wait for Videos DELETE and `generate()` cancel/error cleanup; read when `async_omni` imports | Environment-only setting. A non-float raises `ValueError` during import. | Experimental |
| `VLLM_OMNI_ASYNC_OUTPUT_TIMEOUT` | Float seconds; default `600` | Diffusion engine async-output wait in `step_streaming`; resolved per call on the request path, not at import | Environment-only setting. A non-float or `<=0` value warns once and uses the default. | Experimental |
| `VLLM_OMNI_EVENT_DRIVEN_ORCH` | `1`, `true`, `yes` or `on` enables; default `0` (off) | Orchestration loop and the serving-side final-output drain; read once when the `Orchestrator` is constructed | Environment-only setting. Values are stripped and case-normalized; any unrecognized value leaves the legacy poll loop selected. | Experimental |
| `VLLM_OMNI_INPUT_WAIT_TIMEOUT_S` | Float seconds; default `600`; `<=0` disables | Full-payload input coordinator, not async-chunk transfer; read when the scheduler module imports in each worker | Environment-only setting. A non-float logs a warning and uses `600`. | Stable operational control |
@@ -90,6 +92,13 @@ depends on the installed kernels and model path.
| `VLLM_VIDEO_ASYNC_CHUNK` | `on` or `off`; default `on` | Streaming video output; read on attribute access | Environment-only setting. Values are trimmed and case-normalized; an invalid value warns once and uses `on`. | Experimental |
| `VLLM_VIDEO_AUDIO_DELTA_MODE` | `fast` or `slow`; default `fast` | Streaming video audio deltas; read on attribute access | Environment-only setting. Values are trimmed and case-normalized; an invalid value warns once and uses `fast`. | Experimental |
+### NIXL stage transfer
+
+| Name | Type and default | Applies to and read time | Precedence and invalid values | Lifecycle |
+| --- | --- | --- | --- | --- |
+| `VLLM_OMNI_NIXL_LEASE_S` | Float seconds; default `3600` | Unclaimed producer payload expiry; connector construction | Nonempty environment value overrides `lease_seconds`; empty falls back to config/default. Invalid floats raise `ValueError`. Claimed READ allocations never expire by time. | Experimental |
+| `VLLM_OMNI_NIXL_XFER_TIMEOUT_S` | Float seconds; default `300` | Receiver READ wait; connector construction | Nonempty environment value overrides `transfer_timeout_s`; empty falls back to config/default. Invalid floats raise `ValueError`. Timeout does not cancel DMA or release active allocations. | Experimental |
+
### Server storage
Storage names use Pydantic's nested-settings delimiter (`__`). They are read
@@ -112,6 +121,7 @@ settings.
| Name | Type and default | Applies to and read time | Precedence and invalid values | Lifecycle |
| --- | --- | --- | --- | --- |
+| `VLLM_OMNI_FUSED_QK_NORM_ROPE_MIN_TOKENS` | Non-negative integer; unset or empty means each consumer's own default (Boogu-Image: `2048`) | Token gate of the shared `fused_qk_norm_rope` op: a rotary table is packed for the fused kernel only when it spans at least this many positions (`B*S`); read at forward time | Environment-only. `0` = always fuse; a very large value disables the fused path. Any other value raises `ValueError` on the first forward. The crossover is host-dependent; re-benchmark before overriding. | Experimental performance control |
| `VLLM_OMNI_SKIP_NVFP4_NAN_CLAMP` | Boolean truthy spellings: `1`, `true`, `yes`, `on`; default false | ModelOpt NVFP4 compatibility patch; read when `vllm_omni.patch` imports | Environment-only escape hatch. Any other value means false. Set only to diagnose the upstream NaN-scale issue. | Diagnostic and temporary |
| `VLLM_OMNI_USE_QUACK_FP8` | Boolean truthy spellings: `1`, `true`, `yes`, `on`; unset means hardware auto-detection | FP8 scaled matrix multiplication; evaluated when quack capability is selected | A set value overrides auto-detection. Any non-truthy value forces quack off. If quack cannot load, vLLM-Omni warns and falls back to FlashInfer. | Experimental performance control |
diff --git a/docs/configuration/stage_configs.md b/docs/configuration/stage_configs.md
index 855390739..2540b19d9 100644
--- a/docs/configuration/stage_configs.md
+++ b/docs/configuration/stage_configs.md
@@ -17,6 +17,7 @@ Common `PipelineConfig` fields include:
| ------- | ------------- |
| `model_type` | Pipeline identifier used during model and config resolution. |
| `default_deploy_config_name` | Bundled deploy YAML loaded when the user does not pass `deploy_config`. |
+| `duplex_plugin` | Dotted path of the model's `DuplexModelPlugin`. Set only for full-duplex models; it makes `vllm-omni serve` run the model through `DuplexOmni` (a server whose every surface runs on a duplex session) and the engine host a `DuplexOrchestrator` with the plugin loaded. |
| `model_arch` | Default Hugging Face architecture for the pipeline. |
| `hf_architectures` | Architecture names used to identify checkpoints whose `model_type` is shared. |
| `hf_config_predicate` | Optional predicate used to select between pipelines with otherwise identical HF metadata. |
@@ -38,7 +39,7 @@ Common `StagePipelineConfig` fields include:
| `custom_process_input_func` | Processor applied to this stage's incoming payload. |
| `custom_process_next_stage_input_func` | Processor used for full-payload handoff to the next stage. |
| `async_chunk_process_next_stage_input_func` | Processor used for async chunk handoff. |
-| `sampling_constraints` | Model-owned sampling constraints that deploy defaults cannot override. |
+| `sampling_constraints` | Model-owned sampling constraints that deploy defaults cannot override. Scalar values replace deploy defaults; required `stop_token_ids` extend and deduplicate them. |
To add or change topology, define and register a new pipeline variant. Use
deploy YAML only for runtime placement, resource sizing, connectors, and other
@@ -52,7 +53,7 @@ The new deploy schema lives under `vllm_omni/deploy/` and is paired with a froze
| ------- | ------ | ---------- | --------- | ------------- |
| `base_config` | str (path) | optional | — | Overlay parent (relative or absolute). `stages:` / `platforms:` deep-merged by stage_id; other scalars overlay-wins. Intended for user-authored overlays; prod yamls stay flat. |
| `async_chunk` | bool | optional | `true` | Enable chunked streaming between stages. Pin to `false` if the pipeline runs end-to-end. |
-| `session_mode` | str | optional | `"turn"` | Session behavior. MiniCPM-o 4.5 deploy YAMLs set `"duplex"` so `/v1/realtime` and chat share the same profile. |
+| `session_mode` | str | optional | `"turn"` | Session behavior. For pipelines with a `duplex_plugin`, explicitly select `"duplex"` for the duplex engine or `"turn"` for the ordinary online serving stack. The shipped MiniCPM-o 4.5 default remains `"duplex"`. See [Full Duplex](../serving/full_duplex_api.md#enable-full-duplex). |
| `active_stream_window` | int | optional | `0` | Number of active downstream stream slots; `0` preserves all-stream cycling. |
| `duplex_session` | dict | optional | runtime defaults | Full-duplex session lifecycle, buffering, replay, and capacity limits. |
| `connectors` | dict | optional | `null` | Named connector specs (`{name, extra}`). Referenced by each stage's `input_connectors` / `output_connectors`. See [Connector schema](#connector-schema). |
@@ -97,7 +98,7 @@ Each entry under `stages:` accepts any `StageDeployConfig` field directly (no ne
| `devices` | str \| null | optional | `null` | Device list assigned to this stage. The number of device ids must equal this stage's local world size (`tensor_parallel_size` × local data-parallel size × `pipeline_parallel_size`, or `num_replicas` × that product for a replica pool); a mismatch fails early — see the note below. |
| `output_connectors` | dict \| null | optional | `null` | Keyed by `to_stage_`; values are names registered under top-level `connectors:`. |
| `input_connectors` | dict \| null | optional | `null` | Keyed by `from_stage_`; values are names registered under top-level `connectors:`. |
-| `default_sampling_params` | dict \| null | optional | `null` | Baseline sampling params. Deep-merged with pipeline `sampling_constraints` (pipeline wins). |
+| `default_sampling_params` | dict \| null | optional | `null` | Baseline sampling params. Merged with pipeline `sampling_constraints`; scalar constraints win, while required `stop_token_ids` are appended and deduplicated. |
| `engine_extras` | dict | optional | `{}` | Catch-all for engine fields not listed above; deep-merged across overlays and forwarded to the stage engine. |
**Note:** a stage's `devices` count must equal its local world size (`tensor_parallel_size` × `data_parallel_size_local` × `pipeline_parallel_size`, falling back to global `data_parallel_size` when the local size is unset), or `num_replicas` × that product for a replica pool. A mismatch fails early and names the offending stage. A top-level `--tensor-parallel-size` is broadcast to every stage, so it can make a single-GPU stage violate this contract ([issue #5003](gh-issue:5003)); fix that case with `--stage-overrides` (set `tensor_parallel_size` and `devices` together per stage) or set TP only on the multi-GPU stage.
@@ -289,3 +290,90 @@ vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 --deploy-config /path/to/depl
!!! important
We are actively iterating on the definition of deployment configurations, and we welcome feedback from users and developers.
+
+## Qwen3-TTS with Model Runner V2
+
+Qwen3-TTS can opt into the native CUDA Model Runner V2 pipeline on vLLM
+0.29.0. Select one of these deployment profiles:
+
+| Profile | Runner | Code2Wav graph batches | Intended use |
+| --- | --- | --- | --- |
+| `qwen3_tts.yaml` | V1 | Existing defaults | Existing deployment / regression control |
+| `qwen3_tts_mrv2.yaml` | V2 | B1 | Native runner validation |
+| `qwen3_tts_high_concurrency_mrv2.yaml` | V2 | B1, B2 | Opt-in throughput tuning |
+| `qwen3_tts_high_concurrency_mrv2_b4.yaml` | V2 | B1, B2, B3, B4 | Experimental throughput / buffered playback |
+
+```bash
+vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-Base --omni \
+ --deploy-config vllm_omni/deploy/qwen3_tts_mrv2.yaml
+```
+
+The V2 profiles bound Talker prefill to 512 tokens per step and select the
+Talker AR runner and the Code2Wav generation
+runner together. Native inter-stage delivery carries codec payloads directly;
+request-owned snapshots preserve buffers through asynchronous completion and
+CUDA graph reuse. Terminal completion waits for upstream stage metrics before
+releasing request state. Platform sections retain V1 on NPU, XPU, ROCm and MUSA;
+this change does not qualify MRV2 on those backends or enable other model families.
+Selecting V2 for a stage without `supports_native_mrv2_data_plane` emits a warning
+when pipeline and deployment settings are merged. That stage retains the legacy
+transport path; the warning does not establish support for that combination.
+
+MRV2 model hooks use capability declarations rather than architecture or stage
+names. Omni lifecycle flags select the model state, and `_returns_tuple` declares
+the capture output contract. The optional batched predictor hook is `mtp`, with
+an explicit `mtp_output_key` (a string or two-part payload key), optional
+`mtp_validity_key`, and `mtp_graph_safe`/`mtp_disable_graph` capture controls.
+Its inputs are token IDs, embeddings, previous hidden states and per-row
+conditioning; it returns updated embeddings and prediction codes. Models may
+supply `mtp_sampling_params` and `get_mtp_seed(sampling_params)` for model-local explicit seeds, and declare
+`mtp_accepts_per_row_generators`, `mtp_accepts_req_infos`, or `mtp_sample_uniforms`
+(with `mtp_sample_steps` and `mtp_sample_vocab_size`) as needed. Qwen3-TTS retains
+its existing `talker_mtp` entry point for V1.
+
+### Included performance work
+
+- Request snapshots have a fast path for immutable scalar leaves, including
+ waveform lists. Mutable containers, aliases and cycles keep deep-copy
+ semantics. This is not the historical shallow `dict(prompt)` experiment.
+- The API reference cache holds owned float32 arrays with a default capacity
+ of 1024 entries and a 512 MiB waveform-payload budget. Cache hits return
+ independent lists. Qwen3-TTS model artifact caches also default to 1024
+ entries. These caches have different owners and eviction policies; equal
+ entry limits do not make them a single coherent cache.
+- Talker state stays on GPU where its lifecycle allows it. BOS/EOS projections
+ are cached in their projection dtype and invalidated on weight loading.
+- Code2Wav packs CPU codec inputs before device transfer. Optional B2/B4 graph
+ buckets batch compatible requests without sharing their per-request state.
+- Native output materialization runs in a bounded worker and drains before
+ closing the data plane. The existing control-only shortcut avoids launching
+ model work for a step that has only lifecycle events.
+
+MTP prefix re-prefill remains disabled in the high-concurrency V2 profile.
+Adding more graph shapes increases compilation cost and has not established a
+stable end-to-end gain for this extracted version.
+
+### Decoder batches and playback
+
+Changing `decode_cudagraph_batch_sizes` selects the captured batch buckets.
+Keep `decode_batch_max_size` consistent with the intended maximum too; the
+stateful graph path currently groups according to the captured buckets, while
+the stateless path also uses the explicit maximum.
+
+B4 remains experimental. First-packet latency alone does not establish
+uninterrupted playback. Validate inter-chunk arrival times, buffering, WER and
+speaker similarity before adopting either batching preset for a production
+workload. Floating-point decoder outputs can differ across batch sizes; this PR
+does not claim bitwise or quality equivalence.
+
+### Optional MPS deployment
+
+NVIDIA MPS is an optional operator setting for colocated CUDA processes, not a
+YAML option or a library default. This PR does not establish a throughput or
+first-packet latency benefit from MPS. Measure the exact deployment with and
+without MPS before enabling it.
+
+Use only assigned GPUs and an independent MPS pipe directory. A private MPS
+server does not provide exclusive GPU ownership or MIG isolation. For a
+single-GPU deployment, explicitly place both stages on that GPU; the supplied
+high-concurrency profile places its two stages on different GPUs by default.
diff --git a/docs/contributing/ci/ci_settings.md b/docs/contributing/ci/ci_settings.md
index 885ebd528..4032891c0 100644
--- a/docs/contributing/ci/ci_settings.md
+++ b/docs/contributing/ci/ci_settings.md
@@ -15,7 +15,8 @@ Canonical layout (prefer these paths for new changes):
│ │ ├── skip_ci.py # skip-ci decision (docs / skip-mark / CI YAML paths)
│ │ ├── upload_pipeline.py # Bootstrap + test-pipeline uploader (CUDA/NPU)
│ │ └── resolve_skip_ci.sh # Shell helpers for AMD/Intel bootstrap
-│ └── ci_mirror_hardwares.yml # CUDA uploader presets (referenced by name only)
+│ ├── ci_mirror_hardwares.yml # CUDA/NPU hardware presets (referenced by name only)
+│ └── ci_source_file_dependencies.yml # L2–L5 path-prefix presets (referenced by name only)
├── cuda/ # Primary NVIDIA CUDA CI
│ ├── pipeline.yml # Bootstrap entry (hook upload)
│ ├── bootstrap-upload-steps.yml # Bootstrap child steps (upload_pipeline --upload)
@@ -34,6 +35,7 @@ Canonical layout (prefer these paths for new changes):
├── amd/
│ ├── test-amd-ready.yml # L2 job definitions (template input)
│ ├── test-amd-merge.yml # L3 job definitions
+│ ├── test-amd-nightly.yml # Nightly full-model jobs (experimental)
│ ├── test-template-amd-omni.j2 # Renders final pipeline.yaml
│ └── scripts/
│ ├── bootstrap-amd-omni.sh # Entry: skip-ci → Jinja → upload
@@ -63,7 +65,7 @@ Canonical layout (prefer these paths for new changes):
| -------- | ---------------- | -------------- | ---------------- | -------------------- |
| **CUDA** | `cuda/pipeline.yml` | `test-ready.yml`, `test-merge.yml`, `test-nightly.yml`, `test-weekly.yml` | `upload_pipeline.py --upload` (expands uploader-only keys) | omit `mirror_hardwares` (from `-m`) / preset string + `MIRROR_HW` |
| **NPU** | `npu/pipeline-npu.yml` | `test-npu-ready.yml`, `test-npu-nightly.yml` | `upload_pipeline.py --upload` | `mirror_hardwares: a2b3_npu_1` / `a2b3_npu_4` / `a3_npu_2` |
-| **AMD** | `amd/scripts/bootstrap-amd-omni.sh` | `test-amd-ready.yml`, `test-amd-merge.yml` | Jinja (`test-template-amd-omni.j2`) → `pipeline upload` | `agent_pool` + `mirror_hardwares: [amdproduction]` (array, template filter) |
+| **AMD** | `amd/scripts/bootstrap-amd-omni.sh` | `test-amd-ready.yml`, `test-amd-merge.yml`, `test-amd-nightly.yml` | Jinja (`test-template-amd-omni.j2`) → `pipeline upload` | `agent_pool` + `mirror_hardwares: [amdproduction]` (array, template filter) |
| **Intel** | `intel/scripts/bootstrap-intel-omni.sh` | `intel/pipeline-intel.yml` (steps inline) | Direct `pipeline upload` | Inline `agents.queue` on each step |
## Platform configuration style
@@ -108,19 +110,21 @@ Canonical layout (prefer these paths for new changes):
Inferred form: unset/`empty` `MIRROR_HW` matches `H100` or `L4` in `-m` (both present → H100); no match skips the step. `MIRROR_HW=b200` must appear in `-m` or the step is skipped. The uploader does **not** rewrite pytest `-m`; B200 collection comes from the YAML expression and from tests that declare `B200` in `hardware_test` / `hardware_marks` (for example `res={"cuda": ["H100", "B200"]}`). `MIRROR_HW` must be empty or `b200` (case-insensitive); unknown values (for example `b20o`) **fail the upload**. A CUDA preset string such as `mirror_hardwares: h100_4` is skipped when `MIRROR_HW=b200`. Unset `MIRROR_HW` keeps string presets. NPU presets ignore `MIRROR_HW`. A `mirror_hardwares` name that is not a key in `ci_mirror_hardwares.yml` **fails the upload**.
+ **Path filter in YAML:** `source_file_dependencies: ` (string, or a list of preset names)—preset names in [`common/ci_source_file_dependencies.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/common/ci_source_file_dependencies.yml). Required on **E2E Test** leaf jobs in L2/L3, and on L4/L5 leaf jobs that should be diff-gated on PR labels; see [Step filtering](#step-filtering).
+
**Conventions**
- **`depends_on`:** leaf jobs depend on `upload-ready-pipeline`, `upload-merge-pipeline`, etc.
- **`group` / `label`:** `:card_index_dividers:` groups; labels like `Diffusion · Qwen Image Test`.
- **`commands`:** `timeout … pytest …` with markers and `--run-level` for the pipeline level.
- - **`source_file_dependencies`:** required on **E2E Test** leaf jobs in L2/L3; see [Step filtering](#step-filtering).
+ - **`source_file_dependencies`:** required on **E2E Test** leaf jobs in L2/L3, and on L4/L5 leaf jobs that should be diff-gated on PR labels; name a job key from `ci_source_file_dependencies.yml` (see [Step filtering](#step-filtering)). The key must list every script the step runs; `commands` is not scanned.
**Adding a job**
1. Pick the level file (`test-ready.yml` / `test-merge.yml` / `test-nightly.yml` / `test-weekly.yml`).
2. Add a step under the right **group** (usually **E2E Test** for model pytest).
- 3. Set `label`, `commands`, `mirror_hardwares`, `depends_on: upload-ready-pipeline` (or `upload-merge-pipeline` / `upload-nightly-pipeline` / `upload-weekly-pipeline`).
- 4. For L2/L3 E2E, add `source_file_dependencies` (pytest + model + deploy YAML prefixes).
+ 3. Set `label`, `commands`, `mirror_hardwares`, `depends_on: upload--pipeline`.
+ 4. For L2–L5 jobs that should be diff-gated, add a job key in `common/ci_source_file_dependencies.yml` and set `source_file_dependencies: ` on the step. YAML anchors hold model business-code paths only. The job key (`{model}_function` / `_perf` / `_accuracy` / `_reliability` / `_doc` / `_cov`) aliases that anchor and lists the pytest / example / benchmark scripts the step actually runs. The uploader does **not** parse `commands` for path filters—omitting a script from the key means editing that script will not select the job.
5. Dry-run:
```bash
@@ -144,6 +148,8 @@ Canonical layout (prefer these paths for new changes):
- A5: `a5_npu_2`, `a5_npu_4`, `a5_npu_8`, `a5_npu_16`
- 310P: `310p_npu_1`, `310p_npu_2`, `310p_npu_4`
+ **Path filter in YAML:** same `source_file_dependencies` presets as CUDA, on **L4** leaf jobs in `test-npu-nightly.yml`. Filtering applies on the PR `nightly-test` label; `main` + `NIGHTLY=1` keeps every job. If no job-key prefix matches, a change to the pipeline YAML being uploaded, or to any path under `source_filter_fallback`, keeps every job in that upload. See [Step filtering](#step-filtering).
+
**Conventions**
- **`depends_on: upload-ready-pipeline`** (or `upload-nightly-pipeline`) ties jobs to bootstrap upload keys.
@@ -153,7 +159,7 @@ Canonical layout (prefer these paths for new changes):
1. Edit `test-npu-ready.yml` (L2) or `test-npu-nightly.yml` (L4).
2. Add a step with `mirror_hardwares` (add a new preset in `ci_mirror_hardwares.yml` first if needed).
- 3. Set `commands` to your pytest file and markers.
+ 3. Set `commands` to your pytest file and markers. For L4, set `source_file_dependencies` to a job key from `ci_source_file_dependencies.yml` that aliases the model anchor and lists that pytest file.
4. Dry-run:
```bash
@@ -166,9 +172,9 @@ Canonical layout (prefer these paths for new changes):
**Bootstrap:** [`amd/scripts/bootstrap-amd-omni.sh`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/amd/scripts/bootstrap-amd-omni.sh)—skip-ci, diff filtering, Jinja render, then `buildkite-agent pipeline upload`.
- **Test YAML (data):** `amd/test-amd-ready.yml` (L2 / `ready`), `test-amd-merge.yml` (L3 / `merge-test` and `main`). On a PR carrying both labels, AMD combines both suites behind one image build. PR builds without either tier label retain the legacy L2 fallback for compatibility with the `amd-test` trigger. `DEBUG_TEST_YAML` remains an explicit override. AMD has no L4 file yet, so `nightly-test` does not select an additional suite.
+ **Test YAML (data):** `amd/test-amd-ready.yml` (L2 / `ready`), `test-amd-merge.yml` (L3 / `merge-test` and ordinary `main`), and experimental `test-amd-nightly.yml` (`full_model` / `nightly-test` or `main` with `NIGHTLY=1`). Multiple PR tier labels combine their suites behind one image build. PR builds that reach the bootstrap without a tier label retain the legacy ready-suite fallback. `DEBUG_TEST_YAML` accepts `ready`, `merge`, and `nightly` as an explicit override. During nightly burn-in every AMD nightly leaf is non-blocking.
- **Trigger boundary:** AMD label handling has two layers. First, the external `vllm-omni-amd-ci` Buildkite pipeline condition decides whether a GitHub label event creates a build. Only after that build starts does this repository's bootstrap inspect all current PR labels and select L2, L3, or both. Repository-side selection therefore cannot make a `merge-test` event start AMD CI by itself. The external condition must admit both `ready` and `merge-test`, while preserving the legacy `amd-test` trigger and non-PR `main` / scheduled builds. For PR builds without a `DEBUG_TEST_YAML` override, an unreadable label set fails the bootstrap instead of silently selecting the wrong tier. Debug overrides skip label lookup and are validated directly; invalid values, including whitespace-only values, fail the bootstrap. Keep `nightly-test` out of the AMD condition until an AMD L4 suite exists; otherwise it would start a build without providing nightly coverage.
+ **Trigger boundary:** AMD label handling has two layers. First, the external `vllm-omni-amd-ci` Buildkite pipeline condition decides whether a GitHub label event creates a build. Only after that build starts does this repository's bootstrap inspect all current PR labels and select the ready, merge, nightly, or combined suites. Repository-side selection therefore cannot start AMD CI by itself. AMD-only nightly validation uses both `amd-test` and `nightly-test`: the external condition should accept an event for either label only when the complete PR label set contains both. `amd-test` admits the AMD pipeline, while `nightly-test` makes the bootstrap select `NIGHTLY_TESTS:test-amd-nightly.yml`. Existing `ready` / `merge-test` behavior and non-PR `main` / scheduled builds remain separate. For PR builds without a `DEBUG_TEST_YAML` override, an unreadable label set fails the bootstrap instead of silently selecting the wrong tier. Debug overrides skip label lookup and are validated directly; invalid values, including whitespace-only values, fail the bootstrap. Until the paired-label condition is configured, use an authorized manual Buildkite build with `DEBUG_TEST_YAML=nightly` and verify that the bootstrap reports only the nightly suite.
**Rendering:** [`test-template-amd-omni.j2`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/amd/test-template-amd-omni.j2) wraps data steps with `amd-build` image build and `amd_` queues. Do **not** hand-edit generated `pipeline.yaml`.
@@ -184,7 +190,7 @@ Canonical layout (prefer these paths for new changes):
**Adding a job**
- 1. Edit `test-amd-ready.yml` or `test-amd-merge.yml`.
+ 1. Edit `test-amd-ready.yml`, `test-amd-merge.yml`, or `test-amd-nightly.yml`.
2. Copy a neighboring block: `label`, `agent_pool`, `mirror_hardwares`, `commands`, optional `grade`.
3. Regenerate via bootstrap / Jinja; update `skip_ci.py` if you add a new YAML path.
@@ -213,7 +219,7 @@ PR diffs drive **two independent skip layers**. Both read changed files from git
| Layer | Script | When | What is skipped | Where you configure |
| ----- | ------ | ---- | --------------- | ------------------- |
| **Bootstrap** | [`skip_ci.py`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/common/scripts/skip_ci.py) + [`upload_pipeline.py`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/common/scripts/upload_pipeline.py) | Before child test pipelines upload (`cuda/pipeline.yml`, `npu/pipeline-npu.yml`, AMD/Intel bootstraps) | Entire default CI, or whole L2/L3 upload for a platform | Whitelists in `skip_ci.py`; bootstrap `if` injected by step `key` in `upload_pipeline.py` |
-| **Step filter** | [`upload_pipeline.py`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/common/scripts/upload_pipeline.py) | While uploading CUDA L2/L3 YAML | Individual Buildkite steps inside `test-ready.yml` / `test-merge.yml` | `source_file_dependencies` on each step or group |
+| **Step filter** | [`upload_pipeline.py`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/common/scripts/upload_pipeline.py) | While uploading CUDA L2–L5 YAML | Individual Buildkite steps inside `test-ready.yml` / `test-merge.yml` / `test-nightly.yml` / `test-weekly.yml` | `source_file_dependencies` key on each step (presets in `ci_source_file_dependencies.yml`) |
**Changed files** (both layers):
@@ -240,7 +246,7 @@ Label triggers (`ready`, `merge-test`) are unchanged—diff-aware logic only red
| Non-qualifying skip-mark and no CI YAML | normal CI | all on |
| Diff unavailable | normal CI | all on |
-**`skip_all` exceptions (CUDA/NPU bootstrap only):** PR labels (`nightly-test`, `merge-test`, `weekly-test`, `ready`, …) do **not** revive jobs under `skip_all`. On `main`, scheduled `NIGHTLY=1` still builds the image and uploads L4; `WEEKLY=1` or `NON_CRITICAL=1` still builds the image and uploads L5 (CUDA). `WEEKLY=1` on `main` also uploads L2/L3 with `--e2e`.
+**`skip_all` exceptions:** For CUDA/NPU, PR labels (`nightly-test`, `merge-test`, `weekly-test`, `ready`, …) do **not** revive jobs under `skip_all`; on `main`, scheduled `NIGHTLY=1` still builds the image and uploads L4, while `WEEKLY=1` or `NON_CRITICAL=1` still builds the image and uploads L5 (CUDA). `WEEKLY=1` on `main` also uploads L2/L3 with `--e2e`. AMD applies `skip_ci.py gate` only to selected ready/merge specs, so an explicitly selected AMD nightly spec (`nightly-test`, `NIGHTLY=1`, or a nightly debug override) remains runnable even for a docs-only diff.
**Yaml-gated nightly/weekly:** L4/L5 upload steps keep their normal label / `NIGHTLY` / `WEEKLY` / `NON_CRITICAL` conditions (for example PR `nightly-test` still uploads nightly). Only L2/L3 upload steps are matrix-gated.
@@ -257,6 +263,7 @@ Register new files in `L2_YAML_FILES`, `L3_YAML_FILES`, or `L45_YAML_FILES` in `
| L3 | `.buildkite/cuda/test-merge.yml` | cuda |
| L3 | `.buildkite/amd/test-amd-merge.yml` | amd |
| L4/L5 | `.buildkite/cuda/test-nightly.yml`, `test-weekly.yml` | cuda |
+| L4/L5 | `.buildkite/amd/test-amd-nightly.yml` | amd |
| L4/L5 | `.buildkite/npu/test-npu-nightly.yml` | npu |
##### Yaml-gated category branches
@@ -289,7 +296,7 @@ Docs/skip-mark mixed with any row below follows the same matrix.
| CUDA ready + AMD merge | G4 | `cuda/l2` + `amd/l3` |
| CUDA ready + AMD ready | G2 | `cuda/l2` + `amd/l2` |
| CUDA merge + AMD merge | G3 | `cuda/l3` + `amd/l3` |
-| Only nightly / weekly / npu-nightly YAML | G1 | (none) |
+| Only nightly / weekly / amd-nightly / npu-nightly YAML | G1 | (none) |
| CUDA ready + nightly | G5 | `cuda/l2` |
| CUDA merge + nightly | G6 | `cuda/l3` |
| CUDA ready + merge + nightly | G7 | `cuda/l2` + `cuda/l3` |
@@ -306,40 +313,38 @@ Unit coverage: `tests/buildkite/test_skip_ci.py`.
#### Step filtering {#step-filtering}
-CUDA **L2** (`.buildkite/cuda/test-ready.yml`) and **L3** (`.buildkite/cuda/test-merge.yml`) only. Bootstrap upload entry: `upload_pipeline.py --upload .buildkite/cuda/bootstrap-upload-steps.yml`.
+CUDA **L2** (`.buildkite/cuda/test-ready.yml`), **L3** (`.buildkite/cuda/test-merge.yml`), **L4** (`.buildkite/cuda/test-nightly.yml`), **L5** (`.buildkite/cuda/test-weekly.yml`), and NPU **L4** (`.buildkite/npu/test-npu-nightly.yml`). Bootstrap upload entry: `upload_pipeline.py --upload .buildkite/cuda/bootstrap-upload-steps.yml` (or `npu/bootstrap-upload-steps.yml`).
+
+**When filtering applies:** **PR label** uploads (`ready`, `merge-test`, `nightly-test`, `weekly-test`) and **post-merge `main` L3** (`test-merge.yml`). Scheduled **`main` + env** uploads that pass `--all` (`NIGHTLY=1`, `WEEKLY=1` / `NON_CRITICAL=1`) or `--e2e` (`WEEKLY=1` on L2/L3) keep every selected job and still strip the key. If **no** job-key prefix matches the diff, a change to the pipeline YAML being uploaded, or any path under the `source_filter_fallback` key in `ci_source_file_dependencies.yml`, keeps every job in that upload. `source_filter_fallback` is not a job key — do not attach it to a step. When any job-key prefix already matches, normal filtering wins (the fallback does not expand the selection).
**Uploader-only keys** — removed before Buildkite sees the YAML; never used at runtime on agents:
| Key | Purpose |
| --- | ------- |
-| `source_file_dependencies` | List of path **prefixes**. If any changed file equals a prefix or starts with `prefix/`, keep the step (or group); otherwise omit it. |
+| `source_file_dependencies` | Named preset (or list of presets) from [`ci_source_file_dependencies.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/common/ci_source_file_dependencies.yml). The uploader expands only that key; it does **not** scan `commands`. If any changed file equals a listed prefix or starts with `prefix/`, keep the step (or group); otherwise omit it. Inline path-prefix lists are still accepted. |
| `mirror_hardwares` | Expand to `agents` + `plugins` (+ optional `image`) from `ci_mirror_hardwares.yml`. |
### Policy
-- **Always uploaded** (no key): groups outside **E2E Test**—Simple Test, Diffusion unit tests, Engine/Model Executor, Distributed, Custom Pipeline, Entrypoints (L2), LoRA / Entrypoints (L3).
-- **Diff-gated**: every **E2E Test** leaf job. List the smallest prefix set per step—pytest file(s), model code under `vllm_omni/model_executor/models/` or `vllm_omni/diffusion/models/`, plus `stage_input_processors/` and `vllm_omni/deploy/*.yaml` when applicable. A **group** may define the key instead; the whole group drops if no prefix matches.
+- **Always uploaded** (no key): L2/L3 groups outside **E2E Test**—Simple Test, Diffusion unit tests, Engine/Model Executor, Distributed, Custom Pipeline, Entrypoints (L2), LoRA / Entrypoints (L3).
+- **Diff-gated**: every **E2E Test** leaf job in L2/L3, L4 nightly leaf jobs (CUDA and NPU), and L5 weekly jobs whose `if` includes the `weekly-test` label (Reliability / Perf). Jobs gated only by `WEEKLY=1` or `NON_CRITICAL=1` have no `source_file_dependencies`. Registry layout: YAML anchors are model business code only (`vllm_omni/model_executor/models/`, `vllm_omni/diffusion/models/`, plus `stage_input_processors/` and `vllm_omni/deploy/*.yaml` when applicable). Job keys alias the anchor and **also** list the scripts that job runs (pytest files, `run_cov_split.sh --offline` / `--online` targets, example README runners). Feature keys stay cross-cutting. A **group** may define the key instead; the whole group drops if no prefix matches.
### YAML examples
```yaml
- label: "Diffusion · Qwen Image Test"
- source_file_dependencies:
- - tests/e2e/online_serving/test_qwen_image.py
- - vllm_omni/diffusion/models/qwen_image/
+ source_file_dependencies: diffusion_qwen_image_function
+ # diffusion_qwen_image_function: *qwen_image + tests/e2e/online_serving/test_qwen_image.py
commands:
- pytest -s -v tests/e2e/online_serving/test_qwen_image.py -m 'core_model' ...
mirror_hardwares: h100_1
- label: "TTS · Qwen3-TTS CustomVoice Test"
- source_file_dependencies:
- - tests/e2e/online_serving/test_qwen3_tts_customvoice.py
- - vllm_omni/model_executor/models/qwen3_tts/
- - vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
- - vllm_omni/deploy/qwen3_tts.yaml
+ source_file_dependencies: tts_qwen3_tts_function
+ # tts_qwen3_tts_function: *qwen3_tts + tests/e2e/online_serving/test_qwen3_tts_customvoice.py
commands:
- pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py ...
- mirror_hardwares: l4_4
+ mirror_hardwares: l4_1
```
### Local dry-run
@@ -443,15 +448,16 @@ plugin, which provides the agent. `l4_*` jobs run on the EKS `l4-k8s` queue
still needs `mount-buildkite-agent: true`.
List both `run_cov_split.sh` and `pyproject.toml` in every opted-in job's
-`source_file_dependencies` — both change what the job measures, so without them a
+`source_file_dependencies` preset — both change what the job measures, so without them a
change there is filtered out of normal PR builds and only surfaces in a later
nightly. `tests/buildkite/test_upload_pipeline.py` covers the filter behavior with
a synthetic job (it does not pin real merge labels).
-Editing only the surrounding CI YAML still does not schedule them, so a PR that
-touches just the wiring needs a full E2E run (or the commands run on a GPU host)
-to produce artifacts. When checking a new model's
-artifacts, compare `lines-covered` between the online and offline XML rather than
-just confirming both files exist.
+If no job-key prefix matches, editing the pipeline YAML being uploaded, or any
+path under the `source_filter_fallback` registry key, keeps every job in that
+upload (including coverage jobs) so the wiring can be validated before merge.
+When a job-key prefix already matches, bypass does not expand the selection. A
+change to a different pipeline YAML still does not schedule them. When checking a new model's artifacts, compare `lines-covered`
+between the online and offline XML rather than just confirming both files exist.
### Validation checklist
diff --git a/docs/contributing/ci/test_system_overview.md b/docs/contributing/ci/test_system_overview.md
index f432678bc..dde8f2a58 100644
--- a/docs/contributing/ci/test_system_overview.md
+++ b/docs/contributing/ci/test_system_overview.md
@@ -182,6 +182,8 @@ CUDA **L4** (`full_model`) jobs are defined once in [`.buildkite/cuda/test-night
- **Nightly:** scheduled `main` builds with `NIGHTLY=1` (and PR labels such as `nightly-test` / `omni-test`). Default fleet is typically H100.
- **Pre-release:** the same L4 suite runs again on extra GPU SKUs before a release (for example B200 via `MIRROR_HW=b200`), so `full_model` coverage is not limited to the nightly machine type.
+AMD has an experimental, non-blocking `full_model` nightly vertical slice in [`.buildkite/amd/test-amd-nightly.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/amd/test-amd-nightly.yml). It selects ROCm Qwen3-Omni function-expansion, accuracy, documentation-example, and AITER-on smoke coverage on the MI300 pool. AMD-only PR validation uses `amd-test` to admit the external AMD pipeline and `nightly-test` to select this suite; scheduled `main` builds select it with `NIGHTLY=1`. Keep it non-blocking while the lane accumulates stability and completion-time evidence.
+
How count-form `mirror_hardwares` and `MIRROR_HW` pick a SKU: [CI Settings](./ci_settings.md).
### L2 / L3 diff-aware CI (CUDA)
diff --git a/docs/design/architecture_overview.md b/docs/design/architecture_overview.md
index 58722ffa0..8b7168824 100644
--- a/docs/design/architecture_overview.md
+++ b/docs/design/architecture_overview.md
@@ -131,13 +131,13 @@ for the validated batching, attention, and quantization combinations.
### MiniMax-H3: conditioning, denoising, and decode
-MiniMax-H3 illustrates logical component and phase boundaries between text
-encoding, task-specific denoising, and media decoding:
-`Text Encoder → DiT → VAE Decoder`. The current registered topology co-locates
-these components in one diffusion stage. These boundaries are useful for
-profiling and offload; independent placement would require a future
-multi-stage `PipelineConfig`. The stage mapping follows the implementation and
-serving recipe; the [release overview](https://minimaxi.com/blog/minimax-h3)
+MiniMax-H3 combines text and reference-media encoding, task-specific denoising,
+and media decoding. Standard serving co-locates these components in one diffusion
+stage. The opt-in
+[disaggregated topology](https://github.com/vllm-project/vllm-omni/blob/main/recipes/MiniMaxAI/MiniMax-H3-Disaggregated.md)
+runs Qwen and the video/audio VAE encoders in Stage 0, then sends their conditioning
+to a Stage 1 containing the DiTs and VAE decoders. The stage mapping follows the
+implementation and serving recipe; the [release overview](https://minimaxi.com/blog/minimax-h3)
notes that a detailed technical report is forthcoming.
#### Model architecture by component
@@ -149,16 +149,15 @@ notes that a detailed technical report is forthcoming.
| **VAE Decoder** | Video VAE and audio VAE decode the generated latents and assemble the synchronized output | Decoded video frames and stereo waveform, with FPS and audio sample rate metadata |
Both DiT partitions remain behind one task-selection point; `task` chooses
-which partition runs. In the current implementation, the three logical
-components execute inside one diffusion stage. This is different from
-Qwen3-Omni's three independently scheduled stages, even though both models
-produce synchronized audio and video-like outputs.
+which partition runs.
#### Representative component execution
+The following table describes the standard single-stage deployment.
+
| Logical component | Batching | Attention and execution | Parallelism | Quantization |
| --- | --- | --- | --- | --- |
-| **Text Encoder** | The current co-located pipeline follows the diffusion request scheduler; independent prompt/reference batching would require a separate multi-stage topology | Qwen3-VL attention and multimodal preprocessing | `--text-encoder-tp-size N` shards the encoder across the first `N` DiT ranks; independent placement is not part of the current topology | BF16/FP32 baseline; the H3 DiT FP8 path does not quantize the text encoder |
+| **Text Encoder** | Follows the diffusion request scheduler | Qwen3-VL attention and multimodal preprocessing | `--text-encoder-tp-size N` shards the encoder across the first `N` DiT ranks | BF16/FP32 baseline; the H3 DiT FP8 path does not quantize the text encoder |
| **DiT** | The current H3 implementation executes one generation request per diffusion batch; use concurrency for service-level throughput | cuDNN attention for the validated two-GPU consumer profile; TRTLLM attention or FlashAttention-4 on supported Blackwell profiles | TP2 plus text-encoder TP and Ulysses/VAE parallel groups on multi-GPU profiles; DLO/CPU offload trade memory for transfer time | Online FP8 applies to eligible DiT linears and is incompatible with layerwise offload |
| **VAE Decoder** | Decode follows each generation request; tile/patch work can be distributed even when denoising is not batched | VAE decode kernels rather than DiT attention | VAE patch parallelism and native tiled decode within the diffusion stage | BF16/FP32 baseline; the documented H3 FP8 path leaves both VAEs unchanged |
@@ -324,7 +323,7 @@ inputs, fields, and ownership rules are described below.
flowchart TB
layer1["Layer 1 · Authoring inputs PipelineConfig + DeployConfig"]
layer2["Layer 2 · Production resolution boundary resolve_omni_config() (StageConfigFactory + VllmOmniConfig internally)"]
- layer3["Layer 3 · Startup hand-off OmniConfigResolution PipelineConfig + temporary OmegaConf stage bridge"]
+ layer3["Layer 3 · Startup hand-off OmniConfigResolution PipelineConfig + typed stage configs"]
layer4["Layer 4 · Runtime launch planning StageRuntime"]
layer5["Layer 5 · Engine materialization VllmConfig / OmniDiffusionConfig"]
@@ -336,10 +335,10 @@ The production resolution boundary is `resolve_omni_config()` in
It delegates typed construction to `StageConfigFactory.create_from_model()` and
`VllmOmniConfig.from_pipeline_config()`, then returns an
`OmniConfigResolution` consumed by both `AsyncOmniEngine` and headless startup.
-Until `StageRuntime` consumes typed stage configs directly, this envelope carries
-the effective `PipelineConfig` alongside OmegaConf-compatible `stage_configs` as
-a temporary runtime bridge; both views describe the same resolved topology,
-including injected stages.
+This envelope carries the effective `PipelineConfig` alongside typed
+`stage_configs`, which `StageRuntime` consumes directly. Both views describe the
+same resolved topology, including injected stages. Backend arguments are
+projected from these typed stages when each engine is initialized.
The legacy `stage_args` YAML path has been removed. Model topology now resolves
through `PipelineConfig`, with runtime overrides supplied by `DeployConfig`.
@@ -361,7 +360,7 @@ The important ownership rules are:
3. CLI and Python overrides are applied at the resolution boundary, with
per-stage overrides taking precedence over global values where supported.
4. `OmniConfigResolution` is the sole startup hand-off. Its `pipeline_config`
- and temporary `stage_configs` compatibility view must describe the same
+ and typed `stage_configs` must describe the same
topology.
5. `StageRuntime` owns launch planning and replica lifecycle. `ReplicaInitPlan`
is runtime-private state, not a user configuration object.
diff --git a/docs/design/feature/attention_backend_selection.md b/docs/design/feature/attention_backend_selection.md
index 41cbaf3ff..3ae1115b1 100644
--- a/docs/design/feature/attention_backend_selection.md
+++ b/docs/design/feature/attention_backend_selection.md
@@ -76,6 +76,34 @@ preserve common `self` or `cross` policies. A model also decides whether its
path is compatible with automatic TRTLLM selection, because only the model
knows whether masking and packing satisfy the kernel contract.
+### Model-owned implementation specializations
+
+Use `Attention(..., impl_overrides={backend_name: implementation_class})`
+when a model needs different computation within an existing backend contract.
+The model defines the implementation in its own directory and passes it when
+constructing the layer:
+
+```python
+# MyModelVSAImpl is a model-owned subclass of FastVideoVSAImpl.
+self.attention = Attention(
+ num_heads=num_heads,
+ head_size=head_size,
+ softmax_scale=head_size**-0.5,
+ causal=False,
+ role="self",
+ impl_overrides={"FASTVIDEO_VSA": MyModelVSAImpl},
+)
+```
+
+The override is applied after role/platform selection, keyed by the selected
+backend's `get_name()`. It must subclass the **selected implementation**;
+incompatible platform implementations raise `TypeError`. Unselected overrides
+have no effect. Backend capabilities, constructor options, and shared
+parallel dispatch remain unchanged and must be honored by the specialization.
+
+Use a new backend for a new provider/capability contract. `custom_attention`
+instead owns communication and requires `skip_sequence_parallel=True`.
+
## Adding or changing a backend
An implementation change is complete only when it:
@@ -91,8 +119,9 @@ An implementation change is complete only when it:
## FastVideo VSA model metadata
-FastVideo VSA support is scoped to `FastVideo/FastWan2.2-TI2V-5B-Diffusers`, whose text-to-video and image-to-video modes both use `Wan22Pipeline`. It operates on a flattened DiT sequence but partitions tokens in
-the original latent video grid. Wan integrations therefore attach the
+FastWan's text-to-video and image-to-video modes both use `Wan22Pipeline`.
+It operates on a flattened DiT sequence but partitions tokens in the original
+latent video grid. Wan integrations therefore attach the
post-patch `(T, H, W)` shape as `vsa_dit_seq_shape` attention metadata. The
backend validates that `T * H * W` equals the sequence length, derives the
runtime block count, and selects the configured top-k key/value blocks for
@@ -104,3 +133,8 @@ the checkpoint contains those weights. Checkpoints without the projection do
not allocate or execute it. When top-k selects every block, native checkpoints
route to SDPA, while FastVideo DMD checkpoints preserve the VSA all-block path
to retain checkpoint semantics.
+
+MiniMax-H3 supplies `MiniMaxH3VSAImpl` through `impl_overrides`.
+`models/minimax_h3/attention/fastvideo_h3.py` owns its prefix layout, video tiling, and
+learned compression gate. Pooling, block-map construction, and tile64 provider
+calls are shared through `attention/ops/block_sparse.py`.
diff --git a/docs/design/feature/disaggregated_inference.md b/docs/design/feature/disaggregated_inference.md
index ba8c8f9fc..8b94325cb 100644
--- a/docs/design/feature/disaggregated_inference.md
+++ b/docs/design/feature/disaggregated_inference.md
@@ -9,6 +9,7 @@ Backend-specific setup lives in separate docs:
- [MooncakeStoreConnector](omni_connectors/mooncake_store_connector.md)
- [MooncakeTransferEngineConnector](omni_connectors/mooncake_transfer_engine_connector.md)
- [MoriTransferEngineConnector](omni_connectors/mori_transfer_engine_connector.md)
+- [NixlConnector](omni_connectors/nixl_connector.md)
- [YuanrongConnector](omni_connectors/yuanrong_connector.md)
- [YuanrongTransferEngineConnector](omni_connectors/yuanrong_transfer_engine_connector.md)
@@ -25,6 +26,7 @@ Current connectors operate in D2H2D (device to host to device) mode.
| Multi node (Mooncake Store) | MooncakeStoreConnector | TCP-based, requires Mooncake Master + metadata server. |
| Multi node (Mooncake RDMA) | MooncakeTransferEngineConnector | RDMA/TCP direct transfer with managed memory pool. Fastest. |
| Multi node (Mori RDMA) | MoriTransferEngineConnector | RDMA direct transfer via Mori IOEngine. |
+| Multi node (NIXL) | NixlConnector | NIXL `READ` over UCX; the only backend covering Intel XPU. |
| Multi node (Yuanrong) | YuanrongConnector | Requires Yuanrong Datasystem + etcd. |
| Ascend NPU P2P (Yuanrong TE) | YuanrongTransferEngineConnector | Uses Yuanrong TransferEngine directly. Configure NPU device IPv4 and `memory_pool_device: "npu"`. |
diff --git a/docs/design/feature/host_weight_runtime.md b/docs/design/feature/host_weight_runtime.md
index a9bb1b0b8..15fce07b8 100644
--- a/docs/design/feature/host_weight_runtime.md
+++ b/docs/design/feature/host_weight_runtime.md
@@ -42,6 +42,11 @@ V1 does not include:
## Motivation and use cases
+For investigating CPU memory retained by dependency tensor materialization,
+see the [standalone safetensors diagnostic](https://github.com/vllm-project/vllm-omni/blob/main/benchmarks/host_weight_runtime/README.md).
+It distinguishes repeated dependency calls from reuse of cached views and does
+not establish per-request HWR leakage.
+
Model loading can create the same final host representation repeatedly. This
is especially expensive when loading performs checkpoint decoding, tensor
renaming, TP slicing, quantization, packing, or scale construction before GPU
@@ -313,8 +318,15 @@ One process per exact identity owns a build; other workers wait and then acquire
leases for the published artifact. Publication is invisible until all payloads
and metadata are validated, hashed, fsynced, and atomically renamed.
-`coordination_timeout_seconds` bounds filesystem lock acquisition. It does not
-cancel synchronous validation, a producer that has already started, or atomic
+`coordination_timeout_seconds` bounds domain-initialization and lookup/build
+lock acquisition. Store construction and each later resolution or publication
+operation have separate budgets from the same wait policy, rather than one
+end-to-end startup deadline. A domain-init timeout follows the retryable domain
+failure policy below. After contention ends, a fresh runtime construction can
+retry initialization; the timed-out runtime retains its original failure.
+
+The coordination budget does not cancel filesystem I/O, synchronous validation,
+a producer that has already started, or atomic
publication. A hung in-process producer therefore blocks its owning process and
must be handled by external process supervision. Enforceable producer
cancellation requires a future process-isolated producer contract.
diff --git a/docs/design/feature/model_local_kv_caches.md b/docs/design/feature/model_local_kv_caches.md
new file mode 100644
index 000000000..371b989ff
--- /dev/null
+++ b/docs/design/feature/model_local_kv_caches.md
@@ -0,0 +1,132 @@
+# Model-local KV caches
+
+Some models keep their attention KV as HuggingFace `transformers` cache objects
+instead of using the engine's paged KV manager. Each such model declares what it
+holds by implementing `model_local_kv_specs()`, and
+`OmniGPUModelRunner.load_model` sums and logs the result after load.
+
+## What is and is not already accounted for
+
+This distinction decides what the protocol is for, and an earlier revision of
+this page got it wrong by claiming none of this memory was accounted for.
+
+CUDA-graph capture happens *during* `load_model` -- MiMo in its constructor,
+Qwen3-TTS in `load_weights`. `determine_available_memory()` then samples
+per-process GPU memory through NVML and subtracts it from the requested budget
+(`vllm_omni/worker/base.py`). So the **graph-resident** portion is already
+charged. Subtracting the declared total from the KV budget would double-count
+it.
+
+What is *not* charged is the **per-request** portion, which is allocated after
+profiling. That half is not small: Qwen3-TTS's codec decoder is 4.44 MiB per
+request, so a server at `max_num_seqs=256` reaches roughly 1.1 GiB that no
+per-stage number predicted.
+
+The consumer therefore reports and does not subtract.
+
+## What the four models declare
+
+| Model | Cache | Bounded by | Per row | Rows |
+|---|---|---|---|---|
+| Qwen3-TTS codec decoder (async-chunk only) | sliding `DynamicCache` | `sliding_window - 1 = 71` | 4.44 MiB | `max_num_seqs`, x2 for the working copy |
+| Qwen3-TTS graph pool | retained captures | same | 4.44 MiB | fixed, from captured shapes |
+| MiMo-Audio local transformer | `DynamicCache` | `group_size + max(delay_pattern) = 11` | 704 KiB | `max_num_seqs` |
+| MiMo-Audio graph pool | captured | same | 704 KiB | fixed 261 (bucket sum) |
+| MiniCPM-o Whisper encoder (streaming only) | `EncoderDecoderCache` | `embed_positions` rows = 1500 | 140.62 MiB | fixed 1, per session |
+| ming_flash_omni talker | `StaticCache` | hardcoded `max_cache_len = 2048` | 24.00 MiB | fixed 1 |
+
+**No cache is bounded by `max_model_len`.** Each has its own mechanism: a
+sliding window, a decode-loop trip count, an encoder frame limit, a constant.
+
+**ming's geometry does not exist in this repo.** `self._llm_config` is a
+`Qwen2Config` from the checkpoint, so layers, kv-heads, head-dim and dtype are
+only knowable after load. That is what makes this a runtime query rather than a
+table.
+
+## Lifetime is not multiplicity
+
+`scope` records when memory appears and goes. It does **not** decide how wide
+the allocation is, and an earlier revision that derived scaling from it was
+wrong in two different directions at once:
+
+- ming's talker is per-call but serialized -- `forward` takes
+ `runtime_additional_information[0]` and runs the whole AR loop inline -- so
+ scaling it by `max_num_seqs` over-reported it by that factor.
+- MiniCPM-o's encoder is per *session*, not per sequence, so `max_num_seqs` is
+ simply the wrong number for it.
+
+So width is declared explicitly, by naming the driver. `RowDriver` has two
+values, because two are all the known caches need:
+
+- `FIXED` -- a count the model controls, with `rows_fixed` and a required
+ `rows_reason`. `rows_fixed=1` on a cache that looks per-request is a claim a
+ reviewer must be able to check.
+- `MAX_NUM_SEQS` -- one row per in-flight sequence; only the engine knows the
+ value.
+
+A third value belongs here when a model actually has a cache the engine widens
+by some other number. An earlier revision added one speculatively, for a single
+declarer, and paid for it with a class hierarchy, an engine-capacity object and
+a conditional-annotation field.
+
+`rows` counts row-equivalents at peak and deliberately does not distinguish
+"one allocation N rows wide" from "N allocations of one row". Those cost the
+same, and a revision that tried to carry both got the object topology wrong in
+three of four declarers: MiniCPM-o keeps one cache object per session while
+asserting batch size 1, and both graph pools keep one distinct object per
+captured bucket. The layout belongs in `allocation_note`, where it cannot be
+mistaken for arithmetic.
+
+## Declaring nothing is a valid declaration
+
+A declaration must be inert when its path is. Qwen3-TTS holds KV only in
+async-chunk mode -- stateless decoding runs `_forward_exact`, which calls the
+transformer without `use_cache` -- so the stateless path declares nothing.
+Getting this wrong is easy in both directions, and both have happened here: one
+revision hung the declaration off the CUDA-graph wrapper and reported zero under
+`enforce_eager`, where the eager async-chunk path does allocate; the next
+declared unconditionally and invented roughly a gigabyte for stateless
+deployments.
+
+Where the model cannot see the deciding setting, it declares the honest unit
+and says so in `allocation_note`. MiniCPM-o's encoder cache exists only on the
+streaming path and is one object per session; the session cap is not visible
+from the model, so it declares one session rather than scaling by a number it
+would have to guess at.
+
+## Finding the declarers
+
+`collect_model_local_kv_specs()` walks `named_modules()`. Two wrinkles:
+
+- The runner's `self.model` may be a `CUDAGraphWrapper`, which is not an
+ `nn.Module` but forwards attribute access to its runnable. The walk therefore
+ works either way, but entering through both the wrapper and its runnable would
+ count a root-level declarer twice. The collector calls the supported
+ `unwrap()` accessor first.
+- An owner that is not a submodule at all (Qwen3-TTS's graph wrapper, ming's
+ `MingAudioGenerator`) is reached by one explicit hop from the module that
+ holds it.
+
+## Adding a model
+
+Return one entry per distinct allocation. Prefer `spec_from_hf_config()` and
+pass the same config object the cache is built from; it raises rather than
+guessing when an attribute is missing. `capacity_source` is diagnostic text --
+never branch on it.
+
+## Known limits
+
+Row counts taken from live state after warmup miss caches `transformers` has
+not yet materialized, which makes Qwen3-TTS's graph-pool figure a floor for the
+xvec path.
+
+MiMo's graph-pool cache has no reachable Python object -- it is a local inside
+the captured frame -- so its width comes from the captured bucket list rather
+than from tensors. Its two entries are also mutually exclusive per call: a call
+either replays a bucket or runs eagerly. The sum is the right ceiling for the
+process and the wrong number for a single request.
+
+MiMo captures every bucket gated only on `torch.cuda.is_available()`
+(`mimo_audio_llm.py:670`), so those 179.44 MiB stay resident even when the
+deploy config sets `enforce_eager: true`. Gating it is a one-line change that
+needs a model boot to verify, so it is noted rather than bundled here.
diff --git a/docs/design/feature/mooncake-kv-transfer.md b/docs/design/feature/mooncake-kv-transfer.md
new file mode 100644
index 000000000..ed83d562f
--- /dev/null
+++ b/docs/design/feature/mooncake-kv-transfer.md
@@ -0,0 +1,89 @@
+# Mooncake KV Transfer
+
+## PR summary
+
+This change adds an opt-in AR-to-DiT KV handoff using vLLM's native
+`KVTransferConfig`, Scheduler-owned diffusion KV pages, and
+`MooncakeConnector`. The existing `OmniKVTransferManager` path remains the
+fallback when `kv_transfer_config` is absent.
+
+The orchestrator creates one transfer ticket per request and resolves the
+bound AR replica's Mooncake endpoint after generation. DiT allocates the
+destination pages, waits for every worker rank to finish loading, and releases
+the source and destination pages only after the connector lifecycle completes.
+Request registration failures are rolled back before Worker dispatch. Receive
+timeouts and cancellation terminate the request without recycling in-flight
+pages: the scheduler retains them until every CFG row has completed on every
+rank. Late completion is polled only while such requests remain. If a peer
+never completes, its pages remain reserved until shutdown; other requests can
+continue using the remaining capacity. Invalid pages, missing Worker ranks,
+and failures without a proven rollback still stop the replica before teardown.
+The example sets `transfer_timeout: 60.0` seconds explicitly. Cancellation
+queued during a synchronous receive is processed when that receive completes
+or times out; it does not interrupt the remote write.
+
+## Reference configuration
+
+| Stage | Replicas | Devices | Parallelism | KV cache |
+| --- | ---: | --- | --- | ---: |
+| AR | 2 | 0-3 | TP=2 per replica | 2 GiB per worker |
+| DiT | 2 | 4-7 | TP=2 per replica, EP enabled | 1 GiB per worker |
+
+The acceptance workload uses 512x512 images, two denoising steps, guidance
+scale 2.0, and `max_num_seqs=1`.
+
+For a four-GPU debug run, set both stages to one replica and assign AR TP=2 to
+devices `0,1` and DiT TP=2 to devices `2,3`.
+
+## Experiment
+
+The final comparison used one node with eight NVIDIA RTX PRO 6000 Blackwell
+Server Edition GPUs, TCP Mooncake transport, the same 50-prompt/seed T2I
+dataset, concurrency 8, and two independent 100-request runs per
+implementation. Each side completed 200/200 requests without an OOM or request
+failure.
+
+| Implementation | Run 1 (req/s) | Run 2 (req/s) | Mean (req/s) |
+| --- | ---: | ---: | ---: |
+| `OmniKVTransferManager` | 4.4103 | 4.4497 | 4.4300 |
+| Native `MooncakeConnector` | 4.4457 | 4.4699 | 4.4578 |
+
+Native throughput is **0.63% higher** than the legacy mean. The absolute
+performance difference is below the 1% acceptance threshold.
+
+For accuracy, a non-zero transferred prefix and the resulting attention output
+were compared bit-for-bit with the legacy manager path:
+
+```text
+prefix_bits_equal True
+attention_bits_equal True
+max_abs_diff 0.0
+```
+
+The one-AR/one-DiT TP=2 debug topology also completed 20/20 sequential
+requests. A separate real-TCP transport check transferred both CFG rows from a
+TP=1 producer to a strict-Ulysses SP=2 consumer with exact values on both
+target ranks.
+
+## Scope and limitations
+
+- The fully validated end-to-end topology is 2x AR TP=2 plus 2x DiT TP=2.
+- Heterogeneous TP=1 to SP=2 reshaping is transport-tested, but arbitrary mixed
+ TP/CFG/SP end-to-end combinations have not been exhaustively validated.
+- CFG-parallel end-to-end deployment is not part of this acceptance run.
+- Native AR-to-DiT transfer currently requires `async_chunk: false`.
+- TCP is covered by the acceptance run. The supplementary single-node RDMA
+ measurements use a separate short workload; they do not establish RDMA
+ support for multi-node deployment, which remains out of scope.
+- The native path currently validates the two-stage `AR(0) -> DiT(1)` graph. Other stage graphs are rejected during startup until
+ their producer/consumer edge mapping is implemented.
+- Sleep mode is disabled for the native paged path because registered KV pages
+ must remain mapped for the connector lifetime.
+
+- Ascend NPU adaptation and its performance validation are maintained in a
+ separate follow-up PR; the GPU acceptance results above do not validate NPU.
+- The homogeneous piecewise CUDA batching optimization is retained. A native
+ paged-attention CUDA graph capture/replay regression covers two CFG rows,
+ strided QKV, mixed causal/image spans, changed inputs, and output stability.
+ It must be run on CUDA hardware; CPU unit tests do not establish graph or
+ end-to-end performance acceptance.
diff --git a/docs/design/feature/offloader/README.md b/docs/design/feature/offloader/README.md
index 96cba8f60..52797b68c 100644
--- a/docs/design/feature/offloader/README.md
+++ b/docs/design/feature/offloader/README.md
@@ -41,7 +41,8 @@ Every backend implements `OffloadBackend`:
- `is_enabled()` reports lifecycle state.
`disable()` does not promise to restore the pipeline's original device
-placement. The caller owns any subsequent rematerialization.
+placement. It must restore usable parameter and buffer storage before releasing
+backend-owned host weights. The caller owns subsequent device placement.
An enable failure must remove every partially installed hook. Rank-local
backends restore ordinary tensors so the pipeline can be retried. A failed
@@ -54,9 +55,19 @@ must use distinct hook names and remove only hooks they own.
## Discovery and topology
`resolve_offload_plan(pipeline, config)` is the single topology entry point.
-Every backend calls it once when `enable()` starts and then consumes the
-resolved components, block stacks, resident heads, and staged-residency flags
-it returns. Backends do not re-read model declarations.
+The generic model-level and ordinary layerwise paths call it once when
+`enable()` starts and consume its component selection and block stacks.
+Ordinary layerwise offload also respects a resolved `skip_reason`, preserving
+the legacy missing-DiT no-op without placing auxiliary components. Non-block
+DiT and encoder state is placed by resolved block tensor identity, so the
+backend does not rediscover block containers by attribute name.
+
+Two compatibility paths remain outside this completed generic cutover:
+model-level pipelines implementing `SupportsModelCpuOffload` still own their
+custom phase lifecycle (RFC #6648 J4), and distributed layerwise offload still
+reads declarations for nested-submodule staging and loader-owned mmap paths
+(J3). The custom model-level protocol is delegated to before generic plan
+resolution; it must not be subjected to the generic encoder/DiT swap checks.
Pipeline component discovery prefers `SupportsComponentDiscovery` declarations:
@@ -73,10 +84,22 @@ Block topology comes from the pipeline `OffloadPlan` first: `block_attrs` maps
each DiT path to its ordered block containers, `encoder_block_attrs` declares
streamable encoder stacks, `on_demand_component_paths` marks pipeline-managed
residency, `resident_dit_paths` marks the DiTs that may hold resident layers,
-and `encoder_dlo_weight_replication` marks the encoders whose loader-produced
-weights are safe for AllGather. DiTs absent from the plan fall back to
-`_layerwise_offload_blocks_attrs` (including the deprecated singular-name
-compatibility path).
+`encoder_dlo_weight_replication` marks the encoders whose loader-produced
+weights are safe for AllGather, and `offload_submodules` maps a DiT submodule
+that owns its residency to its own block container. DiTs absent from the plan
+fall back to `_layerwise_offload_blocks_attrs` (including the deprecated
+singular-name compatibility path).
+
+An undeclared DiT submodule joins the plan only when it is large enough that
+keeping it resident would defeat streaming; its block container is then found
+by scanning well-known attribute names, which warns once per submodule class.
+Size is computed from parameter shapes and dtypes, including meta parameters,
+so resolving before mmap loading preserves the same residency decision.
+Block ownership validation includes nested components: a block shared with a
+parent, sibling, or encoder is rejected before any hooks or storage are changed.
+A submodule with no block container must implement the `load_to_device` /
+`offload_to_cpu` lifecycle, and the resolver rejects it before any component of
+the pipeline is placed or hooked.
The resolver is pure: it moves no tensor, installs no hook, writes no module
attribute, and reads no process group — multi-rank facts come from
@@ -102,3 +125,17 @@ remains responsible for transfer, synchronization, and storage ownership.
The diffusion [Offloader module design](../../module/diffusion/offloader.md)
describes how these feature contracts fit into the larger diffusion runtime.
+
+## Backend acceptance tests
+
+`tests/diffusion/offloader/test_backend_plan_contract.py` covers the two
+behaviors the generic cutover changed. A DiT attribute aliasing a streamed
+block must keep the ring's host residency, so placement cannot follow attribute
+names; the CUDA case fails against the previous name-based placement. Both
+backends must then run with their selector helpers and the model declaration
+made unreadable, proving execution consumes the resolved plan alone.
+
+Selection errors, rollback, residency, and enable/disable cycles stay in
+`test_plan_resolver.py`, `test_layerwise_backend.py` and
+`test_sequential_backend.py`. Model-specific phases and executed tied aliases
+remain the J4 contract suite.
diff --git a/docs/design/feature/omni_async_output_materialization.md b/docs/design/feature/omni_async_output_materialization.md
index 50a2e7c52..eff3024d8 100644
--- a/docs/design/feature/omni_async_output_materialization.md
+++ b/docs/design/feature/omni_async_output_materialization.md
@@ -318,18 +318,18 @@ The same behavior applies to the Base and VoiceDesign Qwen3-TTS checkpoints
because they use the same Talker implementation.
!!! warning
- Do not pass `--no-async-chunk` or enable prefix caching when you want this
- optimization. Either change causes the runner to fall back to synchronous
- output construction. Enabling async chunk alone cannot activate the feature
- for a model that has not opted into async Omni output.
+ Do not pass `--no-async-chunk` when you want this optimization. Disabling
+ async chunk causes the runner to fall back to synchronous output
+ construction. Enabling async chunk alone cannot activate the feature for a
+ model that has not opted into async Omni output.
!!! note "Prefix cache compatibility"
- Async Omni output materialization and Omni prefix caching cannot currently
- run together. `_should_use_async_omni_output()` returns `False` whenever
- `self.omni_prefix_cache` is present, so prefix caching selects synchronous
- output materialization. Supporting both features requires snapshotting or
- otherwise synchronizing prefix-cache merge and update state before the
- background output path can consume it safely.
+ Prefix cache and async Omni output can run together. `save_outputs` returns
+ a `step_id`; the output builder later calls `materialize(sid, req_ids)` with
+ the save-time request list, which may be a step late relative to the live
+ `input_batch`. `_should_use_async_omni_output()` does not disable itself
+ when the cache is present. See
+ [Automatic Prefix Caching](prefix_caching.md#implementation).
## Compatibility and Fallbacks
@@ -341,10 +341,9 @@ runtime conditions hold:
| AR async scheduling is enabled | The optimization relies on the scheduler advancing while the prior output is materialized |
| `async_chunk` is enabled | The feature targets incremental downstream Omni payloads |
| The model stage opts in with `use_async_omni_output` | Models must declare that their output lifecycle is safe to defer |
-| Omni prefix cache is disabled | Prefix-cache merge and update ordering currently requires synchronous materialization |
| Speculative decoding is disabled | Speculative output state is not included in this deferred path |
| Routed-expert output is disabled | Routed-expert extraction currently requires the synchronous path |
-| Postprocess is absent or explicitly runs eagerly | State needed by the next decode step must be updated before the runner returns |
+| Postprocess is absent or explicitly runs eagerly | State needed by the next decode step must be updated before the runner returns. Eager postprocess sees the scheduled slice (`None, None` combined tensors), not the prefix-cache-merged full prompt; it must only consume the tail |
These checks are evaluated per stage on runners based on `GPUARModelRunner`.
An unsupported combination does not prevent serving; it only falls back to
@@ -380,6 +379,8 @@ so Omni payload materialization remains synchronous.
- `tests/worker/test_gpu_ar_model_runner.py`: Snapshot, guard, connector
ordering, and background error-propagation tests.
- [Async Chunk](async_chunk.md): Inter-stage chunking and scheduling design.
+- [Automatic Prefix Caching](prefix_caching.md#implementation): Prefix-cache
+ `step_id` / `materialize` contract used by the deferred output builder.
- [Qwen3-Omni optimization blog](https://vllm.ai/blog/2026-07-01-qwen3-omni-optimization):
Optimization context and controlled performance results.
- [PR #4476](https://github.com/vllm-project/vllm-omni/pull/4476): Feature
diff --git a/docs/design/feature/omni_connectors/nixl_connector.md b/docs/design/feature/omni_connectors/nixl_connector.md
new file mode 100644
index 000000000..7dfd73946
--- /dev/null
+++ b/docs/design/feature/omni_connectors/nixl_connector.md
@@ -0,0 +1,135 @@
+# NixlConnector
+
+## When to Use
+
+Multi-node or intra-node stage transfer over NIXL, which brokers RDMA/shared-memory
+transports through a single API. Useful when the deployment already standardises on
+NIXL for KV transfer, or when the target accelerator has no Mooncake/Mori backend --
+NIXL reaches Intel XPU through UCX's Level Zero support.
+
+## Mechanism
+
+Uses vLLM's `NixlWrapper` (`vllm.distributed.nixl_utils`) to register the producer's
+tensors and let the consumer pull them with a NIXL `READ`.
+
+- Data Plane: NIXL agent-to-agent `READ`, GPU-to-GPU where the backend allows it.
+- Control Plane: a ZMQ ROUTER socket atomically claims the source allocation before
+ returning descriptors. Forwarded `put()` metadata includes the generation and
+ endpoint; the consumer claims that generation before submitting any READ.
+
+Transfer metadata uses schema version 1. Tensor descriptors are grouped by NIXL
+memory type (`DRAM`, `VRAM`, and so on), while each descriptor retains its global
+tensor index so mixed CPU/accelerator payloads can be reconstructed in order.
+
+Payloads are not restricted to tensors. A single tensor or a list of tensors is
+transferred as-is; a nested structure has its tensor leaves extracted and shipped
+alongside a msgpack-encoded skeleton; anything else is msgpack-encoded into one
+uint8 tensor. Mapping and sequence structure is preserved. `msgspec.Struct` values,
+including `OmniPayloadStruct`, are normalised to mappings so their tensor leaves use
+the direct NIXL path rather than CPU object serialisation.
+
+Each NIXL agent uses strict thread synchronization. A consumer performs one short
+metadata query per scheduler poll (10ms by default), which prevents an unavailable
+producer from blocking unrelated requests. The regular transfer-completion handshake
+keeps the configured transfer timeout.
+
+## Installation
+
+CUDA hosts can install the published wheel:
+
+```bash
+pip install nixl
+```
+
+For Intel XPU, use `docker/Dockerfile.xpu`. Its upstream vLLM XPU base image
+provides a matching torch-xpu, UCX with Level Zero support, and NIXL runtime.
+
+## Configuration
+
+```yaml
+connectors:
+ nixl_connector:
+ name: NixlConnector
+ extra:
+ host: "auto"
+ zmq_port: 50061
+ backends: ["UCX"]
+
+stages:
+ - stage_id: 0
+ output_connectors:
+ to_stage_1: nixl_connector
+
+ - stage_id: 1
+ input_connectors:
+ from_stage_0: nixl_connector
+```
+
+Parameters:
+
+- `host`: address the producer binds its handshake socket to (`"auto"` to detect).
+- `zmq_port`: handshake port. If a producer omits it, an ephemeral port is opened
+ for ownership claims and returned in `put()` metadata. The value in the deploy YAML is a
+ base port. Purpose, replica, rank, and producer-stage offsets are added centrally,
+ so colocated connectors and tensor-parallel workers receive distinct endpoints
+ without one config entry per rank.
+- `sender_host` / `sender_zmq_port`: consumer-side override naming the producer's
+ handshake endpoint. Only needed when the consumer cannot learn it from metadata.
+ In replicated deployments, the producer endpoint is carried with each request and
+ selected at `get()` time; it is not mutable connector-wide state.
+ Explicit sender ports are preserved, not recomputed using the receiver replica.
+ Intermediate NIXL stages retain their incoming receiver configuration and a
+ separate outgoing bind configuration, so one worker can receive and publish.
+- `backends`: NIXL backends to register memory with. Defaults to `["UCX"]`.
+- `receive_device`: forces where received tensors land. By default the consumer
+ keeps the producer's device *type* but uses its own current device of that type.
+- `memory_type`: overrides the NIXL memory type, otherwise `DRAM` for CPU tensors
+ and `VRAM` for accelerator tensors.
+- `lease_seconds`: how long a `put()` payload stays registered while waiting to be
+ read (default 3600). The consumer reports completion, so this only bounds payloads
+ nobody has claimed. An internal reaper releases only unclaimed registrations.
+ `VLLM_OMNI_NIXL_LEASE_S` overrides it.
+- `transfer_timeout_s`: how long a `get()` waits for its `READ` to complete
+ (default 300). NIXL 1.3 has no transfer cancellation API, so a timed-out transfer's
+ buffers and registrations remain owned by the connector until NIXL reports a
+ terminal state; `close()` waits for that state before releasing them.
+ `VLLM_OMNI_NIXL_XFER_TIMEOUT_S` overrides it.
+
+### Source ownership and failure limits
+
+Claims and expiry use the same lock. Completion identifies both a payload
+generation and a unique READ claim; duplicate or stale ACKs cannot release a
+different reader or replacement payload. Replacing a claimed key is rejected.
+Terminal errors also release claims; timeout, unknown status, and active sibling
+transfers retain the complete destination bundle and source claim until terminal.
+Zero-element tensors retain their shape/dtype/skeleton positions but never create
+DMA descriptors or registrations.
+
+A lost metadata response, lost completion ACK, or abandoned consumer can retain a
+claim indefinitely. NIXL 1.3 cannot prove remote cancellation, so neither TTL nor
+`cleanup()` frees those allocations. Producer `close()` rejects new work and
+retains its agent, listener and claimed allocations; a later `close()` finishes
+teardown after claims drain. Permanently abandoned claims remain until process
+exit. This favors memory safety over bounded shutdown/memory usage. Only trusted
+peers may access this unauthenticated control plane. Both endpoints must use the
+claim-aware protocol; rolling compatibility with older GET_META clients is not
+provided. Legacy externally-owned metadata without a generation is accepted only
+under the caller's lifetime guarantee, not managed as a leased allocation here.
+
+## Validation
+
+The native CUDA smoke test requires NIXL and at least two visible CUDA devices:
+
+```bash
+UCX_RCACHE_MAX_UNRELEASED=1024 pytest -o addopts='' -q -s \
+ tests/distributed/omni_connectors/test_nixl_connector_native.py
+```
+
+It starts independent producer and consumer processes, loads the real UCX backend,
+performs the ZMQ `GET_META` handshake and a native NIXL `READ` from GPU 0 to GPU 1,
+checks mixed CPU/GPU tensor leaves from an `OmniPayloadStruct`, sends `XFER_DONE`,
+and verifies that producer registrations are released. This validates same-host,
+cross-GPU operation. Inter-node UCX transport is supported by the design but was not
+validated by this test.
+
+For more details, refer to the [NIXL repository](https://github.com/ai-dynamo/nixl).
diff --git a/docs/design/feature/prefix_caching.md b/docs/design/feature/prefix_caching.md
index a8fe4fc50..5aeef0f77 100644
--- a/docs/design/feature/prefix_caching.md
+++ b/docs/design/feature/prefix_caching.md
@@ -1,6 +1,5 @@
# Automatic Prefix Caching in Omni Models
-
---
## Table of Contents
@@ -9,6 +8,8 @@
- [High-Level Approach](#high-level-approach)
- [Example](#example)
- [What About Multimodal Inputs?](#what-about-multimodal-inputs)
+- [Implementation](#implementation)
+- [Related Files](#related-files)
---
@@ -27,6 +28,7 @@ vLLM implements automatic prefix caching for managing its kv-cache, which is bes
This document describes vLLM-Omni's mechanism for caching tensor outputs that are meant to be passed between stages, when requests have common prefixes, similar to the way in which vLLM has prefix caching for the kv-cache. This works in conjunction with vLLM's multimodal encoder caching, but is distinct. See the final section for a concrete example for how they tie together in practice.
### High-Level Approach
+
!!! note "Note 2"
Prior to reading this section, it's recommended to take a look at the design documents in vLLM for [Automatic Prefix Caching](https://docs.vllm.ai/en/latest/features/automatic_prefix_caching/), which will make some of the concepts more clear.
@@ -38,8 +40,10 @@ The main focus of vLLM-Omni's approach to prefix caching stage outputs is to bui
With this in mind, consider the set of blocks in a 2D layout, where the row represents the index of blocks being considered, and the columns represent the slots corresponding to tokens within each block. Since we know the `num_blocks` and `block_size` from our kv cache config, if we want to cache a tensor with feature size `D`, we can preallocate a CPU tensor of size `(num_blocks, block_size, D)`, and use the same block index and slot mapping to retrieve the corresponding feature vector.
+Host footprint: each cached key costs `num_blocks × block_size × D × dtype_bytes` of **pinned** CPU memory (pinned so device→host can overlap compute), allocated on the first `save_outputs` that sees the key — the first real request pays the `cudaHostAlloc`. Measured on a Qwen3-Omni deployment: thinker `__hidden_states__` `[15092, 16, 2048]` bf16 ≈ 0.92 GiB (plus the same again for each `hidden_states.layer_*` key a model exposes), Qwen3-TTS talker `codes.audio` `[16180, 16, 16]` ≈ 33 MiB. Budget host RAM for the stage accordingly.
### Example
+
!!! note "Note 3"
Prefix caching in vLLM-Omni currently is only supported on AutoRegressive stages with one kv-cache group. Configure it with the pipeline-wide `enable_prefix_caching` field in the deploy config.
@@ -56,7 +60,7 @@ The prefix cache flow is then outlined below.
2. Say we process the request `The quick brown fox was tired and slept beneath the shady tree`, which is 12 tokens and evenly divides into 3 blocks as shown below.
-```
+```text
[ The quick brown fox ] [ was tired and slept ] [beneath the shady tree ]
Block 1: |<--- block tokens ---->|
Block 2: |<------- prefix ------>| |<--- block tokens --->|
@@ -65,10 +69,9 @@ Block 3: |<------------------ prefix -------------------->| |<--- block tokens -
When the request processes, we inspect the multimodal outputs and identify the `mm_feature` tensor, which will be of shape `(seq_len, feature_dim)`, i.e., `(12, 16)` in this example. We note that the first axis is dependent on the `seq_len` and add a new cache_tensor of shape `(num_blocks, block_size, feature_dim)` to our multimodal cache for tensors.
+1. If we lay out the cache as a 2D tensor of shape (`num_blocks`, `block_size`), we'll have something like the following:
-3. If we lay out the cache as a 2D tensor of shape (`num_blocks`, `block_size`), we'll have something like the following:
-
-```
+```text
0: [ The quick brown fox ]
1: [ was tired and slept ]
2: [beneath the shady tree ]
@@ -78,7 +81,8 @@ When the request processes, we inspect the multimodal outputs and identify the `
```
Or, if we flatten it down to 1D,
-```
+
+```text
0: The
1: quick
2: brown
@@ -90,7 +94,8 @@ Or, if we flatten it down to 1D,
```
which we can think of as row indices into the hidden states tensor if we view it as the 2D shape `(num_blocks x block_size, feature_dim)`. That is, the analogous flattened (from 3D -> 2D) mapping of the cache for hidden states becomes the following.
-```
+
+```text
0:
1:
2:
@@ -103,10 +108,9 @@ which we can think of as row indices into the hidden states tensor if we view it
Similarly, for the multimodal outputs cache, the flattened coordinates are the same, but the `mm_feature` maps to vectors of length `16` instead of the hidden size of `2`. Note that in practice, we may have multiple multimodal output tensors per forward pass, which may have different names and different feature dimensions.
+1. Now, say that we receive a new request `The quick brown fox jumped over the dog`.
-4. Now, say that we receive a new request `The quick brown fox jumped over the dog`.
-
-```
+```text
[ The quick brown fox ] [ jumped over the dog ]
Block 1: |<--- block tokens ---->|
Block 2: |<------- prefix ------>| |<--- block tokens --->|
@@ -116,7 +120,7 @@ Here, we will have a cache hit for `Block 1` which will be detected by vLLM base
Since we have the block indices / slot mappings from the kv cache manager, we can simply mirror the mappings and leverage the same indices for the cached hidden states and multimodal outputs. This allows us to look up the correct tensors from our externally maintained 3D caches.
-```
+```text
0: [ The quick brown fox ] < already in the cache
1: [ was tired and slept ]
2: [beneath the shady tree ]
@@ -129,11 +133,11 @@ Since we have the block indices / slot mappings from the kv cache manager, we ca
Finally, to pass the full hidden states and multimodal outputs to the next stage, we simply concatenate the cached contents with the corresponding new tensors computed from the current forward call.
-
### What About Multimodal Inputs?
+
It's also useful to consider the case about how Omni prefix caching is handled when we have multimodal inputs that don't cleanly end on block boundaries, as well as how this works with multimodal encoder caching in vLLM. For example:
-```
+```text
[ Im0 Im1 Im2 Im3 ] [ Im4 Im5 foo ]
Block 1: |<--- block tokens ---->|
Block 2: |<------- prefix ------>| |<--- block tokens --->|
@@ -147,9 +151,9 @@ In reality, this isn't a big problem for correctness, because vLLM also maintain
- The hash describing the image data starting at position 0 and with length 6
- In vLLM's encoder cache, a mapping from the image hash above to the encoder output
-
To understand what happens, say we get the following input as a second request:
-```
+
+```text
[ Im0 Im1 Im2 Im3 ] [ Im4 Im5 bar baz ]
Block 1: |<--- block tokens ---->|
Block 2: |<------- prefix ------>| |<--- block tokens --->|
@@ -162,3 +166,162 @@ Because we have multimodal data in a scheduled span that isn't fully precomputed
When we pass our multimodal tensors to the language model component in the same stage, we'll then expect the same outputs, because the prefix caching behaviors in vLLM-Omni / vLLM match, so the LLM will use vLLM's KV cache manager's prefix caching to correctly handle the attention information for `Block 1` while calculating the outputs for `Block 2`, giving us the correct results for processing `Block 2` with the context of `Block 1`.
Finally, we look up the output hidden states/multimodal tensors corresponding to the prefix cache hit `Block 1` and concatenate it with the forward pass result to get the final result, which is expected to be identical to the full hidden states when prefix caching is disabled.
+
+### Implementation
+
+The block/slot model is `vllm_omni/core/prefix_cache/`.
+`OmniPrefixCacheManager` owns slot occupancy, the request-task table,
+hit spans, and merge.
+`OmniPrefixCacheController` moves data: a reusable `StagingBufferPool` for
+this step's device→host copy, and writes into the durable `PrefixBlockPool`.
+The state lock covers those tables only.
+
+Miss is not an error (this step's forward slice only). A hit span whose
+hidden rows are absent is fatal. For mm keys the rule is looser: a model may
+emit a key only for some requests, so a hit span with no rows behind an mm key
+reads zeros from the pool for those positions rather than raising. Abort still
+writes: once a hash entered this step's batch it must land in the cache.
+
+`enable_prefix_caching` is refused on KV-consumer stages (`kv_role` of
+`kv_consumer` or `kv_both`). KV received from a producer is reported as
+`num_computed_tokens` too, and the manager cannot tell it from a local hit.
+Producer-only stages are unaffected. Pooling stages never save, so they get
+no cache. This gate is one function, `stage_prefix_cache_config`, called by
+both the GPU and the NPU model runner at kv-cache init.
+
+Which stages may set `enable_prefix_caching: true`:
+
+| Stage | `enable_prefix_caching` | Why |
+| --- | --- | --- |
+| AR stage with one full-attention kv group whose hidden states / per-token mm feed the next stage (Qwen3-Omni thinker and talker) | supported | The case the cache is built for: full-prompt hidden states are merged from the pool on a hit. |
+| AR stage that sets `requires_full_prefix_cached_hidden_states = False`, optionally with `deferred_prefix_cache_mm_keys` (Qwen3-TTS talker, Higgs v3 talker) | supported | Hidden is not cached; deferred codec rows are written once on finish. |
+| Pooling stage | ignored | Never saves; the gate returns no config. |
+| `kv_role: kv_consumer` / `kv_both` | refused at kv-cache init (`OmniPrefixCacheUnmatchError`) | Producer KV shows up as `num_computed_tokens` and is indistinguishable from a local hit. |
+| Speculative decoding on the stage | refused at kv-cache init | Under async scheduling vLLM keeps `num_computed_tokens_cpu` optimistic (all drafts accepted) during the forward and corrects it afterwards; `step_slots_cpu` would mirror rows at the wrong slots. Not verified; refused as a whole. |
+| `prefix_match_unit` smaller than `block_size` | refused at kv-cache init | Sub-block hits make `num_computed_tokens` unaligned; the hit registry only mirrors whole blocks. |
+| Hybrid / sliding-window / multi-group kv cache (e.g. a talker with `attention_type: sliding_recompute`) | refused at kv-cache init | The cache mirrors exactly one full-attention block table. |
+| Attention backend whose kernel block size differs from `--block-size` (FlashInfer / FlashMLA / CutlassMLA with a block size they do not list natively), or decode context parallel | refused at first step (`FullAttentionGroupView`) | `step_slots_cpu` computes `table[req, pos // block_size] * block_size + pos % block_size` over allocator block ids; hybrid kernel blocks and DCP token striping change that row layout. FlashAttention / Triton accept any multiple of 16 and never split blocks. |
+| Codec decoder / Code2Wav stages (Qwen3-Omni stage 2, Qwen3-TTS stage 1) | keep `false` | Nothing downstream consumes their hidden states; the cache would only add device→host copies. Not validated. |
+| Diffusion stages | n/a | No vLLM KV cache to mirror. |
+
+Hit spans come from `scheduled_new_reqs` only, as in the pre-refactor cache:
+
+- A new request with a (partial) prefix hit is the normal path: the hit
+ blocks are read from the pool, the rest is this step's rows, and the
+ divergent tail is written under its own blocks. The gather starts on the
+ prefetch thread at `new_step_starts`; a same-step hit (vLLM hashes blocks
+ at schedule time, so `b` can hit blocks `a` computes in the same forward)
+ cannot plan until `a`'s write is registered and starts at `save_outputs`
+ instead. Either way it runs before the next step can hand those blocks
+ to a new tenant. vLLM frees blocks one step before `finished_req_ids`
+ arrives, so a finished request whose last step materializes late may
+ still find a hit slot reassigned. Each `(slot, key)` carries a write
+ version, bumped whenever a new write claims it. A planned read captures
+ that version and is registered in `_pending_reads`; a later write that
+ reclaims those slots copy-on-writes the still-`COMMITTED` rows into the
+ ref before it overwrites the pool, so a delayed fetch serves the original
+ tenant. A version mismatch with no preserved copy raises for live and
+ finished alike (the pool rows are a newer tenant's). Production defaults
+ stay opt-in until preempt/resume hit spans are reconstructed.
+- `async_chunk` continuation: when the next upstream chunk arrives, the same
+ request id re-enters `scheduled_new_reqs` with `num_computed_tokens` equal
+ to what it already computed itself. Ids already in `live_reqs` are skipped
+ for hit marking: those rows were delivered in earlier steps and re-emitting
+ them would duplicate output. A `delivered_upto` span for this case is
+ Phase 2.
+- Preemption + reschedule: vLLM resets `num_computed_tokens` to 0 on
+ preemption and re-runs prefix matching on resume, so the resumed request
+ can come back with a fresh hit. With the V1 model runner it arrives
+ through `scheduled_cached_reqs` (id in `resumed_req_ids`, `new_block_ids`
+ replaces the table); with the V2 runner it re-enters `scheduled_new_reqs`
+ while still in `live_reqs`. Neither path marks an omni hit span: the
+ resumed request gets only the rows it recomputes, and its still-open
+ deferred write keeps appending (a slot written twice keeps the later
+ chunk). Cache integrity holds either way — the hit blocks already have
+ rows, from this request or the one it hit. Stages that need full prompt
+ hidden states should be sized so preemption does not occur while prefix
+ caching is on. Same as before this refactor; tracked for Phase 2.
+
+Two write paths, split by `ModelCachePolicy.deferred_keys`:
+
+- Immediate (`JOIN_NEXT_STEP`): save launches a whole-step device→host into
+ a staging slot; the committer waits that event and writes the CPU pool.
+ The next save waits `done` (the pool write): a reused slot must never
+ leave a pending pool write behind, or a delayed hit read of the old rows
+ would find nothing recoverable.
+- Deferred (`JOIN_ON_FINISH`): mm whose first dim is this step's token count
+ stays on a per-request GPU clone; `_WriteChunk`s append across steps;
+ finish/abort (or GPU-byte-budget pressure) forces the copy. One open
+ WriteTask per request; a budget flush may close it mid-request, in which
+ case the next save opens a new one (`write_n` + 1) and a hit reads both,
+ so one long request cannot pin the whole budget.
+
+A `WriteTask` moves through `TaskState` only via `transition()`, one step at
+a time along a strict chain:
+`PENDING` (registered, not queued) → `QUEUED` (on a copy queue) →
+`COPYING` (one thread owns the copy stage) → `HOST_READY` (host rows ready) →
+`WRITTEN` (in the CPU pool); `FAILED` is reachable from any non-terminal
+state. Skipping a step raises. The worker claims `COPYING` in the same `_wake`
+critical section as the queue pop, so `escalate` never re-queues a task it can
+see is already claimed. `host_ready` / `done` are wait primitives set by the
+transitions into `HOST_READY` / `WRITTEN` / `FAILED`. GPU-clone bytes are
+charged once per clone on a `_BudgetTicket` pinned by every task that views
+it, and uncharged when the last holder releases (idempotent per tid).
+
+Mm whose first dim equals the unpadded scheduled length *or* the CUDA-graph
+padded length is registered on first sighting. Talker `codes.audio` is a
+cat of scheduled rows and stays unpadded while hidden is padded; both must
+open a pool key, whether immediate or deferred.
+Leftover mm (lists, `codes.ref`, any tensor whose first dim is not this
+step's token count) is copied to CPU at save without truncating that first
+dim. That leftover copy is the async-builder read replica for this step; it
+does not write the pool or carry abort/preempt occupancy.
+
+```python
+cache.register_policy(ModelCachePolicy.from_model(model)) # load_model
+cache.new_step_starts(scheduler_output) # before _update_states
+sid = cache.save_outputs(hidden, mm_outputs, num_tokens_unpadded=n,
+ num_tokens_padded=n_pad)
+outs = cache.materialize(sid, req_ids) # or discard_step(sid)
+```
+
+Each step id is consumed exactly once. `req_ids` must be a subset of the save
+snapshot. At most `staging_depth` unused step ids may exist at once: every
+`save_outputs` claims one staging slot, including saves with only leftover
+mm that copy no device→host page. A slot is also held by each immediate
+write that views it, until the committer's pool write. A later save waits
+for `materialize`/`discard_step` or that pool write to free a slot;
+`staging_claim_timeout_s` then errors with the unused ids and the task count.
+`join`/`join_host_ready` use the same bound and raise with the stuck task's
+id and state instead of hanging the caller.
+`materialize` may run on the async output builder after the engine has
+entered the next step; leftover mm (not written to the pool) is copied
+to CPU at `save_outputs` so the builder never reads live CUDA-graph
+buffers. See
+[Async Omni Output Materialization](omni_async_output_materialization.md).
+
+The cache is constructed only on the last pipeline-parallel rank
+(`_ensure_omni_prefix_cache`). Other ranks skip it: they never call
+`save_outputs`, so a hit table there would resolve to absent slots.
+
+Threads, locks, and what each may block on:
+
+| Thread | Role | May block on | Must not hold while blocked |
+| --- | --- | --- | --- |
+| Engine | `new_step_starts`, `save_outputs` | previous-step `join` (`done`) / finished-write `join_host_ready`; `reserve()` GPU-byte flush; staging-slot claim; `dispatch()` / finish-abort `escalate()` (eager mode: the copy + pool write run inline) | `_state_lock` |
+| Async output builder | `materialize` (may overlap the next engine step) | this step's `step_d2h_event`; `join` (`done`); deferred `fetch_host` | `_state_lock` |
+| Committer | `_worker_loop`: wait device→host / deferred copy / pool write | `_wake.wait`; `step_d2h_event` or copy-stream sync | never takes `_state_lock` |
+| Prefetch pool | hit-span gather during forward | `join` (`done`); deferred `fetch_host` | `_state_lock` |
+
+| Lock | Covers | Does not cover |
+| --- | --- | --- |
+| manager `_state_lock` | occupancy tables, step contexts, hit spans, task registration, pool-key publish (`install_key`) | join, GPU-byte flush, copy, `step_d2h_event` wait, pool-key allocation, eager `dispatch()` / `escalate()` |
+| controller `_lock` / `_wake` | task registry, queues, GPU-clone byte budget | device→host / pool-write body (released before `synchronize`) |
+| `WriteTask.lock` | `state`, `reassigned`, `append_chunk` | waiting on `host_ready` / `done` (those are events) |
+
+### Related Files
+
+- `vllm_omni/core/prefix_cache/`
+- `vllm_omni/worker/gpu_model_runner.py` (`_ensure_omni_prefix_cache`)
+- `tests/core/test_prefix_cache.py`
+- [Async Omni Output Materialization](omni_async_output_materialization.md)
diff --git a/docs/design/feature/realtime_ar_diffusion.md b/docs/design/feature/realtime_ar_diffusion.md
index 5963befe7..2f86744b1 100644
--- a/docs/design/feature/realtime_ar_diffusion.md
+++ b/docs/design/feature/realtime_ar_diffusion.md
@@ -143,8 +143,20 @@ fraction to select additional resident sessions up to the model-declared cap.
All model-owned reservations are deducted before the paged self-attention pool
is allocated.
-LingBot reports its persistent full-horizon image-condition tensor. DreamZero
-reports the measured 603 MiB per-session Wan VAE causal-convolution state.
+LingBot advances a session-owned causal Wan VAE encoder for each condition
+block: the opening pixel frame is followed by four zero pixel frames per later
+latent frame. It retains one current condition block, with separate committed
+and in-flight encoder histories so a failed block cannot overwrite committed
+context. The state reservation includes both encoder histories, derived from
+the encoder's convolution input grids, and the streaming decoder cache. These
+caches are released through the same close/reset hooks. Condition encoding
+currently requires the unpatched, non-tiled Wan encoder path; the existing
+decoder's tiling fallback remains independent. Real-weight GPU parity with a
+full-horizon encode still requires validation because the pointwise posterior
+projection now runs per block.
+
+DreamZero reports the measured 603 MiB per-session Wan VAE causal-convolution
+state.
## Concurrency and routing limits
@@ -178,6 +190,24 @@ For LingBot World v2:
- `lingbot_world/pipeline.py` constructs conditioning, owns small non-KV session state, and
produces one block plus the standard metadata envelope.
+## Ulysses sequence parallelism
+
+LingBot supports pure Ulysses sequence parallelism for both direct and
+AR-Diffusion execution. With Ulysses degree greater than one, hidden tokens,
+camera features, token-expanded timestep modulation, and RoPE tables are sharded
+together. Without SP, timestep modulation retains the frame-broadcast path.
+Self-attention performs the sequence-to-head all-to-all before reading or writing
+paged KV, while static text K/V uses the same local head shard. Text K/V shards
+own compact storage; cross-attention exchanges query/output layouts to use these
+shards. This retains the shared cache geometry and reduces text K/V storage at
+the cost of two all-to-all calls per layer.
+
+The output head projects local tokens before gathering the flow values. For
+the 14B model this reduces the gathered width from 5120 to 64; frame modulation
+uses each shard's global token offset, including shards that split a frame. Only
+`ulysses_mode="strict"` is supported; `advanced_uaa`, Ring, and AllGather-KV modes
+remain unsupported for this model.
+
## Non-goals
This contract does not currently provide:
diff --git a/docs/design/feature/sequence_parallel.md b/docs/design/feature/sequence_parallel.md
index d0328bcf6..7ae263b7d 100644
--- a/docs/design/feature/sequence_parallel.md
+++ b/docs/design/feature/sequence_parallel.md
@@ -77,7 +77,9 @@ Use it when plain Ulysses-SP would otherwise fail because:
- `auto_pad=True` pads sequence tokens in `_sp_plan` and requires attention backends that support `attention_mask`.
- `advanced_uaa` does not depend on mask-based token padding inside Ulysses attention. It is therefore a better fit for non-divisible head counts and uneven Ulysses shard sizes.
-- `auto_pad=True` remains incompatible with Ring attention because the ring backend does not consume `attention_mask`.
+- `auto_pad=True` remains incompatible with Ring attention because the ring backend does not consume arbitrary
+ `attention_mask` values. A model with contiguous suffix padding may instead publish `valid_kv_length`; Ring trims
+ each circulated K/V block against that global valid prefix.
- `advanced_uaa` is still experimental and hybrid mode remains limited by Ring's equal-shape requirement.
---
@@ -416,7 +418,7 @@ _sp_plan = {
| Constraint | Description |
|------------|-------------|
| **Attention Backend Compatibility** | The attention backends must support `attention_mask`. Currently only `TORCH_SDPA` and `FLASH_ATTN` (default for diffusion models) are supported. |
-| **Ring Attention Limitation** | Ring attention does not support `attention_mask`. Therefore, when using `auto_pad=True`, the combination of Ulysses + Ring attention is not feasible. |
+| **Ring Attention Limitation** | Ring attention does not support arbitrary `attention_mask` values, so `auto_pad=True` remains incompatible. Contiguous suffix padding is supported when the model publishes the global `valid_kv_length` prefix. |
1. Enable `auto_pad=True` for all sequence-dimension inputs in `_sp_plan`:
```python
diff --git a/docs/design/fullduplex-personaplex.md b/docs/design/fullduplex-personaplex.md
index 249554bbb..6d32cd8e3 100644
--- a/docs/design/fullduplex-personaplex.md
+++ b/docs/design/fullduplex-personaplex.md
@@ -20,7 +20,7 @@ The target is the engine-native path:
-> PersonaPlex Talker
-> streaming PersonaPlex Code2Wav
-> PersonaPlex data-plane projector
- -> response.audio.delta + response.audio_transcript.delta
+ -> response.output_audio.delta + response.output_audio_transcript.delta
```
The standalone `/api/chat` and `/v1/audio/duplex` server that accompanied the
@@ -197,7 +197,7 @@ model-neutral native results:
It owns per-request audio and text cursors so a cumulative output cannot replay
old audio. The generic projector turns each native result into the Realtime
-`response.audio.delta` / `response.audio_transcript.delta` pair (and
+`response.output_audio.delta` / `response.output_audio_transcript.delta` pair (and
`response.output_text.delta` for text) under one `response_id`; the full
mapping is the name map in the
[Realtime Duplex API](../serving/realtime_duplex_api.md) serving guide.
@@ -271,3 +271,111 @@ The required evidence is:
Audio that is empty, all zero, non-finite, or only a protocol `listen` event is
not a successful end-to-end result.
+
+### Paced multi-session load driver
+
+The existing driver keeps its two-session admission and slot-reuse validation
+when `--sessions` is omitted. Passing `--sessions N` selects a separate,
+bounded load mode; it does not replace the lifecycle test or change server
+admission capacity. Start an existing PersonaPlex server configured to admit
+the requested number of sessions, then run from a source checkout with the
+vLLM-Omni client dependencies installed:
+
+```bash
+python -m tests.e2e.online_serving.personaplex_realtime_duplex \
+ --model nvidia/personaplex-7b-v1 \
+ --input-wav /path/to/input.wav \
+ --sessions 2 --load-frames 1000 --drain-s 2 \
+ --server-revision SERVER_COMMIT --server-hardware A100-80GB \
+ --output-dir /tmp/personaplex-load-n2
+```
+
+Use a new output directory for every run. The driver refuses to overwrite an
+existing `load-result.json`. The server labels are supplied by the operator,
+not detected or verified by the client. Record the server command, package
+versions, checkpoint revision and hardware alongside a published measurement.
+
+All attempted sessions finish admission before admitted sessions share a
+common input start. A rejected session remains a failed row in the report;
+it is not removed from the denominator. Each session receives at most
+`--load-frames` input frames, without looping the WAV, and uses 80 ms pacing.
+A stalled send cannot cause a catch-up burst. The timeline records send
+schedule lateness so an overloaded client is visible rather than mistaken
+for a faster server. This mode measures steady concurrency, not join/leave
+interference; the original lifecycle mode still checks slot reuse.
+Load mode does not probe an additional N+1 session for `resource_exhausted`;
+the lifecycle mode owns that admission-limit check. A per-session
+`client_pacing_warning` and the summary's `client_pacing_warning_sessions`
+identify send-lateness p99 above one nominal frame (80 ms). This is a warning,
+not an extra acceptance gate: read it alongside server-facing measurements
+so a slow client is not mistaken for a slow server.
+
+The fixed drain window is part of the measurement configuration. Audio
+arriving during cleanup is excluded, so closing cannot rescue a session that
+missed its observation deadline. Cleanup is attempted for admitted sessions
+even when audio validation fails. Protocol errors, missing/invalid audio,
+shared response IDs and cleanup failures make the report fail and the CLI
+exit nonzero. Cancellation closes transports and propagates to the caller.
+`--cleanup-timeout-s` (default 5 s) bounds session-close send and acknowledgement
+together, independently of `--timeout-s`. The subsequent WebSocket close
+handshake has the same separate bound; an unresponsive close acknowledgement
+cannot hold cancellation for the full inference timeout.
+
+`load-result.json` retains per-session send and audio-packet timelines using
+the client's monotonic clock. It does not export handshake credentials or
+raw audio payloads. Metric definitions are explicit:
+
+Both modes validate each nonempty audio packet's strict base64 encoding, PCM16
+sample alignment, 24 kHz sample rate and response identity. Valid aggregate
+audio cannot hide a malformed packet. In load mode, rejected packets make the session fail
+and appear in `invalid_audio_packets` with their event index, receive time and
+fixed diagnostic code; they do not contribute samples or timing intervals.
+Empty audio deltas remain legal and contribute no samples. This check does
+not change the public client's decoding. The lifecycle scenario and dispatch
+remain the same, but malformed packets that aggregate checks once tolerated
+now fail that mode as well.
+
+The probe accepts both the current OpenAI Realtime audio event name
+`response.output_audio.delta` and the legacy `response.audio.delta`. Validation
+uses the wire payload directly rather than relying on a particular client
+library's alias table, so a server-side event-name migration cannot silently
+turn real audio into a zero-output measurement.
+
+| Field | Meaning |
+| --- | --- |
+| `client_audio_packet_interval_ms` | Consecutive nonempty packet arrivals: median, linear p99, max, and zero-based index of the first maximal interval. Interval index 0 ends at packet 1. One packet can contain multiple codec frames; this is not server tick latency. |
+| `client_first_audio_after_stream_start_ms` | First audio receipt minus first input send start; admission is excluded. |
+| `client_stream_rtf` | First input send start to last audio receipt, divided by received audio duration. This includes real-time input pacing and is not an inference-only RTF. |
+| `client_send_lateness_ms` | Send start minus its original shared schedule, clipped at zero. |
+| `frame_deficit` | Integer input-frame minus output-frame count from the shared session validation; missing when valid whole-frame accounting is unavailable. |
+| `output_frames`, `voiced_frames`, `silent_frames`, `frame_coverage_ratio`, `audio_rms`, `audio_chunks` | Session-validation statistics retained on success and on acceptance failure once computed. |
+| `acceptance_check` | Failed local check, with numeric diagnostics in `error`; other exceptions retain phase/type only, without remote handshake or metadata text. |
+| `audio_underrun_s`, `audio_underrun_event_count`, `audio_continuity_ok` | Shared `compute_continuity_stats` on valid PCM16 packet arrivals, with its default 100 ms underrun threshold; absent audio gives missing values rather than a continuity pass. |
+
+No output gives missing latency/RTF values, not zero. By default RTF is
+diagnostic; `--max-client-rtf` adds an explicit ceiling. The existing frame
+coverage, audible-frame and scheduler-data-plane checks still apply. A longer
+`--drain-s` can demonstrate eventual completion under backlog, but it must not
+be reported as real-time capacity when the fixed-window run failed or client
+RTF exceeded the stated target. Likewise, the first live session can pay
+one-time model/codec/JIT costs; either use the server's configured duplex
+warmup path or label that run as cold-start evidence before comparing steady
+capacity. Passing client-only metrics does not certify model quality or a
+real-time capacity target on another GPU. Begin with N=1 and N=2; higher N is a
+capacity experiment, not a promised property of an A100 or of this patch.
+Continuity is a client-arrival diagnostic, not proof of speaker playback and
+not an additional pass/fail gate. Packet-interval and send-lateness summaries
+retain their documented linear p99; the older client interval helper reports
+rounded nearest-rank p95 instead and is not substituted for that schema.
+
+Deterministic driver and localhost WebSocket checks need no model or GPU:
+
+```bash
+python -m pytest tests/e2e/online_serving/test_personaplex_load_driver.py \
+ -m 'core_model and cpu' --run-level core_model -q
+```
+
+These checks test the driver against controlled protocol responses. Run the
+load-driver command against real weights separately before reporting model-serving
+performance. Server tick timestamps and generic TTFT/TPOT aggregation remain
+the responsibility of the existing duplex metrics paths, not this driver.
diff --git a/docs/design/fullduplex.md b/docs/design/fullduplex.md
index 87fb1f79f..324cd5bdc 100644
--- a/docs/design/fullduplex.md
+++ b/docs/design/fullduplex.md
@@ -1,1070 +1,441 @@
-# MiniCPM-o 4.5 Full-Duplex Runtime Architecture
+# Unified Full-Duplex Framework
-> Relocation note: after the H20 validation recorded below, the full-duplex
-> implementation moved out of `vllm_omni/experimental/fullduplex/` into the
-> stable tree (`vllm_omni/engine/duplex/`, `vllm_omni/entrypoints/duplex/`,
-> `vllm_omni/model_executor/models/minicpmo_4_5/duplex/`, and companion
-> modules). Module paths in this document reflect the new layout; the runtime
-> evidence was produced on the pre-move tree, which is file-identical apart
-> from module paths.
+This document is the design record of the full-duplex serving framework:
+the engine-resident session model, the typed command/event contract, the
+single model plugin, and the serving/API surfaces built on top of them. It is
+the owner document for `vllm_omni/engine/duplex/**`,
+`vllm_omni/engine/duplex_omni_engine.py`,
+`vllm_omni/engine/duplex_orchestrator.py`,
+`vllm_omni/entrypoints/duplex_omni.py`, `vllm_omni/entrypoints/duplex/**`,
+`vllm_omni/clients/**` and the per-model `duplex/` packages.
+
+`vllm_omni/protocol/realtime/**` (the OpenAI Realtime API) and
+`vllm_omni/protocol/duplex/**` (vLLM-Omni's extension of it) are the shared
+wire codec duplex is built on. Duplex is its first consumer but not its owner: the codec
+is deliberately runtime-agnostic so a second Realtime surface can reuse it
+(RFC #6592 P0a).
+
+The public wire contract lives in
+[`docs/serving/realtime_duplex_api.md`](../serving/realtime_duplex_api.md);
+this page describes how it is produced.
## Purpose
-This document records the runtime architecture implemented by PR #3907, the
-cleanup applied after published commit `e011d936`, and the remaining work that
-must not be advertised as complete. Implemented contracts and future
-architecture work are called out separately below.
+A full-duplex model listens and speaks at the same time: audio streams into
+the model while it produces audio, the model decides when to speak, and the
+user can interrupt. Serving such a model needs a long-lived session with its
+own identity (epoch, turn), ledgers (input, response, playback, history), a
+lease (idle TTL, disconnect grace, resume) and a binding to the resumable
+stage requests that carry the session's audio through the pipeline.
-The checkpoint preserves the active runtime path already exercised on H20. It does
-not introduce another reducer, controller, worker provider, or shadow runtime.
+The framework gives that session exactly one owner, keeps the generic
+turn-based stack free of duplex code, and exposes the same session to three
+consumers: the OpenAI Realtime WebSocket route, the in-process Python API and
+the model plugins.
-## Review Snapshot
-
-- PR: `vllm-project/vllm-omni#3907`
-- Published head before this refactor: `e011d936`
-- Published base snapshot: `62589203`
-- Published diff: 93 files, approximately `+29.2k/-0.6k`
-- Local runtime and pytest validation: intentionally not used
-- Required validation environment: an NVIDIA H20 CUDA host
-- PR head reviewed for the current cleanup:
- `f4f78fa555af4d65e46b003022d352ca2ea9d703`
-- Validated tree: the uncommitted refactor synchronized file-for-file to the
- isolated H20 worktree on 2026-07-20
-
-The current tree has received fresh H20 focused and E2E validation against vLLM
-0.25.0. The final affected matrix includes the pre-response continuation and
-Stage1 CUDA Graph padding regressions. The checked-in MiniCPM profile admits
-two concurrent sessions through an engine-owned limit; a third
-session was rejected and capacity returned after one session closed. The two
-accepted sessions independently completed their audio, transcript, response,
-playback, and close lifecycles. This is isolation and admission evidence for the
-tested two-session deployment, not a production fairness, arbitrary-capacity,
-or failure-recovery claim. The validated paths include the MiniCPM duplex
-runtime extension, typed resumable policy, extracted control plane and clients, request
-preregistration, typed direct-output decision, engine-managed two-session
-capacity in the checked-in deploy profile, separate public/runtime configuration
-channels, private Session ledgers, ordered `session.update`, the MiniCPM runner
-fast path, continuous
-browser input, and the final Realtime input-lifecycle fixes. Exact evidence and
-scope limits are recorded below.
-
-## Scope
-
-The checkpoint keeps these verified contracts:
-
-- MiniCPM Stage0 conversation KV continuity;
-- Stage1 TTS and Token2Wav continuity;
-- model-owned listen/speak decisions on the normal auto-response path;
-- continuous browser PCM upload during assistant playback, without browser VAD
- or browser-generated input commits;
-- segment EOS and turn EOS as different boundaries;
-- transcript/audio cursors scoped to a response and turn;
-- playback acknowledgement and history commit;
-- Stage0 request metadata routed per batch row rather than inherited from the
- first request in the batch;
-- Stage0 token timing snapshots and client-observed audio chunk cadence;
-- 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 a process-shared Silero
- model and per-session VAD state;
-- existing JoyVL behavior.
-
-The checkpoint does not claim:
-
-- scheduler-native KV append;
-- 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;
-- audio/video synchronization beyond one camera frame per model unit (camera
- frames ride the audio appends as `video_frames`; the wire contract is in the
- [Realtime Duplex API](../serving/realtime_duplex_api.md) guide).
-
-## Active Runtime Path
-
-The actual path is:
+## Architecture
```text
-WebSocket
- -> OmniDuplexSessionHandler
- -> DuplexSession + MiniCPMO45ServingSessionState
- -> DuplexRequestClient
- -> AsyncOmni thin open/append/signal/close proxies
- -> DuplexControlClient over the engine-owned correlated RPC transport
- -> DuplexControlPlane + DuplexSessionRuntimeManager
- -> MiniCPM DuplexRuntimeExtension + engine session/stage bindings
- -> StagePool
- -> resumable scheduler request
- -> MiniCPM Stage0
- -> Stage1 TTS / Token2Wav
- -> output processor
- -> Realtime protocol projection
- -> WebSocket writer
+ Python users WebSocket clients
+ ┌──────────────────────┐ ┌────────────────────────┐
+ │ InlineDuplexClient │ │ DuplexClient │
+ │ (DuplexClientBase) │ │ (DuplexClientBase) │
+ └──────────┬───────────┘ └───────────┬────────────┘
+ │ DuplexSessionHandle │ /v1/realtime?duplex=1 (alias /v1/duplex)
+ │ ▼
+ │ ┌──────────────────────────────────────┐
+ │ │ OmniDuplexSessionHandler (thin) │
+ │ │ websocket I/O, command_from_realtime│
+ │ │ event.to_realtime(), attachment / │
+ │ │ resume tokens / replay journal │
+ │ └──────────────────┬───────────────────┘
+ ▼ ▼ DuplexSessionHandle
+ ┌──────────────────────────────────────────────────────────────────────────────┐
+ │ DuplexOmni(AsyncOmni) [entrypoints/duplex_omni.py, thin] │
+ │ open_session / get_session / resume_session / detach_session / close_session│
+ │ DuplexSessionHandle: submit(DuplexCommand) / events() -> DuplexEvent │
+ └──────────────────────────────────────┬───────────────────────────────────────┘
+ │ engine.open/close/resume/touch_session_async (RPC)
+ │ engine.submit_command_async (one-way)
+ ▼
+ ┌──────────────────────────────────────────────────────────────────────────────┐
+ │ DuplexOmniEngine(AsyncOmniEngine) [engine/duplex_omni_engine.py, thin] │
+ │ loads the plugin, validates session_mode, builds queue messages │
+ │ └─ DuplexOrchestrator(Orchestrator) [engine/duplex_orchestrator.py]│
+ │ template seams + DuplexStagePort over the shared stage machinery │
+ │ ├─ DuplexSessionManager admission, backpressure, lease reaper, │
+ │ │ open/close/resume/touch, command dispatch │
+ │ └─ DuplexSessionRunner ×N ONE session state (DuplexEngineSession), │
+ │ ordered mailbox, append planning + stage │
+ │ submit, output projection, response / │
+ │ playback / history, VAD, typed events │
+ │ plugin: DuplexModelPlugin (PipelineConfig.duplex_plugin, one/model) │
+ └──────────────────────────────────────────────────────────────────────────────┘
```
-The removed typed reducer/facade and worker-provider runtimes were not in this
-path. This refactor continues to modify only the path above.
+In each layer the duplex class extends the turn-based one, which extends
+the shared base. The dependency runs duplex -> turn-based and never the
+reverse, so the turn-based stack is unchanged and the duplex stack reuses
+its request machinery instead of duplicating it:
-## State Ownership
+```text
+OmniBase OmniEngineBase OrchestratorBase
+ ├─ Omni (sync, turn) └─ AsyncOmniEngine (turn requests) └─ Orchestrator (turn-based admission)
+ └─ AsyncOmniBase └─ DuplexOmniEngine (session msgs) └─ DuplexOrchestrator (hosts DuplexSessionManager,
+ └─ AsyncOmni (EngineClient) implements DuplexStagePort)
+ └─ DuplexOmni
-The architecture allows several state objects only when their facts are
-orthogonal.
+AsyncOmni._create_engine -> AsyncOmniEngine AsyncOmniEngine._create_orchestrator -> Orchestrator
+DuplexOmni._create_engine -> DuplexOmniEngine DuplexOmniEngine._create_orchestrator -> DuplexOrchestrator
+```
-| Owner | Authoritative facts |
+Each concrete class constructs its collaborator in an explicit factory
+method the base calls at the right moment: `OmniBase.__init__` calls
+`self._create_engine(...)`; `OmniEngineBase._bootstrap_orchestrator` calls
+`self._create_orchestrator(...)` on the orchestrator thread once the stages
+are initialized. `DuplexOmniEngine._create_orchestrator` passes the loaded
+plugin and the session runtime config to `DuplexOrchestrator` directly.
+
+### Design rules
+
+1. **One session authority.** The whole duplex session (config, epoch/turn,
+ ledgers, lease, stage request resources, model state, projection state) is
+ one object, `DuplexEngineSession`, owned by one `DuplexSessionRunner` on
+ the orchestrator loop. Nothing above the engine keeps session state beyond
+ a handle (session id, event queue, capabilities, public session object).
+2. **Typed contract.** Commands into a session are `DuplexCommand`
+ dataclasses (`engine/duplex/commands.py`); outputs are `DuplexEvent`
+ dataclasses (`engine/duplex/events.py`). `command_from_realtime()` and
+ `DuplexEvent.to_realtime()` derive the OpenAI Realtime JSON, so the wire
+ format is never hand-built, and the websocket handler and the inline client
+ share one conversion.
+3. **Generic bases and the turn-based classes have zero duplex vocabulary.**
+ `OmniBase`, `AsyncOmniBase`, `AsyncOmni`, `OmniEngineBase`,
+ `AsyncOmniEngine`, `OrchestratorBase` and `Orchestrator` carry only
+ template seams; `tests/engine/test_duplex_import_boundary.py` checks that
+ importing the turn-based stack loads no duplex module.
+4. **Deployment selects the serving stack.**
+ `vllm-omni serve` constructs `DuplexOmni` when the pipeline declares
+ `duplex_plugin` and the deploy configuration sets `session_mode: duplex`.
+ An explicit `session_mode: turn` selects `AsyncOmni` instead, without
+ changing the pipeline's duplex capability. In duplex mode the server exposes `/v1/realtime?duplex=1` (alias
+ `/v1/duplex`), `POST /v1/chat/completions`, `/v1/models` and `/health`, and
+ every other turn-based route reports "not available". The chat route uses
+ the ordinary chat service on the duplex engine, without a session, when
+ the plugin declares `DuplexCapabilities.supports_chat_completions`.
+ Turn-based deployments use the ordinary API initialization and retain
+ model capability checks and endpoint restrictions.
+5. **Serving is transport only.** The websocket handler does socket I/O,
+ wire-envelope validation, command translation, event rendering and the
+ attachment/resume/replay bookkeeping; it holds no session state.
+6. **No `typing.Protocol`** in the duplex surfaces: the plugin, data plane,
+ session state, PCM buffer, stage port and client transport seams are ABCs.
+7. **The wire codec is not duplex, and a wire type is not a mailbox message.**
+ The event and command *vocabulary* plus its wire rendering, the audio format
+ negotiation, the conversation-item rules and the error envelope are model-
+ and runtime-agnostic, and split along the three tiers
+ [`docs/serving/realtime_duplex_api.md`](../serving/realtime_duplex_api.md)
+ already defines: **Tier 1** (identical to OpenAI) is
+ `vllm_omni/protocol/realtime/**`; **Tier 2** (OpenAI names carrying our
+ extensions) and **Tier 3** (ours alone) are `vllm_omni/protocol/duplex/**`.
+ A Tier 2 class *subclasses* its Tier 1 twin and declares only what it adds,
+ so the OpenAI surface stays honestly pure --- `session.created` without
+ `resume_token`, `input_audio_buffer.append` without `video_frames`. Our
+ error **codes** are Tier 3 (`protocol/duplex/errors.py`); only the envelope
+ shape and OpenAI's three `error.type` classes are Tier 1.
+ What stays engine-side is everything that is *internal representation*
+ rather than contract: `DuplexCommand.payload()` renders the session runner's
+ mailbox dictionary, whose channel genuinely differs from the client event
+ (`session.update` and the three `conversation.item.*` commands all travel on
+ `turn.signal`); `engine/duplex/realtime_commands.py` decides which command a
+ decoded event becomes; `engine/duplex/realtime_events.py` holds the
+ session's projection state. The codec may not import the engine, the
+ entrypoints, `model_executor` or the clients
+ (`tests/protocol/realtime/test_protocol_import_boundary.py`), and there is
+ exactly one implementation of each codec behaviour
+ (`tests/protocol/realtime/test_realtime_codec_single_source.py`).
+ The dependency chain is `protocol/realtime` <- `protocol/duplex` <- engine /
+ entrypoints / clients, and a duplex consumer uses **only** the middle link:
+ `protocol/duplex/**` re-exports the Tier 1 names it does not extend, so a
+ helper that later needs a duplex-specific version (`convert_input_audio_with_rate`
+ resamples to MiniCPM-o's 16 kHz rather than the client's rate) is overridden
+ in one file instead of at every call site
+ (`tests/protocol/duplex/test_duplex_protocol_facade.py`).
+ `DuplexEvent` is therefore a plain alias of `RealtimeEvent` --- an event has
+ no engine-internal half --- while `DuplexCommand` is a real class, because a
+ command does.
+
+## Package layout
+
+```text
+vllm_omni/
+├── entrypoints/
+│ ├── omni_base.py OmniBase (+ abstract _create_engine)
+│ ├── async_omni_base.py AsyncOmniBase (output pump, _route_engine_message seam)
+│ ├── async_omni.py AsyncOmni (turn-based requests)
+│ ├── duplex_omni.py DuplexOmni, DuplexSessionHandle (thin pipe)
+│ ├── duplex/ SERVING (thin)
+│ │ ├── serving.py OmniDuplexSessionHandler
+│ │ ├── realtime_input.py RealtimeEnvelope (query rules, first message), parse_resume_request
+│ │ ├── session_attachment.py DuplexSessionAttachmentRegistry (resume tokens, replay journal)
+│ │ ├── audio_encoding.py encode_audio, injected into DuplexOmniEngine for the plugin's data plane
+│ │ ├── chat_completions.py DuplexChatCompletionsAdapter (/v1/chat/completions on a session per request)
+│ │ └── websocket.py websocket send/close/receive helpers
+│ └── openai/api_server.py builds DuplexOmni for duplex models; session-backed app state
+├── protocol/ SHARED WIRE CODEC (no engine / no model / no transport)
+│ ├── realtime/ the OpenAI Realtime API itself
+│ │ ├── events.py RealtimeEvent + OpenAI's 30 server events (22 ship as Tier 1;
+│ │ │ 8 have a Tier 2 twin in protocol/duplex)
+│ │ ├── commands.py RealtimeCommand + OpenAI's 10 client commands (8 Tier 1,
+│ │ │ 2 with a Tier 2 twin) --- wire_type + fields, no payload()
+│ │ ├── formats.py audio format spellings, what is supported, per-session validation
+│ │ ├── session.py session-object readers; RealtimeInputDefaults (append defaults)
+│ │ ├── items.py conversation item shape, truncation, transcripts, camera frames
+│ │ ├── audio_input.py decode_audio_append -> RealtimeAudioAppend; client speech hints
+│ │ ├── audio.py PCM / G.711 / WAV decode, resample, re-encode
+│ │ ├── errors.py RealtimeProtocolError (the envelope's exception type)
+│ │ └── capabilities.py RealtimeProtocolCapabilities: what one consumer can serve
+│ └── duplex/ Tier 2 + Tier 3, and the one door a duplex consumer uses
+│ ├── __init__.py re-exports the Tier 1 helper functions (audio, formats,
+│ │ items, session, capabilities) so the engine never
+│ │ imports protocol/realtime directly
+│ ├── events.py the whole 42-event vocabulary: 22 Tier-1 re-exports,
+│ │ 8 Tier-2 subclasses (session.created + resume_token, ...),
+│ │ 12 Tier-3 events (listen/speak, playback, resume, ...);
+│ │ DuplexEvent = RealtimeEvent
+│ ├── commands.py the whole 17-command vocabulary: 8 Tier-1 re-exports,
+│ │ 2 Tier-2 subclasses (append + hints, commit + final),
+│ │ 7 Tier-3 commands (barge_in, playback.ack, ...)
+│ └── errors.py our error-code vocabulary (Tier 3) + RealtimeProtocolError
+├── engine/
+│ ├── omni_engine_base.py OmniEngineBase (stage processes, orchestrator thread, queues, RPC)
+│ ├── async_omni_engine.py AsyncOmniEngine (turn-based request building)
+│ ├── duplex_omni_engine.py DuplexOmniEngine (session message surface, creates DuplexOrchestrator)
+│ ├── orchestrator.py OrchestratorBase + Orchestrator
+│ ├── duplex_orchestrator.py DuplexOrchestrator (+ DuplexOrchestratorRequestState; implements DuplexStagePort)
+│ └── duplex/
+│ ├── commands.py mailbox half: DuplexCommand.payload() + the mailbox `type`,
+│ │ paired with each protocol command; command_from_realtime
+│ ├── realtime_commands.py duplex binding of the codec: decoded event -> DuplexCommand,
+│ │ DUPLEX_REALTIME_CAPABILITIES, duplex_response_format
+│ ├── events.py re-export shim (DuplexEvent = RealtimeEvent) + the runner's
+│ │ epoch-filter sets DOMAIN_TERMINAL_EVENTS / MODEL_OUTPUT_EVENTS
+│ ├── realtime_events.py RealtimeProjectionState: internal event -> typed events
+│ ├── messages.py queue envelopes (Open/Close/Resume/Touch/Command/Result/Event), DuplexSessionError
+│ ├── config.py DuplexSessionConfig, DuplexCapabilities, ResponseCreateOptions
+│ ├── contracts.py DuplexFence (session_id, epoch, turn_id), stage request records, DuplexStagePort
+│ ├── plugin.py DuplexModelPlugin, DuplexModelSessionState, DuplexDataPlane, PcmAppendBuffer ABCs
+│ ├── turn_detection.py server-side VAD turn detector used by the session
+│ ├── audio.py compatibility re-export, via protocol/duplex
+│ ├── vad.py / intermediate.py
+│ └── session/ one engine-resident session and everything that runs it
+│ ├── engine_session.py DuplexEngineSession: ledgers, lease, fence, stage resources, append sequencing
+│ ├── runner.py DuplexSessionRunner (per-session mailbox on the orchestrator loop)
+│ ├── manager.py DuplexSessionManager (admission, backpressure, reaper, dispatch)
+│ ├── context.py DuplexSessionContext / DuplexRunState: what the runner shares with its components
+│ ├── emitter.py SessionEmitter: projection, epoch filter, domain effects of a terminal event
+│ ├── model_channel.py ModelChannel: submit an append, project stage output, continue a turn
+│ ├── control.py SessionControl: server VAD and the events that reconfigure it
+│ ├── append_task.py AppendAttempt: one append in flight and the rollback its failure owes
+│ ├── helpers.py pure reads and payload builders over a session
+│ ├── lease.py DuplexLeaseState (idle TTL, disconnect grace, resume generation)
+│ └── overlap_policy.py / commit_policy.py / playback_ledger.py
+├── config/stage_config.py PipelineConfig.duplex_plugin; DuplexSessionRuntimeConfig
+├── model_executor/models/minicpmo_4_5/duplex/plugin.py MiniCPMO45DuplexPlugin (+ data_plane, input, policy, ...)
+└── clients/
+ ├── duplex.py DuplexClientBase (ABC), DuplexClient (websocket), client-side events
+ ├── inline_duplex.py InlineDuplexClient (in-process, over DuplexOmni)
+ ├── minicpmo_4_5.py MiniCPM-o 4.5 session preset
+ └── personaplex.py PersonaPlex session preset
+```
+
+## Session identity
+
+- **The server allocates every session id.** `DuplexOmni.open_session`
+ generates `duplex-`; a `session_id` / `id` key inside the
+ Realtime session object is ignored (Realtime clients echo the session object
+ back). The allocated id reaches the client in `session.created` and is the
+ only handle for `session.resume`, `close` and every command.
+- **Ids are never reused** within an engine's lifetime, so the id alone
+ identifies a session. There is no incarnation counter: stage request ids are
+ `duplex-s..e..r.`, model-side per-session state
+ is keyed by `session_id` alone, and a command, close, touch or resume for an
+ id the manager no longer holds is answered with `unknown_session`.
+- **Staleness inside a live session is epoch-based.** `DuplexFence
+ (session_id, epoch, turn_id)` is an engine-internal identity: every stage
+ request is bound to the fence it was submitted under, cancels advance the
+ epoch, and tracked tasks re-check the epoch after each `await`.
+- **Resume identity** is `(session_id, resume_token, expected_lease_generation)`:
+ the token belongs to the attachment registry, the lease generation is the
+ engine-side compare-and-swap.
+
+## Session lifecycle
+
+```text
+open DuplexOmni.open_session(config)
+ -> engine.open_session_async(session_id, DuplexSessionConfig) [RPC]
+ -> DuplexSessionManager.open: admission (max_sessions, closing sessions keep their slot),
+ plugin.validate_client_extra_body / prepare_runtime_config, DuplexEngineSession,
+ Stage0 request id reserved (ensure_stage_request), DuplexSessionRunner.start()
+ -> first event: SessionCreated (announces the allocated id)
+command handle.submit(DuplexCommand)
+ -> engine.submit_command_async -> DuplexSessionCommandMessage on the request queue [one-way]
+ -> DuplexSessionManager.dispatch: unknown_session / input_backpressure checks, then runner mailbox
+output DuplexOrchestrator._intercept_stage_output -> runner.on_stage_output -> mailbox -> typed events
+ -> output_sink (DuplexSessionEventMessage) -> DuplexOmni._route_engine_message -> handle.events()
+detach DuplexOmni.detach_session -> touch(DETACH): engine-owned disconnect grace; expiry -> SessionExpired
+resume DuplexOmni.resume_session(expected_lease_generation) -> lease CAS; the existing handle is re-entered
+close DuplexOmni.close_session -> close RPC; the manager tears the runner down, then the stage cleanup
+ (abort submitted requests, release reserved ids), then SessionClosed, then the RPC result: seeing
+ the event means the admission slot is free (it is held until the cleanup succeeded)
+reap DuplexSessionManager.reaper_loop: idle TTL / disconnect grace expiry, cleanup retries
+```
+
+### Concurrency and ordering model of the runner
+
+Everything below runs on the orchestrator asyncio loop; there is no lock.
+
+- **Single writer per session.** `DuplexEngineSession` is mutated only by its
+ runner. Inputs reach the runner through one mailbox in this order: client
+ commands (from `_request_handler`, in request-queue order), stage outputs
+ (`on_stage_output` enqueues an internal item; it does not mutate the session
+ inline), timers (silence continuation, bounded auto-response) and internal
+ items (deferred overlap promotion, VAD-synthesized commits). The mailbox
+ worker processes items one at a time.
+- **Slow work is not awaited inline.** An `AppendAudio` handler reserves
+ bytes, then schedules a tracked append task on the per-session append tail:
+ the task awaits `_offload(decode/resample/VAD)` on a dedicated
+ `ThreadPoolExecutor` owned by the manager, then `plugin.plan_append`, then
+ `stage_port.submit`. The worker returns to the mailbox immediately, so a
+ later `CancelResponse` is not blocked behind an in-flight append.
+- **Re-validation after every `await`.** A tracked task captures `(epoch,
+ turn_id)` when it starts and re-checks them before the stage submit and
+ before committing ledgers; a task that finds the epoch advanced rolls back
+ its PCM reservation and exits.
+- **Cancel is atomic at the session.** `CancelResponse` / `BargeIn` advance
+ the epoch, cancel tracked append tasks, abort the owned stage bindings
+ through the stage port and emit the cancelled terminal events before
+ yielding. An append task already past its last check is harmless: its stage
+ request is bound to the old epoch's request id, which the abort covers, and
+ the base never emits session-owned outputs to a client.
+- **Ordered event stream.** Because the epoch bump and every emission happen
+ on one loop in program order, nothing can be emitted for an old epoch after
+ its terminal event; `runner.emit()` still drops a terminal carrying a stale
+ epoch and stamps `epoch` on every event for clients that filter
+ defensively. This is a statement about order, not about latency: the engine
+ output queue and each `DuplexSessionHandle` outbox are unbounded and FIFO,
+ so a cancellation is delivered behind whatever audio was already emitted for
+ the response it cancels. A client that must stop quickly cancels its own
+ playback on `response.done` / `audio.cancelled` rather than waiting for the
+ stream to drain. Bounding those buffers and letting an accepted invalidation
+ skip undelivered media is left to the follow-up RFC.
+- **Backpressure before the mailbox.** `DuplexSessionManager.dispatch`
+ checks `max_pending_input_bytes_per_session` and reserves a pending turn for
+ `Commit` before the put; a rejected command is answered with
+ `ErrorEvent(code="input_backpressure")`.
+- **Per-response stage metrics.** `_route_output` computes the per-segment
+ `StageMetrics` before calling `_intercept_stage_output`; the runner
+ accumulates the snapshot into the active response, so
+ `ResponseDone.stage_metrics` and `metadata.vllm_omni.stage_metrics` on the
+ wire keep their meaning.
+
+## Orchestrator seams
+
+`OrchestratorBase` keeps the generic machinery (request handler skeleton,
+abort, collective RPC, output loops, stage error and dead-replica handling,
+`_route_output`, stage forwarding, prewarm, cleanup, shutdown) and offers
+these seams. `DuplexOrchestrator` extends `Orchestrator` rather than sitting
+beside it: a seam it does not override falls through to the turn-based
+implementation, and `_dispatch_message` hands anything that is not a session
+message to `super()`:
+
+| Seam | `Orchestrator` | `DuplexOrchestrator` |
+| --- | --- | --- |
+| `_dispatch_message(msg) -> bool` | `add_request`, `streaming_update`, `add_companion_request`, `interaction` | open/command/close/resume/touch messages -> `DuplexSessionManager` |
+| `_background_tasks()` | — | `[manager.reaper_loop()]` |
+| `_shutdown_extensions()` | — | `await manager.shutdown()` |
+| `_on_stage_submitted(stage_id, request_id, replica_id, req_state)` | — | bind the forwarded stage request to the owning session |
+| `_intercept_stage_output(...) -> bool` | — | hand every session-owned output (Stage0 decision, Stage1 audio) plus its metric snapshot to `runner.on_stage_output`; `True` = do not forward |
+| `_handle_forward_failure(...) -> bool` | — | fail the owning session's response and close the session, keep the loop alive |
+| `_cleanup_request_ids(ids, abort=, release_owners=)` | ignores `release_owners` | brackets `super()` with `close_sessions_for_request_ids` / `defer_request_cleanups` / `finalize_closed_sessions` |
+| `OrchestratorRequestState.session_owned` | never set | set by `ensure_request`: no synthetic terminal, no client emission, no terminal re-forward, no auto-cleanup on finish |
+
+`DuplexOrchestrator` also implements the `DuplexStagePort` ABC the manager and
+runner use (`stage_count`, `sampling_defaults`, `ensure_request`, `submit`,
+`cleanup`, `abort_requests`): the resumable Stage0 request is preregistered at
+open with `DuplexOrchestratorRequestState(session_owned=True, session_id,
+fence)`, submitted with `submit_initial` on the first unit and
+`submit_update` afterwards, and the request's `streaming.bridge_states["duplex"]`
+carries the session identity (`session_id`, `fence`, `epoch`, `turn_id`,
+`model_turn_id`), the public session config and the server-owned runtime
+config for the worker-side model hooks.
+
+## Model plugin
+
+`DuplexModelPlugin` (ABC, `engine/duplex/plugin.py`) is the one class a model
+provides, selected by `PipelineConfig.duplex_plugin`; "is a duplex model" is
+`duplex_plugin is not None`. It merges the engine policy and the session
+policy that used to be two separately configured objects:
+
+| Half | Members |
| --- | --- |
-| `DuplexSession` | session state, epoch, turn, active/last response, pending input, overlap duration, playback cursor, history |
-| `MiniCPMO45ServingSessionState` | MiniCPM PCM buffer, deferred overlap payload, data-plane task, continuation effect counter |
-| `DuplexWebSocketActor` | inbound mailbox, outbound queue, writer, transport close state, effect task handles |
-| `NativeRealtimeSessionProtocol` | wire-only response/item projection and input-buffer projection |
-| `DuplexSessionRuntimeManager` | fence snapshot, append reservation, completed-operation cache, resource reservations, stage bindings |
-| `DuplexControlPlane` | control-message validation, transactional open/append/update/close, extension invocation, typed segment/output decisions |
-
-Important distinctions:
-
-- Realtime response IDs are projection caches, not domain decision sources.
-- Engine fences duplicate identity across a process boundary by design; they
- are immutable validation snapshots.
-- Stage bindings are engine resources, not serving response state.
-- `continuation_owner_id` scopes an effect counter either to a visible response
- or, before `response.created`, to an explicit model turn. A pre-response
- continuation also revalidates request, epoch, turn state, and model-turn
- identity immediately before append; it cannot survive turn completion.
-- runtime open/close acknowledgements are handler-local effect bookkeeping,
- not Session lifecycle.
-
-## Implemented Concurrency Contracts
-
-### Single inbound mailbox
-
-The Actor now uses one:
-
-```python
-asyncio.Queue[event]
-```
-
-This replaces competing control/input/event `Queue.get()` tasks and the
-deferred-event list while preserving WebSocket arrival order. A later close,
-cancel, or clear must not overtake earlier append, playback ACK, or
-session-update events. Slow runtime appends remain background tasks. Cancelling
-their asyncio task does not stop a synchronous callable already running in the
-default executor, so cancellation correctness is provided by the engine fence,
-not by `Task.cancel()`.
-
-Properties covered by focused tests:
-
-- every enqueued event is delivered exactly once;
-- terminal control preserves wire order while cancelling background work;
-- clear and cancel never scan or delete later mailbox events;
-- one writer owns all WebSocket sends.
-
-### Correlated RPC result router
-
-`AsyncOmniEngine` now has one consumer for `rpc_output_queue`. Results route by:
-
-```text
-("duplex", control_id)
-("collective", rpc_id)
-```
-
-The router replaces the global RPC lock. It supports out-of-order replies,
-timeout unregister, late-result rejection, fatal-error broadcast, and close
-unblocking.
-
-Additional lifecycle rules:
-
-- uncorrelated non-fatal errors are not broadcast to unrelated waiters;
-- a latched fatal rejects new calls before they enter the request queue;
-- `EngineDeadError` is broadcast directly to the RPC queue even though the
- orchestrator consumes that exception during teardown;
-- shutdown closes the router before joining the orchestrator;
-- failure to enqueue shutdown closes the request queue and still attempts a
- bounded orchestrator join;
-- `StageRuntime` cleanup runs only after the orchestrator has stopped, avoiding
- concurrent client teardown.
-
-The router removes wait-side head-of-line blocking. The orchestrator request
-handler remains ordered, but executor submission order is not itself a session
-ordering guarantee.
-
-### Extracted control ownership
-
-The generic engine mechanism and the duplex plugin are split by
-responsibility:
-
-| Module | Responsibility |
-| --- | --- |
-| `engine.messages` | ordinary engine queue messages and collective RPC envelopes |
-| `engine.rpc_result_router` | generic correlation routing through each result message's `rpc_correlation_key` |
-| `engine.duplex.contracts` | immutable duplex DTOs, extension/stage protocols, typed decisions, request-ID codec |
-| `engine.duplex.lease` | duplex lease activity, TTL/grace configuration, and expiry records |
-| `engine.duplex.messages` | identity fence and duplex control message/result envelopes |
-| `engine.duplex.runtime` | extension loading, validation, and compatibility exports |
-| `engine.duplex.session` | session resources, fences, append reservations, idempotency cache, binding cleanup |
-| `engine.duplex.control_plane` | control algorithms and the narrow `DuplexStagePort` used to submit and clean up stage requests |
-| `engine.duplex.control_client` | typed control-message construction and calls through the engine-owned correlated RPC transport |
-| `entrypoints.duplex_request_client` | deterministic request identity, client-state preregistration, acknowledgement validation, data-plane collection, rollback |
-
-`Orchestrator`, `AsyncOmniEngine`, and `AsyncOmni` retain explicit wiring and
-compatibility proxies. They no longer own the corresponding duplex algorithms.
-The ControlClient does not own waiters or consume the result queue;
-`RpcResultRouter` remains the single correlation owner for both duplex and
-collective RPC. The ControlPlane receives a narrow stage port rather than the
-Orchestrator object.
-
-Control-plane enablement is independent from extension presence.
-`PipelineConfig.duplex_control_enabled` determines whether an Orchestrator
-constructs a ControlPlane. This preserves extension-free
-`TURN_COMMIT_ONLY` deployments while allowing ordinary deployments to use a
-single `None` fast-path check. The MiniCPM pipeline explicitly enables control
-and separately selects its runtime extension.
-
-### Transactional open and append
-
-Opening a native session registers its engine state and reserves the Stage0
-request resource as one externally atomic operation. If capability, sampling,
-extension, or request-state initialization fails, the ControlPlane removes the
-session, reserved request resources, and any preregistered request state before
-returning the error. Cleanup distinguishes a reserved resource from a submitted
-request, so it does not decrement the running counter for unrelated work.
-Closing a session that never appended also removes the preregistered resource.
-
-Expiry and request-triggered cleanup use a two-step close. The runtime manager
-first marks the session closing but retains it in the admission set. Only a
-successful stage cleanup finalizes and removes the session. A blocked or failed
-cleanup therefore continues to consume its server-owned slot and is retried;
-replacement admission cannot overlap request or KV resources that are still
-owned by the closing session.
-
-Append uses prepare/submit/commit ordering. `prepare_append()` computes the
-next sequence values without mutating the session. Prompt planning and stage
-submission use that reservation, and only a successful submit commits
-`input_seq`, `input_turn_seq`, and the accepted fence. Serving uses a matching
-PCM reservation and consumes bytes only after the append acknowledgement;
-failure or a closed predecessor rolls the bytes back. An `operation_id` caches
-completed append results so a retry cannot submit the same physical append
-twice.
-
-### Ordered append and configuration effects
-
-Native append effects form a per-session tail. Each append awaits its
-predecessor before entering the engine, and `session.update` awaits that same
-tail before changing either the serving aggregate or engine configuration.
-Consequently, an append received before an update uses the old immutable
-sampling snapshot, while the first accepted append after the update uses
-the new generation.
-
-The engine increments `config_generation` when it accepts the replacement
-configuration. At the next append boundary, the control plane rebuilds sampling
-parameters from stage defaults through the model extension. It never mutates
-the snapshot of a segment already in progress.
-
-### Irreversible cancellation fence
-
-Every cancel control carries the cancelled fence and the next session fence.
-The `AsyncOmni` facade preserves both values across the serving,
-entrypoint, engine-message, and orchestrator boundaries. The orchestrator then
-atomically:
-
-1. advances the runtime state to the next fence;
-2. releases and aborts bindings owned by the cancelled fence;
-3. permanently rejects an append that arrives later with the old fence.
-
-Missing `next_fence` is rejected without releasing bindings; the engine never
-advances independently from serving. A late cancel cannot move a session
-backward when the runtime is already on a newer fence. Idle playback clear does
-not send an engine cancel because no runtime resource is being cancelled.
-
-`model_native_duplex` also validates its engine-client contract before opening
-the session. The open, append, signal, and close methods must exist and declare
-the required `fence` arguments; signal must additionally accept `next_fence`.
-An older or custom client that cannot preserve this boundary fails with
-`runtime_contract_invalid` instead of silently running an unfenced native
-session. The generic serving-session adapter retains its compatibility behavior.
-
-`DuplexFence` also carries a server-side session incarnation. Reusing a public
-session ID after close increments that incarnation, and resource request IDs
-include it after the first incarnation. A synchronous append from a closed
-session therefore cannot be accepted by a newly opened session with the same
-public ID. Stale close validates the full fence before removing the runtime
-registry entry or releasing bindings.
-
-## Implemented Serving Ownership
-
-### Session-owned response and overlap state
-
-The Actor no longer stores:
-
-- a Session reference;
-- lifecycle mirror state;
-- active or last response ID;
-- overlap duration;
-- response-in-progress or playback business predicates;
-- runtime open/close acknowledgements.
-
-`DuplexSession.begin_response()` owns both active and last response identity.
-The Session owns overlap accumulation/reset and playback state.
-
-One `native_response_in_progress()` predicate now covers:
-
-- active response ID;
-- active engine request ID;
-- uncommitted ACK-only playback;
-- active generic response task;
-- response-bound append tasks;
-- MiniCPM data-plane task;
-- assistant-generating turn state.
-
-The write-only `MiniCPMO45ServingSessionState.response_emitted` field and a dead
-data-plane task mirror were removed.
-
-### Response-scoped options
-
-`response.create` is represented by an immutable `ResponseCreateOptions` value.
-The Session reserves it for one response, applies it to a response-local copy
-when that response begins, and restores the base session configuration on
-completion, listen, cancellation, or failure. Instructions, voice, modalities,
-temperature, token limit, format, tools, and conversation metadata therefore do
-not mutate the permanent session configuration or leak into a later response.
-
-### Explicit event effects and pure writer
-
-Business paths call `emit_event()`. It serializes domain effects and outbound
-queue insertion with one session-local lock. Deferred append starts outside the
-lock so recursive event emission cannot deadlock.
-
-The writer only:
-
-1. dequeues an event;
-2. applies late stale filtering to streaming model output;
-3. projects the event to the selected wire protocol;
-4. sends JSON.
-
-The writer does not promote overlap, clear input, advance Session state, call
-the engine, or commit history.
-
-### Single terminal acceptance point
-
-Terminal events are accepted or rejected before domain effects run:
-
-```text
-response.done
-response.listen
-audio.cancelled
-input.cancelled
-session.closed
-```
-
-For a terminal carrying an epoch, that epoch must match the current Session.
-Stale cancellation therefore cannot reset overlap belonging to a later turn.
-Once a terminal is accepted and its effect is applied, the writer cannot revoke
-it because close or epoch state changed while it waited in the output queue.
-
-Streaming audio/text deltas still receive late stale filtering at send time,
-which prevents queued old audio from leaking after a cancel or epoch bump.
-
-## Runtime Type Boundary
-
-The stable kernel is deliberately limited to generic engine and scheduler
-mechanisms:
-
-```text
-vllm_omni.engine.messages # ordinary queue and collective RPC envelopes
-vllm_omni.engine.rpc_result_router # generic correlation routing
-vllm_omni.engine.async_engine_utils # ordinary engine lifecycle helpers
-```
-
-The full-duplex implementation lives in dedicated duplex modules:
-
-```text
-vllm_omni.engine.duplex.runtime
- # extension loading and validation
-vllm_omni.engine.duplex.contracts
- # immutable DTOs and narrow protocols
-vllm_omni.engine.duplex.lease
- # duplex lease primitives
-vllm_omni.engine.duplex.messages
- # duplex identity and queue envelopes
-vllm_omni.engine.duplex.session
- # session transaction implementation
-vllm_omni.engine.duplex.control_plane
- # engine-side control algorithms
-vllm_omni.engine.duplex.control_client
- # correlated control client
-vllm_omni.entrypoints.duplex_request_client
- # entrypoint request/output lifecycle
-```
-
-Stable engine and entrypoint modules import the duplex kernel eagerly at
-module load (top-of-file imports). Duplex behavior remains opt-in: the
-ControlPlane, ControlClient, and request client are *constructed* only on
-duplex-enabled deployments, and the model runtime extension still loads only
-through the dotted-string plugin path. The client-side package
-(`vllm_omni/clients/`, home of the public `DuplexClient`) is never imported
-by runtime code.
-
-`PipelineConfig.duplex_control_enabled` enables the generic mechanism, while
-`PipelineConfig.duplex_runtime_extension` separately selects the model adapter.
-The MiniCPM pipeline opts into both and installs
-`MiniCPMO45DuplexRuntimeExtension`; ordinary pipelines construct neither a
-ControlPlane nor a ControlClient. The model-neutral protocol exposes
-sampling-parameter configuration, append planning, and a typed stage-output
-decision. MiniCPM owns
-stage-specific overrides, PCM-to-token budgeting, force-listen payload policy,
-special-token interpretation, and `listen` response metadata. The generic
-Orchestrator does not parse MiniCPM session keys, `listen_token_id`, or construct
-MiniCPM metadata, and it does not import the adapter.
-
-Extension loading is fail-fast. Startup validates every required callable and
-the number and type of returned stage sampling parameters. Invalid custom
-extensions fail before a session can open.
-
-Direct listen/speak outcomes use `DuplexOutputDecision` on
-`OmniRequestOutput`. That typed envelope is authoritative over inner completion
-metadata for native projection, so a processed output cannot hide a listen
-decision merely because it retains unrelated multimodal metadata.
-
-Generic multimodal chunk accumulation lives in
-`vllm_omni.outputs.multimodal_accumulation`. It owns snapshot replacement,
-DELTA draining, and audio-finality interpretation; `OmniRequestState` only
-invokes that policy while assembling request output. Model output processors no
-longer need to add chunk-retention rules to the main output orchestration file.
-
-The strict chunk-transfer schema still contains `hidden_states.tts` as the
-generic AR-stage-to-TTS handoff and several MiniCPM streaming token IDs as
-compatibility wire fields. The latter are not a preferred generic model
-contract. Moving them behind a model-namespaced metadata envelope requires a
-versioned producer/consumer migration because unknown msgspec fields are
-rejected; silently deleting or renaming them in this checkpoint would break
-existing Stage0-to-Stage1 transfer.
-
-### Public and runtime configuration channels
-
-Control messages carry two independent snapshots:
-
-- `session_config` contains the public Realtime/session values used by serving
- and model prompt policy;
-- `runtime_config` contains server-derived MiniCPM sampling, scheduler, context
- reserve, prefix-budget, and resolved reference-audio values.
-
-The MiniCPM serving adapter derives `runtime_config` from deploy/model defaults
-and validated public options. Clients cannot provide private runtime keys in
-`session.create` or `session.update`; response-scoped `extra_body` drops those
-keys rather than turning them into engine policy. The ControlPlane constructs
-sampling parameters only from `runtime_config`,
-while append planning receives both snapshots explicitly. Raw Realtime
-`extra_body` is not used as the engine's sampling-policy channel.
-
-`session.update` prepares both candidates, validates the runtime candidate in
-the engine, and replaces the serving snapshots only after the engine ACK. A
-rejected candidate therefore leaves both the public configuration and runtime
-sampling generation unchanged.
-
-`DeployConfig.duplex_session` is an immutable, server-owned
-`DuplexSessionRuntimeConfig`. These limits are not read from client
-`extra_body` or session payloads:
-
-| Field | Default | Owner | Lifecycle or resource effect |
-| --- | ---: | --- | --- |
-| `idle_ttl_s` | `300.0` | Engine lease manager | Expires an attached or detached session after inactivity; `null` disables only idle expiry. |
-| `disconnect_grace_s` | `30.0` | Engine lease manager and API attachment registry | Bounds how long a detached session may retain engine resources before cleanup. |
-| `reaper_interval_s` | `5.0` | Orchestrator | Sets the cadence for lease expiry and pending cleanup retries. |
-| `resume_replay_ttl_s` | `60.0` | API attachment registry | Expires replay events retained for resume or takeover. |
-| `resume_replay_max_bytes_per_session` | `8388608` | API attachment registry | Bounds each session's replay buffer. |
-| `max_pending_input_bytes_per_session` | `16777216` | API session input ledger | Applies per-session byte backpressure before input is admitted. |
-| `max_pending_turns_per_session` | `4` | API session input ledger | Bounds queued turns that have not completed runtime processing. |
-| `max_sessions` | `1` | Engine session manager | Atomically enforces admission; closing sessions retain capacity until cleanup finalizes. |
-| `completed_append_cache_size` | `256` | Engine session manager | Bounds completed append idempotency records per session. |
-
-Deploy profiles may lower or raise these values to match scheduler capacity,
-but clients cannot override them. The engine remains authoritative for
-admission and cleanup ownership even when the API performs an early check.
-
-### Client-side companion package
-
-The public client surface ships under `vllm_omni/clients/` and stays on the
-consumer side of the boundary above: `vllm_omni.clients.duplex` provides the
-model-agnostic `DuplexClient` (typed events, per-response demux via
-`ResponseHandle`, `session.resume` with acknowledgement-based replay dedup)
-and the `EventCollector` helper used for timing and request-metric summaries.
-Model-specific session defaults live in thin per-model preset modules —
-`vllm_omni.clients.minicpmo_4_5.create_duplex_session_config()` and
-`vllm_omni.clients.personaplex.create_duplex_session_config()` — so the core
-client never imports model packages and the model duplex plugin packages
-never import the client (both directions are enforced by tests:
-`tests/engine/test_duplex_import_boundary.py`,
-`tests/entrypoints/openai_api/test_duplex_capability.py`, and the subprocess
-purity guards in `tests/clients/test_model_session_configs.py`).
-`examples/online_serving/barge_in_client.py` (with its flow companion
-`barge_in_client_flow.md`) is the runnable reference for the client library
-against a live duplex deployment. The wire vocabulary the client speaks —
-every `/v1/realtime?duplex=1` event with its OpenAI-compatibility tier and a
-JSON example, plus the native-to-Realtime name map — is catalogued in the
-[Realtime Duplex API](../serving/realtime_duplex_api.md) serving guide; that
-page derives from the normative contract table above and must be updated
-with it.
-
-## Generic-Path Cleanup
-
-Model-specific `MINICPMO45_PROFILE_LOGS` probes were removed from:
-
-- the generic AR scheduler;
-- orchestrator;
-- AsyncOmni;
-- the generic GPU AR runner.
-
-The cleanup removes the temporary serving and generic-runtime probes. The
-generic AR runner builds `DuplexSamplingRow` values only when the model exposes
-the optional `prepare_duplex_sampling()` hook, then invokes that hook exactly
-once before the normal sampler. Ordinary models do not scan request rows.
-MiniCPM owns force-listen logits, turn-ended state, and its row-local sampling
-policy behind that hook; the runner no longer reads `_minicpmo45_*` attributes
-or retries sampling after matching `TypeError` text.
-
-The scheduler no longer parses serving `session_config`, `extra_body`, or
-`duplex_stage_sampling_params`. Stage-specific overrides are materialized as
-ordinary `SamplingParams` by the model runtime extension before scheduler
-admission. Existing vLLM stop-token, EOS, and max-token handling defines each
-segment boundary; `resumable=True` parks the request for the next streaming
-update. The scheduler does not inspect a duplex dictionary, Realtime
-configuration, or MiniCPM sampling fields.
-
-The generic runner's unused `_duplex_force_listen_applied_segments` cleanup and
-the obsolete `streaming_accumulated_keys` accumulation hook were removed. The
-latter had no model producer after its upstream Qwen removal; streaming input
-still performs the ordinary replacement merge needed by active models.
-
-Local `turn.signal` transitions such as `user_started` and
-`assistant_started` no longer perform an engine RPC. Only cancellation and
-`session.update` cross the engine boundary because those operations change
-runtime resource identity or runtime configuration.
-
-During a stage transition, `StreamingInputState.source_token_decoder` exposes a
-short-lived, model-neutral decoder sourced from the upstream output processor.
-The MiniCPM Stage1 input processor consumes that capability for token-faithful
-delta transcripts. The generic orchestrator no longer installs or names a
-MiniCPM-specific bridge-state key.
-
-The stable API server imports the duplex serving package eagerly and inspects
-the deployment `session_mode` before constructing the duplex handler. Ordinary
-deployments do not create its registries and background state.
-For an enabled deployment, the client still selects the Realtime duplex route
-with `?duplex=1` (or an equivalent explicit true value). Model-name matching is
-not used for routing or native-runtime activation. MiniCPM clients explicitly
-set `extra_body.native_duplex=true`; the canonical way to do so is
-the `vllm_omni.clients.minicpmo_4_5.create_duplex_session_config()` preset,
-which the repository's barge-in example
-(`examples/online_serving/barge_in_client.py`) uses in its session payload.
-
-`DuplexRequestClient` derives the data-plane request identity from the accepted
-fence and preregisters a resumable `ClientRequestState` before the append crosses
-the engine boundary. The acknowledgement must return the same identity or the
-operation fails. Unregistered outputs, including names that merely use the old
-`duplex-` prefix, are dropped. Timeout, cancel, and close remove the
-preregistered state.
-
-## Why Scheduler Changes Remain
-
-Serving cannot preserve model KV after a segment ends. The scheduler must keep
-one resumable request in a waiting state and accept a later update:
-
-```text
-RUNNING
- -> segment stop
-WAITING_FOR_STREAMING_REQ (KV retained)
- -> next append
-RUNNING
- -> session close
-FINISHED (KV released)
-```
-
-Scheduler responsibilities are limited to resumable request state, runtime
-context update, stop/boundary handling, and final release. Scheduler must not
-own response IDs, playback, overlap, Realtime events, or model policy.
-
-## Serving and Compatibility Boundaries
-
-### Normative Realtime event contract
-
-The public contract in this section applies to `/v1/realtime?duplex=1`. Internal
-duplex event names such as `response.output_audio.delta` are projection inputs,
-not additional public aliases. Events for one attachment preserve mailbox
-order; replay after resume preserves the original event order and `event_id`.
-
-Accepted compatibility aliases are normalized before mailbox admission:
-
-| Canonical input event | Accepted aliases | Contract |
-| --- | --- | --- |
-| `input.text.append` | `input_text.append`, `push_text` | Appends text to the open input item. |
-| `input_audio_buffer.append` | `input.audio.append`, `push_chunk` | Appends audio; legacy aliases default to WAV when no format is supplied. |
-| `turn.signal` | `signal_turn` | Sends an explicit turn signal; it does not imply response creation. |
-| `playback.ack` | `audio.playback_ack` | Advances playback/history only for the identified response and attachment. |
-| `session.close` | `close_session`, `close` | Closes the session after previously admitted mailbox events. |
-
-The Realtime projector emits the following response lifecycle. Cardinality is
-scoped to one `response_id` unless stated otherwise.
-
-| Public event | Trigger and ordering | Cardinality |
-| --- | --- | --- |
-| `response.created` | First event for a visible assistant response; precedes its output-item, content-part, speak, text, and audio events. | Exactly once for every visible response. |
-| `response.speak` | The model selected speak; emitted after `response.created` and no later than the first `response.audio.delta`. It carries decision metadata, not transcript text. | At most once. |
-| `response.audio.delta` | Carries one ordered audio chunk. Every emitted audio delta is followed by a `response.audio_transcript.delta` for the same chunk. | Zero or more. |
-| `response.audio_transcript.delta` | Append-only transcript contribution paired with an audio delta; it may be empty for a text-less audio unit and must not repeat or overlap earlier text. | One per audio delta. |
-| `response.audio.done` | Closes the audio stream after its final delta and before response terminal events. It is omitted for a response with no audio or transcript. | At most once. |
-| `response.audio_transcript.done` | Contains the exact concatenation of all non-overlapping transcript deltas for the response. | At most once, and only for a non-empty transcript. |
-| `response.output_item.done` / `conversation.item.done` | Finalize the assistant item after content-part terminal events and before `response.done`. | At most once each. |
-| `response.done` | Terminal event for a created response; follows audio, transcript, content-part, and item terminal events. | Exactly once for every created response that reaches a terminal state. |
-| `rate_limits.updated` | Compatibility event emitted immediately after `response.done`; the current payload has an empty rate-limit list. | Once per emitted `response.done`. |
-| `response.listen` | The model selected listen at a model-unit boundary. It may be emitted without `response.created`; clients must not infer a missing or pending response from it. | Zero or more per session, at most once per model-unit decision. |
-
-Session lifecycle events (`session.created`, `session.updated`,
-`session.resumed`, `session.replaced`, `session.expired`, and
-`session.resync_required`) are session-scoped rather than response-scoped.
-`session.created` is emitted before held response output on a new attachment.
-Cancellation and truncation use terminal status/details on the affected
-response and never reuse that response ID for later model turns.
-
-Compatibility changes must update this table and its protocol contract tests in
-the same change. The golden transcript test requires joined
-`response.audio_transcript.delta` values to equal
-`response.audio_transcript.done.transcript`; response tests separately enforce
-at-most-once `response.speak` and terminal event cardinality.
-
-### Session internal ledgers
-
-`DuplexSession` remains the serving aggregate root and now composes
-`InputBufferState`, `ResponseState`, `PlaybackLedger`, and
-`ConversationHistory`. The ledgers are private. Read-only properties return
-tuples, snapshots, or mapping proxies, and serving modules mutate them through
-Session transition methods for request, response-turn, lifecycle, playback,
-capability replacement, and history. The Realtime session-update parser still
-normalizes individual fields before atomically retaining or rolling back the
-aggregate configuration. Further work may narrow the read-only compatibility
-surface, but it must not introduce another reducer or a second session state
-machine.
-
-### Realtime input ownership
-
-The browser is a continuous PCM producer. While the session is open and the
-microphone is unmuted, it sends `input_audio_buffer.append` every 200 ms,
-including while assistant audio is being generated or played. It does not run
-VAD and does not send `input_audio_buffer.commit`. Native Stage0 consumes the
-stream in approximately one-second model units; MiniCPM listen, speak, and turn
-EOS decisions advance the model conversation.
-
-Explicit Realtime clients may still use `input_audio_buffer.commit` to create a
-conversation item. The translator validates that wire input before producing a
-commit carrying `item_id`. Native auto-response may already have
-streamed those PCM samples into the runtime, so the runner accepts a validated
-commit even when no runtime-side chunk remains. A truly empty explicit buffer
-continues to return `input_audio_buffer_empty`.
-
-The commit-to-response contract is:
-
-| Session state at `input_audio_buffer.commit` | Runtime action | Required event outcome |
-| --- | --- | --- |
-| Auto-response disabled, valid idle input | Commit and retain the input item. | Emit `input_audio_buffer.committed`; emit no response until an explicit `response.create`. |
-| Auto-response enabled, valid idle speech | Submit the final native append for the current model turn. | Emit `input_audio_buffer.committed` before model output. Create a response only on the first visible text/audio output; a model listen decision may end with `response.listen` and no response. |
-| Auto-response enabled, short overlap during an active response | Discard the short overlap as an acknowledgement. | Emit the committed/no-response acknowledgement; do not create or defer a response. |
-| Auto-response enabled, meaningful overlap during an active response | Retain one deferred payload and promote it after the active response terminates. | Emit `input_audio_buffer.committed` with deferred metadata. A later response follows the same visible-output rule and never reuses the prior response ID. |
-| Explicit silence/noise (`is_speech=false`) | Clear the physical input without a runtime append. | Emit the committed/no-response acknowledgement followed by `response.listen`. |
-| Empty or invalid input | Reject without changing response ownership. | Emit one typed `error`; do not emit `response.created`. |
-| Previous response cancelled or truncated and cleanup complete | Apply the corresponding idle rule above. | The prior response remains terminal; any later visible output receives a new response ID. |
-
-Append tasks, explicit commits, cancellation, and deferred promotion are
-serialized by the session actor. Tests cover post-response commits, active
-playback deferral, truncated-response ownership, and two-turn
-response-required execution; the H20 validation record below is the golden
-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.
-
-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
-has already advanced the Session turn; clear, cancel, close, and successful
-promotion release the reservation.
-
-### Physical input and model-turn identity
-
-A Realtime input item, an optional `input_audio_buffer.commit`, and a MiniCPM
-model turn are intentionally different identities. The browser normally keeps
-one input stream open without commits. Native Stage0 evaluates that stream in
-approximately one-second model units. A sampled `<|turn_eos|>` closes the
-current model turn and allows a later streamed unit to start another turn.
-Explicitly committed input may also produce only model-listen decisions and no
-spoken response.
-
-Therefore neither `model_turn_id` nor response cardinality is derived from the
-number of physical inputs. `model_turn_id` advances at model EOS. The
-`response-required` fixture may require exactly one response per requested
-turn, but `model-policy` validation must accept additional model-owned responses
-and cannot assert cross-turn transcript independence through physical-input
-attribution.
-Before closing, the model-policy demo drains every created response through
-`response.done`, acknowledges every completed audio playback, and requires a
-bounded quiet interval with no newly created response. This validates lifecycle
-completion without suppressing or overriding the model's listen/speak policy.
-
-### Non-cancel signal semantics
-
-`turn.signal` remains a public compatibility surface for local Session and
-Realtime transitions. Serving forwards only cancellation and `session.update`
-to the engine. The engine rejects arbitrary non-cancel signal names instead of
-returning a misleading supported acknowledgement. `session.update` remains a
-real engine operation because it replaces the configuration consumed by later
-append plans.
-
-## Remaining Architecture Work
-
-The following work is deliberately not hidden inside this checkpoint.
-
-### Serving composition
-
-The generic runner and runtime bridge now depend on the model-neutral
-`ServingRuntimeAdapter` protocol and no longer import MiniCPM modules. The
-`MiniCPMO45ServingRuntimeAdapter` owns MiniCPM serving state, PCM preparation,
-data-plane projection, client/runtime configuration validation, prefix policy,
-and capability projection. `PipelineConfig.duplex_serving_adapter` explicitly
-selects its import path; `AsyncOmniEngine` and `AsyncOmni` carry that path to the
-API server, which passes it into the generic handler. The handler has no model
-default and fails startup when the duplex endpoint is enabled without an
-adapter. Tests may inject an adapter instance directly. Import-boundary tests
-verify that loading the generic runner, bridge, or handler does not load MiniCPM
-modules.
-
-Behavior is still assembled through `DuplexSessionRunnerMixin`,
-`NativeRuntimeBridgeMixin`, and `ChatFallbackProjectorMixin`, so explicit
-adapter composition is only the model-policy boundary, not a completed removal
-of host-method coupling. A later refactor may replace the remaining mixins with
-explicit protocol and effect-runner dependencies while preserving one actor
-mailbox and one `DuplexSession` transition owner. Splitting those components
-must not create a second reducer or concurrent state machine.
-
-The four private Session ledgers are data partitions, not independent owners.
-`DuplexSession` intentionally remains the aggregate root. Further encapsulation
-may narrow compatibility properties, but serving code must continue to perform
-state changes through Session transitions.
-
-### Plugin descriptor and typed payloads
-
-The pipeline currently selects the MiniCPM engine extension and Serving adapter
-through two explicit fields, but there is not yet one versioned plugin descriptor
-that binds those identities in an open handshake. Realtime client payloads do
-not select either implementation. Adding a second native model should first
-introduce that descriptor and reject serving/engine plugin mismatches explicitly.
-
-The stable boundary has typed fences, plans, policies, decisions, and control
-messages, but `session_config`, `runtime_config`, and append payloads still use
-generic mappings or objects at the extension boundary. Model-neutral typed
-session and input-chunk contracts remain follow-up work, especially before
-adding video.
-
-### Output modality policy
-
-The current Realtime duplex contract supports text and audio output. Unknown
-output modalities fail closed with `unsupported_response_modality`; they are
-never silently projected as text. This checkpoint deliberately does not add a
-modality registry because there is only one native text/audio projection path
-to register.
-
-A registry becomes justified when a second non-audio/non-text implementation
-uses the same validation, encoding, lifecycle, and projection contract. At that
-point the abstraction must be extracted from two working adapters and retain
-the same fail-closed behavior. A speculative registry before that condition
-would add selection state without removing any existing branch.
-
-### Performance measurement contract
-
-Stage0 token timing comes only from the engine's per-segment metric snapshot:
-`vllm_ttft_ms`, `vllm_tpot_ms`, and `vllm_itls_ms`. A resumable Stage0 request
-exports its segment-local `num_generation_tokens`; ordinary requests retain the
-existing output-token fallback. Realtime response state clears its metric
-accumulator at `response.created`, combines only model units owned by that
-response, and ignores session-level events with no matching `response_id`.
-Realtime projection carries the result under
-`metadata.vllm_omni.stage_metrics`; clients do not estimate TPOT from transcript
-deltas or subtract cumulative snapshots.
-
-Audio timing uses the client's monotonic receive clock and is partitioned by
-`response_id`. `response_created_to_first_audio_ms` measures projection startup
-after `response.created`; `commit_to_first_audio_ms` measures end-to-end output
-from an explicit input commit. Neither field is called TTFP without naming its
-origin. Reports also include inter-chunk and chunk-duration
-count/mean/p50/p95/max plus maximum chunk gap. Engine and client measurements
-have different clock origins and must not be merged into one timeline.
-
-### Stage1 CUDA Graph boundary
-
-Native Stage1 streaming advances Python-owned request generators and turn
-state on every scheduler step. A full-model CUDA Graph would execute that
-Python control flow only while capturing and then replay stale chunk and turn
-metadata, even if its host-to-device tensor construction were made
-capture-safe. The duplex deploy profile therefore keeps `enforce_eager=false`
-but selects `cudagraph_mode=PIECEWISE` for Stage1. Python orchestration runs on
-every step while fixed-topology compiled regions remain eligible for CUDA
-Graph capture. This is a model execution constraint, not an E2E-only override.
-
-### Resource capabilities
-
-At the time of this snapshot the checked-in MiniCPM duplex deploy profile set
-Stage0 and Stage1 `max_num_seqs` to two and configured the engine runtime
-manager with `max_sessions=2`; the duplex settings have since been folded into
-`vllm_omni/deploy/minicpmo_4_5.yaml`, which admits four sessions
-(`duplex_session.max_sessions: 4`, matching its stage `max_num_seqs`). The
-Realtime capability response advertises
-multi-session and same-replica multi-session support with
-`session_admission_mode="engine_managed"`. Client session fields cannot raise
-this server-owned limit. Fresh E2E evidence covers two concurrent sessions in
-both model-policy and response-required modes, rejection of a third session,
-and admission of a replacement after one accepted session closes. It does not
-establish behavior beyond that configured limit.
-
-Session leases, lease TTL, resumable attachment, orphan reaping, and bounded
-Serving input/backpressure are implemented. A production admission controller,
-KV-aware capacity budgeting, fairness, starvation metrics, and multi-session
-worker-failure recovery remain follow-up work. Capability claims must stay
-limited to the mechanisms and two-session execution shape validated here.
-
-## Validation Evidence
-
-All pytest and runtime evidence for this branch must run on the remote H20.
-
-### Current synchronized tree
-
-The current dirty tree was synchronized to the isolated H20 worktree at
-`/home/admin/workspace/aop_lab/model_runner_v2/vllm-omni-worktrees/pr3907-boundary-cg-0721`.
-
-- Final affected matrix: 661 passed, one network-dependent config test
- deselected, 20 warnings. It includes the complete MiniCPM native-duplex hook
- file and all control/session/handler/Orchestrator/import-boundary suites. The
- run is task `be221f67` (`/tmp/remote_gpu_logs/be221f67.log`). The deselected
- `test_resolve_when_autodetect_resolves_none` attempted a Hugging Face model
- lookup and hung in the isolated environment; the duplex deploy-config test
- passed independently.
-- Stage1 PIECEWISE CUDA Graph validation: the deploy-config assertion first
- failed with no Stage1 compilation config, then passed in task `3ed6091d`.
- Server task `95827027` started with `enforce_eager=False`, logged
- `cudagraph_mode=PIECEWISE`, and captured batch sizes 1, 2, and 4 without the
- CPU-to-CUDA capture error. The first concurrent E2E exposed graph padding as
- `request_token_spans` covering three valid rows in a four-row capture. A
- focused RED/GREEN now verifies both single-request and batched-request
- padding, and the full MiniCPM hook file passed 47 tests in task `e89dba88`.
- After the fix, two concurrent response-required sessions each completed two
- spoken responses, playback acknowledgements, and history lifecycles with no
- error or stale event. Artifacts are under
- `/tmp/pr3907_piecewise_response_required_2x2_fixed_20260721`.
-- An exploratory two-session, two-turn model-policy run still left one session
- waiting after its second input commit while the other session completed both
- turns. The same liveness symptom was reproduced with Stage1 eager mode before
- the padding fix, and no Stage1 fatal occurred after the fix. This run is not
- counted as PIECEWISE acceptance evidence; the remaining Stage0 resumable
- reactivation path requires separate diagnosis. Artifacts are under
- `/tmp/pr3907_piecewise_model_policy_2x2_fixed_20260721`.
-- Latest broad affected matrix before the final pre-response continuation fix:
- 608 passed, 19 warnings. This includes the
- Stage0 row-routing regression, cleanup-held admission regressions,
- segment-local token accounting, response-owned metric filtering, the generic
- output-processor suite, direct E2E script execution, and continuous-response
- overlap ownership. The run is task `e2146318`
- (`/tmp/remote_gpu_logs/e2146318.log`).
-- The pre-response continuation regression group passed 5 focused tests. The
- response-required two-turn scenario then passed 5/5 repeated runs in task
- two-session semantic isolation plus resume, takeover, and
- admission passed in task `49225794`; model-unit overlap passed in task
- `3888387d`. The final affected matrix above supersedes the earlier pending
- rerun requirement.
-- Serving plugin selection was first reproduced as three RED tests: the generic
- handler silently selected MiniCPM and the pipeline had no explicit Serving
- adapter field. The final pipeline/import/fail-fast group passed 9 tests in task
- `4db6d959` (`/tmp/remote_gpu_logs/4db6d959.log`). The token-metric and client
- audio-cadence producer-to-consumer group passed 8 tests in task `cf80f6b2`
- (`/tmp/remote_gpu_logs/cf80f6b2.log`).
-- Stable-import isolation was first reproduced as a RED that loaded twelve
- `experimental.fullduplex` modules. After extracting the kernel and making
- implementation loading conditional, the isolated import regression passed.
- The focused ControlPlane/ControlClient/session/runtime/Orchestrator/AsyncOmni
- suite then passed 139 tests in task `fad613ea`
- (`/tmp/remote_gpu_logs/fad613ea.log`).
- After the final contract-import cleanup, the import-boundary, runtime, and
- lease suites passed 45 tests in task `bc138887`
- (`/tmp/remote_gpu_logs/bc138887.log`).
-- Concurrency and failure-path hardening now has direct regressions for
- single-flight cancel/close cleanup, cleanup retry without dropping request
- resources, failed-replica affinity cleanup and same-address reattachment,
- append ordering across resume, takeover ownership of pending-turn
- reservations, and a closed-transport send immediately after resumable
- credentials are delivered. The final focused resume/takeover group passed 5
- tests in task `dc2ac96e` (`/tmp/remote_gpu_logs/dc2ac96e.log`).
-- Stage1 batching regression: the focused test first failed because only the
- first request in `runtime_additional_information` reached the talker. After
- routing every request by `request_token_spans`, preserving per-row terminal
- flags, and emitting request-keyed sparse audio, the complete MiniCPM duplex
- hook file passed 42 tests.
-- Stage0 batching regression: two requests in one batch now retain different
- `duplex_prompt_token_ids` and `special_token_ids`. The model emits row-aligned
- payload elements, so Stage0 no longer copies request zero's duplex metadata to
- later sessions.
-- Admission finalization regressions cover `max_sessions=1` with blocked and
- failed expiry cleanup plus request-triggered cleanup. Every replacement is
- rejected with `resource_exhausted` until stage resources are cleaned and the
- old session is explicitly finalized.
-- Resumable token accounting exports each model unit's native generation-token
- count instead of recounting the request's cumulative token IDs. Realtime
- response state resets and accumulates those segment snapshots, and the client
- ignores unowned session-level metrics while summarizing a response.
-- Engine-owned admission and two-session model-policy E2E: two random session
- IDs were admitted and remained isolated, a third was rejected with
- `resource_exhausted`, closing one accepted session returned capacity, and a
- random replacement session was admitted. The capability projection reported
- `configured_limit=2`, `advertised_multi_session=true`, and
- `session_admission_mode=engine_managed`. In the same run one session selected
- speak and the other selected listen without identity leakage. Detach/resume
- advanced attachment generation from one to two and rotated its token; active
- takeover rejected all four writes from the replaced connection. The run is
- task `1d9dd1ed` (`/tmp/remote_gpu_logs/1d9dd1ed.log`), with artifacts under
- `/tmp/pr3907_resume_takeover_native_metrics`.
-- Concurrent two-session, two-turn response-required E2E: both sessions produced
- two complete spoken responses, committed two playback acknowledgements and
- history records, and emitted no error, cancel, truncate, or stale events. The
- semantically distinct inputs produced isolated transcripts containing
- "当时" and "一加一" respectively. Per-response Stage0 output counts were 20,
- 38, 32, and 33 tokens rather than cumulative request totals. The run is task
- `a16f1467` (`/tmp/remote_gpu_logs/a16f1467.log`), with artifacts under
- `/tmp/pr3907_semantic_2turn_native_metrics`.
-- Disconnect-grace E2E: a detached session expired after the configured
- 30-second engine grace, and a later resume was rejected with
- `session_resume_expired`. The run is task `9872d50c`, with artifacts under
- `/tmp/pr3907_0719_disconnect_grace_expiry`.
-- A separate black-box takeover client exercised the current backend with two
- semantically distinct concurrent inputs. Active resume replayed 17 events
- including five audio deltas and committed playback history; takeover rejected
- all four stale-attachment sends, emitted `session.replaced`, and preserved
- attachment/session isolation. Both transcripts contained only their expected
- semantic token. The client task is `6773bd34`
- (`/tmp/remote_gpu_logs/6773bd34.log`), with artifacts under
- `/tmp/pr3907_gaohan_final_takeover`. The backend task was `5abcd1ee`.
-- Across the final response-required, overlap, and resume/takeover runs, engine
- Stage0 TTFT ranged from 31.17 to 310.18 ms, TPOT from 15.18 to 21.18 ms, and
- reported ITL p95 from 16.35 to 32.83 ms. Explicit
- `commit_to_first_audio_ms` ranged from 386.80 to 1033.25 ms, while the
- client-monotonic maximum audio chunk gap ranged from 388.30 to 1316.37 ms.
- These are response-scoped observations from a small, warm-state-sensitive
- sample, not an SLO claim. The slowest response still does not satisfy a 200 ms
- end-to-end first-output target.
-- Post-merge focused handler/demo/Web/Stage0/runner/video suite: 324 passed,
- 19 warnings. The run is task `f78cb7a8`
- (`/tmp/remote_gpu_logs/f78cb7a8.log`). The Web contract explicitly rejects
- browser VAD markers and browser-generated input commits while the merged
- video path retains camera-frame admission.
-- No-response listen and residual accounting: the focused handler regressions
- passed after first reproducing both failures. A model-listen unit without a
- Realtime response no longer defers the next committed input, and three
- consecutive 1.2-second `pcm_f32le` inputs each returned pending input bytes
- to zero.
-- Model-unit overlap E2E: the second physical input streamed while the first
- response remained active. Seven overlap decisions had
- `defer_runtime_append=false`; the one-second model-unit boundary occurred
- before the first `response.done`, and no barge-in, cancel, truncate, stale, or
- error event occurred. The model-policy contract accepts a later unit ending
- the continuous response instead of requiring a second physical-input-owned
- response. The two response-owned Stage0 snapshots independently reported 20
- tokens / 152.8 ms TTFT and 19 tokens / 43.3 ms TTFT. The run is task
- `a4977622` (`/tmp/remote_gpu_logs/a4977622.log`), with artifacts under
- `/tmp/pr3907_overlap_native_metrics_scoped`.
-- The split multi-session E2E runner is directly executable from the repository
- even when another installed package owns the top-level `tests` name. Its
- script-import and continuous-terminal regressions passed in task `f11b106c`.
-- Pinned response-required E2E: the first 1400 ms of the pinned fixture produced
- one complete spoken response, five audio/transcript deltas, and one playback
- history commit, with symmetric created/audio-done/done lifecycle and no
- duplicate `response.speak`. The client task is `a76dec52`
- (`/tmp/remote_gpu_logs/a76dec52.log`).
-- Continuous-input model-policy E2E: 36 browser-like 200 ms chunks produced
- seven model-listen decisions with zero input commits and no
- error/cancel/truncate/stale events. The client task is `fb256595`; artifacts
- are in `/tmp/minicpmo_pr3907_continuous_model_policy_20260718`.
-- Three-response continuous-input E2E: three consecutive 9469 ms streams,
- composed of the original fixture plus four seconds of silence, produced three
- spoken responses in one session. All three completed `response.done`, audio
- transcript, playback ACK, and history commit; the run had 22 audio/transcript
- deltas, zero input commits, and no error/cancel/truncate/stale events. This is
- lifecycle evidence, not semantic cross-input independence, because the same
- fixture was repeated. The client task is `2a5f29f0`; artifacts are in
- `/tmp/minicpmo_pr3907_continuous_three_response_20260718`.
-- The fresh validation backend was stopped after the E2E runs and GPU0/1 returned
- to baseline. Audio-quality and ASR evaluation were not rerun for these
- lifecycle-only changes. Other GPU workloads were not modified.
-
-### Regression lineage
-
-Earlier synchronized checkpoints passed broader affected matrices before the
-extension, policy, and ledger consolidation. Those runs were useful while
-developing the refactor but are superseded by the current file-identical H20
-matrix and E2E evidence above; they are intentionally omitted to avoid mixing
-different trees in the acceptance argument.
-
-### Focused suites
-
-- RPC router and AsyncOmni shutdown/error routing;
-- orchestrator fatal and duplex control paths;
-- WebSocket Actor mailbox and writer;
-- duplex protocol and Session ownership;
-- Realtime handler, terminal ordering, overlap, playback ACK;
-- scheduler resumable boundary;
-- MiniCPM runner and Stage0/Stage1 lifecycle.
-
-### E2E contract
-
-Run two deliberately separate fixture contracts:
-
-- model-policy uses three distinct inputs, no transcript hints, 200 ms
- real-time pacing, and continuous input with no browser commit. It allows
- natural listen or speak decisions and assumes no 1:1 mapping between
- physical inputs, model turns, and responses;
-- response-required uses `minicpmo_pr3907_jiayan_16k.wav` pinned to SHA-256
- `2e5fd4eb3ee434ce107ee3a0591fa624a33f7683c7462f45fe651c443c9af941`,
- sends its first 1400 ms, and requires one complete spoken response.
- It is an output-lifecycle fixture, not evidence that arbitrary no-hint,
- real-time input must select speak;
-- late and old-response playback ACK handling in the affected test matrix;
-- final model-policy response drain before `session.close`;
-- close/disconnect with no post-terminal audio;
-- transcript delta/final consistency;
-- generated WAV validity and external ASR sanity.
-
-Distinct-input fixtures must also be semantically distinct. Different files or
-waveform hashes containing the same greeting can legitimately produce the same
-reply and are not sufficient evidence for cross-turn transcript independence.
-
-For each run record the exact SHA/worktree, command, server log, client log,
-WAV path, and cleanup result.
-
-## Acceptance Criteria
-
-This checkpoint is ready to publish only when:
-
-- one mailbox passes exactly-once and wire-order tests;
-- cancel advances the engine fence even when the old append continues in an
- executor, and the next-epoch append remains accepted;
-- request completion removes MiniCPM helper and model-owned force-listen state;
-- RPC fatal/close wakes every waiter and no new work enters after terminal;
-- Session is the only serving response/overlap authority;
-- stale terminal events cannot mutate a newer epoch;
-- an accepted terminal cannot be dropped by the writer;
-- no duplicate `response.speak` or transcript delta appears;
-- Stage0 KV continuity and Stage1 turn reset pass;
-- the real H20 multi-turn audio E2E passes;
-- ordinary non-duplex paths in the affected matrix still pass;
-- no local path, proxy token, temporary profiling probe, or test-only switch is present.
-
-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 an acoustic-onset barge-in latency target across
-arbitrary client chunk sizes, multi-session production concurrency, bounded
-long-session KV, or scheduler-native append. Camera-frame input
-(`video_frames`) landed after this snapshot and is covered by its own E2E
-test rather than by the evidence above.
+| engine policy | `configure_sampling_params(runtime_config, defaults)`, `plan_append(...) -> DuplexAppendPlan` (the resumable Stage0 prompt for one unit), `decide_output(...) -> DuplexOutputDecision \| None` (e.g. the listen decision on a finished Stage0 segment) |
+| session policy | `capabilities(max_sessions)`, `validate_client_extra_body`, `prepare_runtime_config(config, model_config)` (server-owned runtime keys, reference audio resolution), `runtime_config_for_update`, `runtime_config_for_function_output`, `create_session_state() -> DuplexModelSessionState`, `data_plane: DuplexDataPlane` (projects raw stage outputs into internal events), `data_plane_context(...)` |
+
+`DuplexOmniEngine._validate_deployment` loads the plugin before any stage
+starts; `DuplexSessionManager.__init__` validates it against the stage
+sampling defaults once the stage pools exist. Plugin hooks that may block
+(`prepare_runtime_config` fetching `ref_audio`) are awaited in `open()` and
+offloaded from the loop.
+
+The MiniCPM-o 4.5 plugin (`model_executor/models/minicpmo_4_5/duplex/plugin.py`)
+is the only integration in this framework version. PersonaPlex and Nemotron
+VoiceChat still carry their pre-framework duplex code (runtime extension plus
+serving adapter) and are therefore not served over this framework yet: their
+pipelines declare no `duplex_plugin`, so they run turn-based until the
+follow-up PRs port them (RFC vllm-omni#7181, PR 2/3).
+
+## Serving
+
+`OmniDuplexSessionHandler.handle_realtime_session(websocket)`:
+
+1. accept; build the `RealtimeEnvelope` (autostart / default session from
+ query params, `event_id` correlation, error rendering through the typed
+ `ErrorEvent`);
+2. handshake: first message `session.update` -> `omni.open_session(session)`
+ (the id inside the payload is ignored; the allocated id is announced in
+ `session.created` together with `resume_token` / `attachment_generation`
+ when the model supports resume); `session.resume(session_id, resume_token,
+ last_received_server_event_seq)` -> `attachment.authenticate_resume` ->
+ `omni.resume_session`;
+3. reader loop: JSON -> `RealtimeEnvelope.translate` (`translate_realtime_command`
+ with the session's declared audio defaults) -> `handle.submit`;
+ envelope-level errors (invalid JSON, oversize frame, unknown type,
+ event acks) are answered locally;
+4. writer pump (session-scoped, survives reconnects): `async for ev in
+ handle.events(): attachment.send_event(ev.to_realtime())`, journaling
+ for replay until the journal overflows (`session.resync_required`);
+5. disconnect: a resumable session is detached (`attachment.detach` +
+ `omni.detach_session`, engine-owned grace); a superseded socket's
+ disconnect is a no-op; a non-resumable session is closed. Takeover sends
+ `session.replaced` to the old socket and closes it.
+
+## Validation
+
+- CPU suites: `tests/engine/duplex/**` (session, lease, manager, runner,
+ plugin, events, commands), `tests/engine/test_duplex_orchestrator.py`,
+ `tests/engine/test_duplex_omni_engine.py`,
+ `tests/entrypoints/test_duplex_omni.py`,
+ `tests/entrypoints/duplex/test_duplex_serving.py`,
+ `tests/entrypoints/openai/test_duplex_session_attachment.py`,
+ `tests/entrypoints/openai_api/test_duplex_api_server.py` (the duplex
+ server: pipeline probe, app state, routes, warmup gate),
+ `tests/entrypoints/duplex/test_chat_completions_adapter.py`,
+ `tests/clients/**`, `tests/engine/test_duplex_import_boundary.py`,
+ `tests/model_executor/models/minicpmo_4_5/duplex/**`,
+ `tests/worker/test_native_duplex_hooks.py`.
+- GPU: `examples/online_serving/minicpmo/realtime_duplex_demo.py` (one turn
+ over the websocket), `examples/online_serving/barge_in_client.py` (cancel
+ during playback; `--inline` drives the same scenario through
+ `InlineDuplexClient`), `tests/e2e/online_serving/run_minicpmo_realtime_duplex_multi_session.py`
+ (admission, resume, takeover, expiry).
diff --git a/docs/design/index.md b/docs/design/index.md
index cff0b1eb3..f70571cb5 100644
--- a/docs/design/index.md
+++ b/docs/design/index.md
@@ -26,6 +26,7 @@ implementation contract; it is not, by itself, a general support claim.
- [Async Omni Output Materialization](feature/omni_async_output_materialization.md)
- [Runner-to-model Prefill/Decode Phase Contract](feature/preprocess_phase_contract.md)
- [Automatic Prefix Caching in Omni Models](feature/prefix_caching.md)
+- [Model-local KV Caches](feature/model_local_kv_caches.md)
- [Realtime AR-Diffusion Sessions](feature/realtime_ar_diffusion.md)
### Communication
@@ -35,6 +36,7 @@ implementation contract; it is not, by itself, a general support claim.
- [Mooncake Store Connector](feature/omni_connectors/mooncake_store_connector.md)
- [Mooncake Transfer Engine Connector](feature/omni_connectors/mooncake_transfer_engine_connector.md)
- [Mori Transfer Engine Connector](feature/omni_connectors/mori_transfer_engine_connector.md)
+- [NIXL Connector](feature/omni_connectors/nixl_connector.md)
- [Shared Memory Connector](feature/omni_connectors/shared_memory_connector.md)
- [Yuanrong Store Connector](feature/omni_connectors/yuanrong_connector.md)
- [Yuanrong Transfer Engine Connector](feature/omni_connectors/yuanrong_transfer_engine_connector.md)
diff --git a/docs/design/metrics.md b/docs/design/metrics.md
index 85eb7ac38..874cbd310 100644
--- a/docs/design/metrics.md
+++ b/docs/design/metrics.md
@@ -60,7 +60,7 @@ Upstream per-engine metrics retain the `vllm:` prefix but are now registered by
There are five independent paths for metric collection.
-**Path 1: Pipeline-level metrics (`vllm_omni:*`)**
+#### Path 1: Pipeline-level metrics (`vllm_omni:*`)
`OmniPrometheusMetrics` registers the Gauge / Counter / Histogram collectors at import time. It is instantiated once per entrypoint, labeled with the model name. The entrypoint calls its methods as requests progress:
@@ -69,7 +69,7 @@ There are five independent paths for metric collection.
- `request_failed()` — recorded by the cleanup path when a request exits without natural completion. Internally maps to `finished_reason="abort"` on the existing completion Counter.
- `inc_requests_failed(reason)` — increments the dedicated failure-attribution Counter once per request. Reasons are normalized to the bounded set `client_abort`, `client_disconnect`, `stage_error`, and `unknown`.
-**Path 2: Image and diffusion metrics**
+#### Path 2: Image and diffusion metrics
Image and diffusion metrics use two cooperating collectors. `OmniPrometheusMetrics` owns pipeline/stage workload families, while `OmniModalityMetrics` owns per-replica diffusion breakdowns. Finished stage messages are consumed once in `OmniBase._process_single_result`, which prevents replayed terminal messages from incrementing Counters or Histograms twice.
@@ -81,15 +81,15 @@ Image and diffusion metrics use two cooperating collectors. `OmniPrometheusMetri
Zero and missing values have different meanings. Valid zero-duration queue observations are recorded, while unavailable optional measurements are omitted instead of being exported as synthetic zeroes. `peak_memory_mb`, `diffusion_forward_s`, `diffusion_kv_load_s`, `vae_decode_s`, and `kv_wait_s` therefore appear only when their data source is active.
-**Path 3: Audio modality metrics (`vllm_omni:audio_*`)**
+#### Path 3: Audio modality metrics (`vllm_omni:audio_*`)
`OmniModalityMetrics` registers seven audio families with `{model_name, stage, replica}` (plus an extra `threshold_ms` / `reason` label on the two extra-cardinality Counters). Three observation sites:
- `observe_modality_at_finalize(...)` — called from `omni_base._process_single_result` inside the existing `e2e_done` finalize guard. For `output_type == "audio"` it emits `audio_frames_total`, `audio_duration_s`, `audio_rtf` (or `audio_skipped_requests_total{reason="no_audio_data"}` when no audio was produced). Sample rate is resolved from `engine_outputs.multimodal_output` via `definitions.resolve_audio_sample_rate(...)` (fallback chain mirrors `serving_chat.py`'s audio response path).
-- `observe_audio_first_packet(...)` — called from the OpenAI SSE audio branch in `serving_chat.py` on the first audio packet for a request. The once-per-request guard is held by `ClientRequestState.first_audio_ts`. The `request_arrival_ts` anchor is stored in `ClientRequestState` by `async_omni.generate()`, computed at request entry.
-- `observe_audio_streaming_finalize(...)` — called from `serving_chat.py` after the streaming chunk loop exhausts. It runs the per-chunk player simulation from `vllm_omni/benchmarks/audio_continuity.py` to compute the worst-case underrun and emits `audio_underrun_s` plus (when the request stayed below the threshold) `audio_continuity_ok_total{threshold_ms}`. Per-chunk PCM byte counts and arrival timestamps are recorded by the same audio branch that updates `first_audio_ts`.
+- `observe_audio_first_packet(...)` — called from the OpenAI SSE audio branch in `serving_chat.py` and the streaming TTS output paths in `serving_speech.py` on the first non-empty audio payload for a request. Streaming Speech observes the first PCM payload rather than a header-only WAV chunk. HTTP carries the middleware request timestamp through response generation; WebSocket uses the start of each sentence synthesis request. Non-streaming Speech does not emit TTFP and uses `e2e_request_latency_s` for overall latency instead.
+- `observe_audio_streaming_finalize(...)` — called from `serving_chat.py` and `serving_speech.py` after a streaming chunk loop exhausts normally. It runs the per-chunk player simulation from `vllm_omni/benchmarks/audio_continuity.py` to compute the worst-case underrun and emits `audio_underrun_s` plus (when the request stayed below the threshold) `audio_continuity_ok_total{threshold_ms}`. Per-chunk PCM byte counts and arrival timestamps are recorded by the same output paths that update TTFP. Speech records raw PCM payloads before raw/SSE/WebSocket encoding, excludes WAV headers and empty chunks, and does not emit continuity samples for failed, cancelled, or non-streaming requests.
-**Path 4: Cross-stage transfer metrics (`vllm_omni:transfer_*`)**
+#### Path 4: Cross-stage transfer metrics (`vllm_omni:transfer_*`)
`OmniTransferMetrics` registers four Histogram families with `{model_name, from_stage, from_replica, to_stage, to_replica}` labels. Each observation corresponds to one physical transfer hop (one chunk between adjacent stages), not the per-request accumulated total — so the histograms track per-transfer distribution.
@@ -100,7 +100,7 @@ The hook lives in `OrchestratorAggregator.record_transfer_tx` and `record_transf
Defensive fail-safe: if `transfer_emitter` or `replica_resolver` is missing, or the resolver returns `None` for either side, the emit is skipped silently (the underlying `TransferEdgeStats` accumulation is unaffected).
-**Path 5: Per-engine metrics (`vllm:*`, stage/replica wrap)**
+#### Path 5: Per-engine metrics (`vllm:*`, stage/replica wrap)
The Orchestrator instantiates `OmniPrometheusStatLogger` (a thin subclass of upstream `vllm.v1.metrics.loggers.PrometheusStatLogger`) and feeds it scheduler stats and iteration stats after processing each batch of engine outputs. This populates the standard ~37 vLLM metric families (TTFT, ITL, TPOT, KV cache usage, etc.) using the same upstream code path — but with the `engine` label reshaped into `stage` + `replica` so multi-replica deployments produce distinct series per replica. See the next section for the wrap mechanics.
@@ -174,7 +174,7 @@ second.
### Pipeline (4)
| Metric | Type | Labels | Description |
-|--------|------|--------|-------------|
+| -------- | ------ | -------- | ------------- |
| `vllm_omni:num_requests_running` | Gauge | `model_name` | Requests currently executing across all stages |
| `vllm_omni:num_requests_waiting` | Gauge | `model_name` | Requests queued but not yet scheduled |
| `vllm_omni:requests_success_total` | Counter | `model_name`, `finished_reason` | Total requests by completion reason ({stop, length, abort, ...}); aborts cover client-disconnect / cancellation paths in addition to upstream `FinishReason.ABORT` |
@@ -183,7 +183,7 @@ second.
### Image and diffusion service-level metrics
| Metric | Type | Labels | Description |
-|--------|------|--------|-------------|
+| -------- | ------ | -------- | ------------- |
| `vllm_omni:stage_gen_time_s` | Histogram | `model_name`, `stage`, `stage_type` | Stage submit to finished output; includes in-stage queueing |
| `vllm_omni:request_queue_wait_s` | Histogram | `model_name` | Orchestration-layer queue wait; a present zero is recorded |
| `vllm_omni:stage_waiting_requests` | Gauge | `model_name`, `stage` | Sum of the latest waiting snapshots across live replicas in the stage |
@@ -200,7 +200,7 @@ second.
Labels: `{model_name, stage, replica}`.
| Metric | Type | Description |
-|--------|------|-------------|
+| -------- | ------ | ------------- |
| `vllm_omni:diffusion_exec_s` | Histogram | Core diffusion-step execution time per request |
| `vllm_omni:diffusion_exec_per_step_s` | Histogram | Core diffusion execution divided by inference-step count |
| `vllm_omni:diffusion_preprocess_s` | Histogram | Diffusion input preprocessing time |
@@ -218,7 +218,7 @@ The optional breakdown and memory families are sparse by design: when an engine
Labels: `{model_name, stage, replica}` plus the listed extra label.
| Metric | Type | Extra label | Description |
-|--------|------|-------------|-------------|
+| -------- | ------ | ------------- | ------------- |
| `vllm_omni:audio_ttfp_s` | Histogram | — | Time from request arrival to first audio packet/frame |
| `vllm_omni:audio_duration_s` | Histogram | — | Audio content duration (`audio_frames / sample_rate`) |
| `vllm_omni:audio_rtf` | Histogram | — | Real-time factor `stage_gen_time_s / audio_duration_s` (SLO `< 1`); uses `RTF_BUCKETS` |
@@ -227,12 +227,29 @@ Labels: `{model_name, stage, replica}` plus the listed extra label.
| `vllm_omni:audio_continuity_ok_total` | Counter | `threshold_ms` | Incremented when the request's worst underrun stayed below `threshold_ms` |
| `vllm_omni:audio_skipped_requests_total` | Counter | `reason` | Silent-loss counter — code2wav rejected malformed codec input and returned `200 OK` with empty audio |
+### Speech streaming (2)
+
+Labels: `{model_name}` plus the listed extra label.
+
+| Metric | Type | Extra label | Description |
+| -------- | ------ | ------------- | ------------- |
+| `vllm_omni:speech_stream_aborted_total` | Counter | `reason` | Interrupted audio generators, including before first PCM; reasons: `cancelled`, `closed`, `engine_dead`, `error` |
+| `vllm_omni:speech_stream_completed_total` | Counter | — | Normally completed audio generators; does not confirm client receipt |
+
+Scope: started Speech audio generators (raw/SSE/WebSocket), counted once per
+generation; WebSocket counts each sentence. Chat, non-streaming Speech, and
+failures before generator execution are excluded. Interrupted streams do not
+emit continuity or underrun samples.
+
+Interruption ratio: `aborted / (aborted + completed)`, using increments over the
+same time window and summing all reasons per model.
+
### Cross-stage transfer (4)
Labels: `{model_name, from_stage, from_replica, to_stage, to_replica}`.
| Metric | Type | Description |
-|--------|------|-------------|
+| -------- | ------ | ------------- |
| `vllm_omni:transfer_size_bytes` | Histogram | Per-transfer payload size in bytes |
| `vllm_omni:transfer_tx_s` | Histogram | Sender-side time (serialize + submit to connector) |
| `vllm_omni:transfer_rx_s` | Histogram | Receiver-side time (recv + deserialize) |
@@ -245,8 +262,8 @@ After the wrap, every upstream `vllm:*` family — TTFT, ITL, TPOT, e2e latency,
## Naming Convention
- All time-bearing metrics use the `_s` suffix (values in seconds). Two bucket families are used:
- - `SECONDS_BUCKETS` (0.05 s – 300 s) for e2e / generation / TTFP style values.
- - `SECONDS_FAST_BUCKETS` (0.001 s – 60 s) for fine-grained cross-stage transfer and audio-underrun values that need millisecond-level resolution.
+ - `SECONDS_BUCKETS` (0.05 s – 300 s) for e2e / generation / TTFP style values.
+ - `SECONDS_FAST_BUCKETS` (0.001 s – 60 s) for fine-grained cross-stage transfer and audio-underrun values that need millisecond-level resolution.
- Counters use the `_total` suffix (auto-appended by `prometheus_client`).
- Sizes use the `_bytes` suffix.
- All omni-specific families are prefixed `vllm_omni:`. The upstream `unregister_vllm_metrics()` function is monkey-patched to a scoped version that still strips upstream `vllm:*` collectors (so multi-engine init within one process does not crash on duplicate registration) but preserves anything prefixed `vllm_omni:` / `vllm_omni`.
diff --git a/docs/design/module/engine_orchestration.md b/docs/design/module/engine_orchestration.md
index d4dbf51a2..df22f3ce1 100644
--- a/docs/design/module/engine_orchestration.md
+++ b/docs/design/module/engine_orchestration.md
@@ -16,6 +16,7 @@ required_reviewers:
- "@yenuo26"
- "@NickCao"
primary_code_paths:
+ - vllm_omni/engine/omni_engine_base.py
- vllm_omni/engine/async_omni_engine.py
- vllm_omni/engine/async_engine_utils.py
- vllm_omni/engine/orchestrator.py
@@ -28,8 +29,12 @@ related_code_paths:
- vllm_omni/engine/stage_pool.py
- vllm_omni/outputs/output_processor.py
- vllm_omni/distributed/omni_coordinator/**
- # Duplex kernel design is owned by ../fullduplex.md; listed here because the
- # orchestrator hosts the duplex control plane.
+ # The duplex siblings (DuplexOmniEngine / DuplexOrchestrator) and the
+ # engine-resident session framework are owned by ../fullduplex.md; listed
+ # here because they fill the template seams of OmniEngineBase and
+ # OrchestratorBase.
+ - vllm_omni/engine/duplex_omni_engine.py
+ - vllm_omni/engine/duplex_orchestrator.py
- vllm_omni/engine/duplex/**
depends_on:
- input_output_modality_contracts.md
@@ -48,6 +53,8 @@ validation_paths:
- tests/engine/test_cfg_companion_lifecycle.py
- tests/engine/test_rpc_result_router.py
- tests/engine/duplex/**
+ - tests/engine/test_duplex_orchestrator.py
+ - tests/engine/test_duplex_omni_engine.py
- tests/engine/test_duplex_import_boundary.py
upstream_refs:
- vllm.v1.engine.EngineCoreRequest
diff --git a/docs/design/module/entrypoints.md b/docs/design/module/entrypoints.md
index a0dee90b9..0c0ba8439 100644
--- a/docs/design/module/entrypoints.md
+++ b/docs/design/module/entrypoints.md
@@ -19,7 +19,9 @@ required_reviewers:
primary_code_paths:
- vllm_omni/entrypoints/omni.py
- vllm_omni/entrypoints/async_omni.py
+ - vllm_omni/entrypoints/async_omni_base.py
- vllm_omni/entrypoints/omni_base.py
+ - vllm_omni/entrypoints/duplex_omni.py
- vllm_omni/entrypoints/cli/**
- vllm_omni/entrypoints/openai/**
- vllm_omni/entrypoints/openpi/**
@@ -27,14 +29,14 @@ primary_code_paths:
- vllm_omni/entrypoints/stage_utils.py
- vllm_omni/entrypoints/utils.py
- vllm_omni/entrypoints/duplex/**
- - vllm_omni/entrypoints/duplex_request_client.py
- vllm_omni/clients/**
+ - vllm_omni/protocol/**
primary_path_exceptions:
- path: vllm_omni/entrypoints/openai/errors.py
owner: error_contracts.md
- path: vllm_omni/entrypoints/duplex/**
owner: ../fullduplex.md
- - path: vllm_omni/entrypoints/duplex_request_client.py
+ - path: vllm_omni/entrypoints/duplex_omni.py
owner: ../fullduplex.md
- path: vllm_omni/clients/**
owner: ../fullduplex.md
@@ -42,7 +44,9 @@ related_code_paths:
- vllm_omni/errors.py
- vllm_omni/inputs/**
- vllm_omni/outputs/**
+ - vllm_omni/engine/omni_engine_base.py
- vllm_omni/engine/async_omni_engine.py
+ - vllm_omni/engine/duplex_omni_engine.py
- vllm_omni/config/**
- vllm_omni/deploy/**
depends_on:
@@ -50,10 +54,13 @@ depends_on:
- error_contracts.md
- engine_orchestration.md
validation_paths:
+ - tests/protocol/**
- tests/entrypoints/test_omni_entrypoints.py
- tests/entrypoints/test_async_omni.py
- tests/entrypoints/test_async_omni_pause_sleep_routing.py
- - tests/entrypoints/test_async_omni_duplex.py
+ - tests/entrypoints/test_duplex_omni.py
+ - tests/entrypoints/duplex/**
+ - tests/entrypoints/openai_api/test_duplex_api_server.py
- tests/entrypoints/test_serve.py
- tests/entrypoints/test_stream_finish_reason.py
- tests/entrypoints/openai/**
@@ -89,11 +96,43 @@ This document owns offline API semantics, CLI and serve composition,
supported OpenAI-compatible routes, request validation and normalization,
response conversion, streaming/session behavior, and engine handoff.
+When `--api-server-count` is greater than one, the serve composition root
+starts the frontend process manager while a parent-owned `StageRuntime`
+launches the shared local stage engines. Each frontend receives only its own
+stage-client channel configuration and does not launch or retire backend
+processes. CLI validation rejects unsupported distributed, diffusion,
+fault-tolerant, elastic-EP, Ray, and runtime-LoRA combinations before startup.
+
It does not own configuration precedence, cross-stage routing, stage
placement, payload implementation, or semantic error classification.
`entrypoints/openai/errors.py` is an explicit primary-path exception owned by
`error_contracts.md`.
+It also owns `vllm_omni/protocol/**`, the shared wire codec promoted by RFC
+[#6592](https://github.com/vllm-project/vllm-omni/issues/6592) P0a. It follows the three tiers
+`docs/serving/realtime_duplex_api.md` already defines. `protocol/realtime/**`
+is Tier 1, identical to OpenAI: 30 server events, 10 client commands, their
+typed fields and pure wire rendering, audio-format negotiation,
+conversation-item rules and the error-envelope shape. `protocol/duplex/**` is
+Tier 2 (OpenAI names carrying vLLM-Omni extensions, each a subclass of its
+Tier 1 twin declaring only what it adds) and Tier 3 (ours alone, including our
+error-code vocabulary), kept separate so a GA-only consumer is not handed a
+vocabulary its clients never send. That package sits
+outside `entrypoints/` on purpose --- the engine depends on it, so it belongs
+to neither layer --- but deciding what a Realtime client may put on the wire is
+this document's subject, and the same reasoning already places
+`vllm_omni/errors.py`, `inputs/` and `outputs/` under contract documents rather
+than under a consumer.
+
+It is deliberately **not** owned by `../fullduplex.md`, even though duplex is
+the codec's only consumer today. RFC #6592 asks for a named owner precisely so
+a second Realtime consumer does not need duplex review to change the codec
+(#6592 open question 8). Ownership here is review scope, not a dependency: the
+codec may not import the engine, the entrypoints or the model code, which
+`tests/protocol/realtime/test_protocol_import_boundary.py` enforces. When a
+second consumer lands (#6592 P0b) the package is expected to graduate to its
+own module document with owners drawn from both consumers.
+
## Candidate invariants
These identifiers are proposals while the document is `draft`.
@@ -103,6 +142,22 @@ These identifiers are proposals while the document is `draft`.
**Rule:** Entrypoints MUST NOT implement cross-stage routing or stage lifecycle
policy.
+### ENTRY-INV-002: The shared Realtime codec does not depend on its consumers
+
+**Rule:** `vllm_omni/protocol/**` MUST NOT import `vllm_omni.engine`,
+`vllm_omni.entrypoints`, `vllm_omni.model_executor`, `vllm_omni.worker` or
+`vllm_omni.clients`; `protocol/duplex/**` MAY depend on `protocol/realtime/**`
+but never the reverse, and a Tier 1 class MUST NOT carry a vLLM-Omni extension
+field; and each protocol/codec behavior MUST have exactly one
+implementation that every consumer uses. A wire type MUST NOT carry a
+consumer's internal representation: the duplex mailbox rendering
+(`DuplexCommand.payload()`, whose channel differs from the client event for
+`session.update` and the `conversation.item.*` commands) stays engine-side.
+A duplex consumer (`engine/duplex/**`, `entrypoints/duplex/**`, the duplex
+clients) MUST import `vllm_omni.protocol.duplex` and MUST NOT import
+`vllm_omni.protocol.realtime` directly, so the tier boundary has exactly one
+extension point.
+
### ENTRY-INV-100: Public requests are normalized once
**Rule:** Public protocol values MUST be validated and converted to internal
@@ -136,6 +191,9 @@ stage so `wake_up(stage_ids=[0])` does not skip a later `wake_up(stage_ids=[1])`
Streaming input pumps take an admission slot immediately before each EngineCore
ADD or update, not while waiting for the next client chunk. Frontend abort
keeps `request_states` until `generate()` consumes the terminal output.
+Multi-API serving must preserve one client rank and one channel set per
+frontend, include every frontend in readiness/failure observation, and keep
+stage lifecycle ownership in the parent composition root.
## Promotion gate
diff --git a/docs/design/module/host_weight_runtime.md b/docs/design/module/host_weight_runtime.md
index ac92711fc..c97561667 100644
--- a/docs/design/module/host_weight_runtime.md
+++ b/docs/design/module/host_weight_runtime.md
@@ -136,8 +136,16 @@ so only when it is marked retryable; unsupported capabilities, identity
collisions, producer failures, and publication failures remain visible as
startup failures.
-`coordination_timeout_seconds` bounds acquisition of lookup and build locks; it
-is not a hard wall-clock deadline for in-process validation, production, or
+`coordination_timeout_seconds` bounds acquisition of the domain-initialization
+lock and lookup/build locks. Store construction and each later resolution or
+publication operation receive separate coordination budgets from the same
+`WaitPolicy`; they do not share an end-to-end startup deadline. A domain-init
+lock timeout is a retryable domain failure: preferred mode may fall back and
+required mode fails. Retrying initialization requires constructing a fresh
+runtime after contention ends; a waiter never cancels or unlocks the owner.
+
+The coordination budget is not a hard wall-clock deadline for filesystem I/O,
+in-process validation, production, or
atomic publication. Once this V1 implementation becomes the producer, the
synchronous producer and publication run to completion. A hung producer blocks
its owning process and requires external process supervision; enforceable
diff --git a/docs/design/module/stage_runtime.md b/docs/design/module/stage_runtime.md
index aefba5a40..38255880f 100644
--- a/docs/design/module/stage_runtime.md
+++ b/docs/design/module/stage_runtime.md
@@ -77,6 +77,22 @@ readiness, one pool per logical stage, replica identity and membership,
selection and affinity, stage-client/process lifecycle, liveness, draining,
and shutdown.
+Local EngineCore initialization and multi-API serving use
+`StageRuntime.launch_stage_engines(num_api_servers=1)` as their common launch
+entry. It resolves plans when not supplied by the runtime's device-group
+scheduler, coordinates device locks and environment overlays, starts engines,
+and rolls back resources on startup or attachment failure. Single-API clients
+attach locally after readiness and take ownership of the engine resources;
+there is no additional API subprocess. Diffusion and remote attachment retain
+their backend-specific initialization.
+
+For local multi-API serving, the parent runtime owns and launches each stage
+engine once. It supplies a distinct input/output channel for every API
+frontend and stage replica; frontend workers attach clients to those engines
+without acquiring stage-process ownership. Readiness observation and shutdown
+remain parent-owned. This topology currently excludes diffusion and
+remote/headless stages, intra-stage data parallelism, and Ray backends.
+
It does not own cross-stage request policy, model scheduler policy, connector
transfer semantics, configuration precedence, or public error rendering.
@@ -114,6 +130,10 @@ affinity, membership changes, abort routing, process failure, draining, and
repeated shutdown. Cross-stage policy changes belong in
`engine_orchestration.md`.
+For multi-API changes, additionally exercise per-frontend channel assignment,
+shared-engine startup handshakes, frontend failure observation, and parent
+cleanup without duplicate stage shutdown.
+
## Promotion gate
- Reconcile terminology and ownership after #5441 reaches a final state.
diff --git a/docs/features/comfyui.md b/docs/features/comfyui.md
index ff766cbb3..dda68b793 100644
--- a/docs/features/comfyui.md
+++ b/docs/features/comfyui.md
@@ -13,7 +13,6 @@ It can send model inference requests to either a locally running vLLM-Omni servi
!!! tip
If you run both ComfyUI and vLLM-Omni on the same device, you can create separate virtual environments and use different Python versions for them.
-
## Installation
Copy the `apps/ComfyUI-vLLM-Omni` folder to the `custom_nodes` subfolder of your ComfyUI installation. Your directory should look like `ComfyUI/custom_nodes/ComfyUI-vLLM-Omni`.
@@ -21,9 +20,10 @@ Copy the `apps/ComfyUI-vLLM-Omni` folder to the `custom_nodes` subfolder of your
If you are running ComfyUI during copying, you should restart ComfyUI to load this extension.
!!! tip
- You can use utility websites such as https://download-directory.github.io/ to download a subdirectory of a repo. Also checkout community discussions (e.g., https://stackoverflow.com/questions/7106012/download-a-single-folder-or-directory-from-a-github-repository) for more info.
+ You can use utility websites such as to download a subdirectory of a repo. Also checkout community discussions (e.g., ) for more info.
On the device and virtual environment you run ComfyUI, launch ComfyUI with
+
```bash
cd ComfyUI
@@ -35,6 +35,7 @@ python main.py --cpu
```
On the device and virtual environment you run vLLM-Omni, start a model service with
+
```bash
vllm serve The_Model_ID_to_Serve --omni --port 8000
```
@@ -50,12 +51,27 @@ This extension offers the following nodes based on the output modalities:
- **Generate Video** for text-to-video and image-to-video tasks
- **Multimodality Understanding** for multimodality-to-text and multimodality-to-audio tasks
- **TTS** and **TTS Voice Clone** for TTS tasks
+- **Generate Music** for text-to-music tasks
This extension also offers example workflows (at **ComfyUI sidebar -> Templates -> vLLM-Omni**)
!!! info
The node UI and feature designs are intended to match vLLM-Omni online serving interfaces. It cannot offer more than what the interfaces support.
+Every node carries the vLLM-Omni mark in its title bar and is tinted by what it outputs, so a graph is readable at a glance:
+
+| Colour | Nodes | What they produce |
+| --- | --- | --- |
+| Blue | Generate Image, Generate Video, Multimodality Understanding, TTS, TTS Voice Clone | A generated image, video, audio, or text. These are the only nodes that reach a server. |
+| Amber | AR / Diffusion / Multi-Stage Sampling Params | Sampling parameters that apply to any model |
+| Purple | Qwen TTS Params, Wan Video Params, MiniMax-H3 Video Params | Parameters that only one model family accepts |
+| Red | LoRA, FastH3 Deployment | Which weights the server is expected to have loaded |
+| Teal | Video References | Reference media |
+
+Recolouring a node by hand (right click -> Colors) overrides its tint, and the choice is kept.
+
+**Generate Video** takes a clip length in seconds (`duration`), not a frame count. Frames stay the wire unit and are derived with the node's `fps`, so the length is always measured against the rate that is actually served; models that accept only certain frame counts still round to their own lattice server-side. Graphs saved before this widget existed stored `num_frames` in its place and are converted on load, using the fps recorded alongside it -- the browser console names every node it rewrites.
+
To build a simple workflow yourself,
- Drag a generation node onto the canvas.
@@ -66,6 +82,18 @@ To build a simple workflow yourself,
- For some multi-stage models like BAGEL, [only one stage's sampling parameters are exposed and tunable via vLLM-Omni's online serving API](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/examples/online_serving/bagel/). Thus, these models are treated as single-stage ones. Please check the vLLM-Omni documentation on how to correctly set each model's sampling parameters.
- For multi-stage models where all stages are either autoregression or diffusion, you can also connect only a single Sampling Params node, indicating that this set of sampling parameters will be used for all stages.
+## MiniMax H3 text-to-video workflow
+
+The **MiniMax H3 Text to Video** template uses the existing remote Generate Video,
+H3 Params, sampling, and Remote LoRA nodes. It includes Base settings and an
+optional Turbo preset, with native resolution, 24 FPS, and `17k+5` frame counts.
+ComfyUI receives the generated video and audio from the server; it does not load
+H3 weights locally. Configure the server-side LoRA path before enabling Turbo.
+
+See the [H3 workflow guide](https://github.com/vllm-project/vllm-omni/blob/main/apps/ComfyUI-vLLM-Omni/docs/minimax-h3-t2v.md)
+for server setup, template import, Turbo configuration, recorded validation,
+and saved-video/audio checks.
+
## Examples & Screenshots
Please read the [ComfyUI integration's README](https://github.com/vllm-project/vllm-omni/tree/main/apps/ComfyUI-vLLM-Omni) for more info.
diff --git a/docs/features/custom_pipeline.md b/docs/features/custom_pipeline.md
index 887dfa769..463798d6d 100644
--- a/docs/features/custom_pipeline.md
+++ b/docs/features/custom_pipeline.md
@@ -1,7 +1,7 @@
# Custom Pipeline Extension Guide
Transformer already support Custom Pipeline via
-https://github.com/huggingface/diffusers/blob/main/docs/source/en/using-diffusers/custom_pipeline_overview.md
+
This guide demonstrates how to use the newly added features for extending vLLM-Omni's diffusion pipeline with custom functionality.
@@ -20,8 +20,9 @@ Three main features enable custom pipeline extension:
`WorkerWrapperBase` is a wrapper class that creates `DiffusionWorker` instances with optional extension support. It enables dynamic inheritance, allowing you to add custom methods and functionality to workers without modifying the base worker class.
**Key capabilities:**
+
- Dynamic worker class extension via `worker_extension_cls`
-- Support for custom pipeline initialization via `custom_pipeline_args`
+- Support for custom pipeline initialization via `custom_pipeline_args["pipeline_class"]`
- Method delegation to underlying worker
- Attribute access forwarding
@@ -42,6 +43,7 @@ The `diffusion_load_format` parameter controls the initial diffusion model load.
`CustomPipelineWorkerExtension` is a mixin class that extends `DiffusionWorker` with the ability to re-initialize the pipeline with a custom implementation.
**Key method:**
+
- `re_init_pipeline(custom_pipeline_args)`: Re-initializes the pipeline with custom arguments, properly cleaning up the old pipeline first
**Location:** `vllm_omni/diffusion/worker/diffusion_worker.py`
@@ -150,6 +152,7 @@ omni = Omni(
},
worker_extension_cls=MyCustomExtension,
# Note: worker_extension_cls is an internal parameter
- # CustomPipelineWorkerExtension will automatically init pipeline when custom_pipeline_args is provided
+ # CustomPipelineWorkerExtension will automatically init pipeline when
+ # custom_pipeline_args includes "pipeline_class".
)
```
diff --git a/docs/getting_started/installation/gpu/rocm.inc.md b/docs/getting_started/installation/gpu/rocm.inc.md
index d15bba2eb..83efe0c62 100644
--- a/docs/getting_started/installation/gpu/rocm.inc.md
+++ b/docs/getting_started/installation/gpu/rocm.inc.md
@@ -1,11 +1,23 @@
+
+
# --8<-- [start:requirements]
- GPU: Validated on gfx942 (It should be supported on the AMD GPUs that are supported by vLLM.)
# --8<-- [end:requirements]
+
# --8<-- [start:set-up-using-python]
-vLLM-Omni current recommends the steps in under setup through Docker Images.
+For ROCm, vLLM-Omni currently recommends the setup steps through Docker Images.
+
+vLLM-Omni depends on the matching major/minor release of vLLM. The 0.29
+development line uses vLLM 0.29.x. Published 0.28.0 wheels and images use vLLM
+0.28.x.
+
+The Dockerfile's `BASE_IMAGE` pin applies only to Docker builds. The
+`vllm-omni` package does not install vLLM as a dependency, so non-Docker source
+installs must install the matching ROCm vLLM release explicitly before
+installing vLLM-Omni, as shown below.
# --8<-- [start:pre-built-wheels]
@@ -14,6 +26,7 @@ vLLM-Omni current recommends the steps in under setup through Docker Images.
These pre-built wheel instructions install the published vLLM-Omni 0.28.0 release. For the 0.29 development line, use the source-install instructions below.
vLLM-Omni is built based on vLLM. Please install it with command below.
+
```bash
uv pip install vllm==0.28.0+rocm723 --extra-index-url https://wheels.vllm.ai/rocm/0.28.0/rocm723
```
@@ -36,6 +49,7 @@ uv pip install onnxruntime-rocm
# --8<-- [start:build-wheel-from-source]
#### Installation of vLLM
+
If you do not need to modify source code of vLLM, you can directly install the stable 0.29.0 release version of the library
```bash
@@ -45,7 +59,9 @@ uv pip install vllm==0.29.0+rocm723 --extra-index-url https://wheels.vllm.ai/roc
The pre-built 0.29.0 vLLM wheel targets ROCm 7.2.3. If you need a different ROCm stack or want to reuse an existing PyTorch installation, build vLLM from source instead.
#### Installation of vLLM-Omni
+
Since vllm-omni is rapidly evolving, it's recommended to install it from source
+
```bash
git clone https://github.com/vllm-project/vllm-omni.git
cd vllm-omni
@@ -71,15 +87,31 @@ python3 setup.py develop
#### Build docker image
+The source build defaults to the published upstream base image
+`vllm/vllm-openai-rocm:v0.29.0`, aligned with the vLLM release used by CI.
+Older or custom bases must provide the vLLM APIs checked by the Dockerfile's
+image-build canary. This upstream base is distinct from the prebuilt
+`vllm/vllm-omni-rocm` images discussed below; their published-tag availability
+is tracked in [#7405](https://github.com/vllm-project/vllm-omni/issues/7405).
+
```bash
DOCKER_BUILDKIT=1 docker build -f docker/Dockerfile.rocm -t vllm-omni-rocm .
```
+To select the upstream ROCm base explicitly, pass `BASE_IMAGE`:
+
+```bash
+DOCKER_BUILDKIT=1 docker build \
+ -f docker/Dockerfile.rocm \
+ --build-arg BASE_IMAGE=vllm/vllm-openai-rocm:v0.29.0 \
+ -t vllm-omni-rocm .
+```
+
#### Launch the docker image
##### Launch with OpenAI API Server
-```
+```bash
docker run --rm \
--group-add=video \
--ipc=host \
@@ -97,7 +129,7 @@ vllm-omni-rocm \
##### Launch with interactive session for development
-```
+```bash
docker run --rm -it \
--network=host \
--group-add=video \
@@ -119,7 +151,9 @@ vllm-omni-rocm
vLLM-Omni offers an official docker image for deployment. These images are built on top of vLLM docker images and available on Docker Hub as [vllm/vllm-omni-rocm](https://hub.docker.com/r/vllm/vllm-omni-rocm/tags). The version of vLLM-Omni indicates which release of vLLM it is based on.
#### Launch vLLM-Omni Server
+
Here's an example deployment command that has been verified on 2 x MI300's:
+
```bash
docker run --rm \
--group-add=video \
@@ -136,8 +170,10 @@ docker run --rm \
--model Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091
```
-#### Launch an interactive terminal with prebuilt docker image.
+#### Launch an interactive terminal with prebuilt docker image
+
If you want to run in dev environment you can launch the docker image as follows:
+
```bash
docker run --rm -it \
--network=host \
diff --git a/docs/getting_started/installation/npu/npu.inc.md b/docs/getting_started/installation/npu/npu.inc.md
index 9ff477522..21127caf2 100644
--- a/docs/getting_started/installation/npu/npu.inc.md
+++ b/docs/getting_started/installation/npu/npu.inc.md
@@ -10,25 +10,25 @@ The recommended way to use vLLM-Omni on NPU is through the vLLM-Ascend pre-built
=== "A2"
```bash
- export IMAGE=quay.io/atlas-ci/vllm-ascend:v0.28.0
+ export IMAGE=quay.io/atlas-ci/vllm-ascend:v0.29.0
```
=== "A3"
```bash
- export IMAGE=quay.io/atlas-ci/vllm-ascend:v0.28.0-a3
+ export IMAGE=quay.io/atlas-ci/vllm-ascend:v0.29.0-a3
```
=== "A5"
```bash
- export IMAGE=quay.io/atlas-ci/vllm-ascend:v0.28.0-a5
+ export IMAGE=quay.io/atlas-ci/vllm-ascend:v0.29.0-a5
```
=== "310P (Experimental)"
```bash
- export IMAGE=quay.io/atlas-ci/vllm-ascend:v0.28.0-310p
+ export IMAGE=quay.io/atlas-ci/vllm-ascend:v0.29.0-310p
```
```bash
@@ -53,7 +53,7 @@ docker run --rm \
# Inside the container, install vLLM-Omni from source
cd /vllm-workspace
-git clone -b v0.28.0 https://github.com/vllm-project/vllm-omni.git
+git clone -b v0.29.0rc1 https://github.com/vllm-project/vllm-omni.git
cd vllm-omni
pip install -v -e . --no-build-isolation
# or VLLM_OMNI_TARGET_DEVICE=npu pip install -v -e .
@@ -71,10 +71,10 @@ We are keeping [issue #886](https://github.com/vllm-project/vllm-omni/issues/886
# --8<-- [start:installation-main]
-You can also install vLLM-Omni from the latest main branch if you want to use the latest features or bug fixes. Use the aligned vLLM-Ascend v0.28.0 image described above, and check [issue #886](https://github.com/vllm-project/vllm-omni/issues/886) for the status of the latest vLLM-Omni commit on NPU.
+You can also install vLLM-Omni from the latest main branch if you want to use the latest features or bug fixes. Use the aligned vLLM-Ascend v0.29.0 image described above, and check [issue #886](https://github.com/vllm-project/vllm-omni/issues/886) for the status of the latest vLLM-Omni commit on NPU.
```bash
-# Inside the vLLM-Ascend v0.28.0 container, install vLLM-Omni from main
+# Inside the vLLM-Ascend v0.29.0 container, install vLLM-Omni from main
cd /vllm-workspace
git clone https://github.com/vllm-project/vllm-omni.git
cd vllm-omni
@@ -103,7 +103,7 @@ Here's an example deployment command that has been verified on 4 x NPUs:
=== "A2"
```bash
- export IMAGE=quay.io/ascend/vllm-omni:v0.28.0
+ export IMAGE=quay.io/ascend/vllm-omni:v0.29.0
docker run --rm \
--name vllm-omni-a2 \
--shm-size=64g \
@@ -127,7 +127,7 @@ Here's an example deployment command that has been verified on 4 x NPUs:
=== "A3"
```bash
- export IMAGE=quay.io/ascend/vllm-omni:v0.28.0-a3
+ export IMAGE=quay.io/ascend/vllm-omni:v0.29.0-a3
docker run --rm \
--name vllm-omni-a3 \
--shm-size=64g \
@@ -151,7 +151,7 @@ Here's an example deployment command that has been verified on 4 x NPUs:
=== "A5"
```bash
- export IMAGE=quay.io/ascend/vllm-omni:v0.28.0-a5
+ export IMAGE=quay.io/ascend/vllm-omni:v0.29.0-a5
docker run --rm \
--name vllm-omni-a5 \
--shm-size=64g \
@@ -175,7 +175,7 @@ Here's an example deployment command that has been verified on 4 x NPUs:
=== "310P (Experimental)"
```bash
- export IMAGE=quay.io/ascend/vllm-omni:v0.28.0-310p
+ export IMAGE=quay.io/ascend/vllm-omni:v0.29.0-310p
docker run --rm \
--name vllm-omni-310p \
--shm-size=64g \
diff --git a/docs/mkdocs/hooks/generate_api_readme.py b/docs/mkdocs/hooks/generate_api_readme.py
index 3130e688f..d236c1129 100644
--- a/docs/mkdocs/hooks/generate_api_readme.py
+++ b/docs/mkdocs/hooks/generate_api_readme.py
@@ -25,8 +25,8 @@ CATEGORIES = {
"clients": {
"name": "Clients",
"description": (
- "Client libraries that talk to a running vLLM-Omni server "
- "(the full-duplex Realtime client and its per-model session presets)."
+ "Client libraries for full-duplex sessions: the Realtime WebSocket client, "
+ "the in-process client over `DuplexOmni`, and the per-model session presets."
),
},
"inputs": {
diff --git a/docs/mkdocs/hooks/generate_argparse.py b/docs/mkdocs/hooks/generate_argparse.py
index 4c3ad3373..ea0fc9e0c 100644
--- a/docs/mkdocs/hooks/generate_argparse.py
+++ b/docs/mkdocs/hooks/generate_argparse.py
@@ -2,6 +2,7 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import ast
+import json
import logging
import re
import sys
@@ -124,6 +125,7 @@ def extract_omni_serve_subparser_init():
"make_arg_parser": lambda parser: parser, # no-op for doc
"_ensure_vllm_platform": lambda: None, # no-op for doc
"_nonneg_finite_float": float,
+ "_json_object": json.loads,
"_parse_stage_overrides": __import__("json").loads,
"VLLM_SUBCMD_PARSER_EPILOG": "",
"logger": logger,
diff --git a/docs/mkdocs/hooks/search_index.py b/docs/mkdocs/hooks/search_index.py
index 1b9e268d9..8cf23e869 100644
--- a/docs/mkdocs/hooks/search_index.py
+++ b/docs/mkdocs/hooks/search_index.py
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
-# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Reduce browser search-index memory for generated API reference pages."""
from material.plugins.search.plugin import SearchIndex
diff --git a/docs/models/supported_models.md b/docs/models/supported_models.md
index 756f4be6a..d53cbe41a 100644
--- a/docs/models/supported_models.md
+++ b/docs/models/supported_models.md
@@ -59,6 +59,7 @@ th {
| `Magi2Pipeline` | MAGI-2 native Preview-stage T2VA and I2VA (272p/540p) | `sand-ai/MAGI-2-preview` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/SandAI/MAGI-2-preview-L20X.md) |
| `DreamZeroPipeline` | DreamZero-DROID | `GEAR-Dreams/DreamZero-DROID` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `Pi0Pipeline` | π0 (Pi-Zero) VLA | `lerobot/pi0_base` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/lerobot/Pi0.md) |
+| `Pi05Pipeline` | π0.5 VLA | `lerobot/pi05_base` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/lerobot/Pi05.md) |
| `HeliosPipeline`, `HeliosPyramidPipeline` | Helios | `BestWishYsh/Helios-Base`, `BestWishYsh/Helios-Mid`, `BestWishYsh/Helios-Distilled` | ✅︎ | ✅︎ | ✅︎ | | — |
| `OvisImagePipeline` | Ovis-Image | `OvisAI/Ovis-Image` | ✅︎ | ✅︎ | | ✅︎ | — |
| `LongcatImagePipeline` | LongCat-Image | `meituan-longcat/LongCat-Image` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
@@ -69,9 +70,10 @@ th {
| `StableDiffusionXLPipeline` | Stable-Diffusion-XL | `stabilityai/stable-diffusion-xl-base-1.0` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `StableDiffusion3Pipeline` | Stable-Diffusion-3 | `stabilityai/stable-diffusion-3.5-medium` | ✅︎ | ✅︎ | | ✅︎ | — |
| `CosyVoice3Model` | CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | ✅︎ | ✅︎ | | ✅︎ | — |
+| `AuKForConditionalGeneration` | AuK / AuK-Flash (instruction-driven speech generation and editing) | `tencent/AuK`, `tencent/AuK-Flash` (with `Qwen/Qwen2.5-Omni-3B`; assemble with `tools/prepare_auk_checkpoint.py`) | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/Tencent/AuK-H100.md) |
| `OmniVoiceModel` | OmniVoice | `k2-fsa/OmniVoice` | ✅︎ | | | ✅︎ | — |
| `VoxCPM2TalkerForConditionalGeneration` | VoxCPM2 | `openbmb/VoxCPM2` | ✅︎ | | | ✅︎ | — |
-| `DotsTTSForConditionalGeneration` | dots.tts | `dots-studio/dots.tts-soar` | ✅︎ | | | | — |
+| `BreezeForConditionalGeneration` | Breeze-TTS-2 (TTS, voice clone, voice direction) | `BreezeBlue/Breeze-TTS-2` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/BreezeBlue/Breeze-TTS-2.md) |
| `MammothModa2ForConditionalGeneration` | MammothModa2-Preview | `bytedance-research/MammothModa2-Preview` | ✅︎ | ✅︎ | | | — |
| `MammothModa2ForConditionalGeneration` | MammothModa2-Dev (AR-only image understanding) | `bytedance-research/MammothModa2-Dev` | ✅︎ | | | | — |
| `Flux2KleinPipeline` | FLUX.2-klein | `black-forest-labs/FLUX.2-klein-4B`, `black-forest-labs/FLUX.2-klein-9B` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
@@ -86,7 +88,7 @@ th {
| `Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-Base | `Qwen/Qwen3-TTS-12Hz-1.7B-Base` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `MingTTSForConditionalGeneration` | Ming-omni-tts dense 0.5B | `inclusionAI/Ming-omni-tts-0.5B` | ✅︎ | | | ✅︎ | — |
| `GLMTTSForConditionalGeneration` | GLM-TTS | `zai-org/GLM-TTS` | ✅︎ | | | | — |
-| `MossTTSNanoForCausalLM` | MOSS-TTS-Nano | `OpenMOSS-Team/MOSS-TTS-Nano` | ✅︎ | | | | — |
+| `MossTTSNanoForCausalLM` | MOSS-TTS-Nano | `OpenMOSS-Team/MOSS-TTS-Nano` | ✅︎ | | ✅︎ | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/OpenMOSS/MOSS-TTS-Nano-NPU.md) |
| `MossTTSDelayModel` | MOSS-TTS, MOSS-TTSD, MOSS-SoundEffect, MOSS-VoiceGenerator | `OpenMOSS-Team/MOSS-VoiceGenerator` | ✅︎ | | | | — |
| `MossTTSRealtime` | MOSS-TTS-Realtime | `OpenMOSS-Team/MOSS-TTS-Realtime` | ✅︎ | | | | — |
| `HiggsAudioV2ForConditionalGeneration` | Higgs-Audio v2 | `bosonai/higgs-audio-v2-generation-3B-base` | ✅︎ | | | | — |
@@ -105,12 +107,12 @@ th {
| `HunyuanVideo15ImageToVideoPipeline` | HunyuanVideo-1.5-I2V | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_i2v`, `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_i2v` | ✅︎ | ✅︎ | | | — |
| `VoxtralTTSForConditionalGeneration` | Voxtral TTS | `mistralai/Voxtral-4B-TTS-2603` | ✅︎ | ✅︎ | | | — |
| `CovoAudioForConditionalGeneration` | Covo-Audio-Chat | `tencent/Covo-Audio-Chat` | ✅︎ | | | | — |
-| `DyninOmniForConditionalGeneration` | Dynin-Omni | `snu-aidas/Dynin-Omni` | ✅︎ | | | ✅︎ | — |
-| `MiniCPMO45OmniForConditionalGeneration` | MiniCPM-o 4.5 | `openbmb/MiniCPM-o-4_5` | ✅︎ | | ✅︎ | | — |
+| `MiniCPMO45OmniForConditionalGeneration` | MiniCPM-o 4.5 | `openbmb/MiniCPM-o-4_5` | ✅︎ | | ✅︎ | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/OpenBMB/MiniCPM-o-4_5.md) |
| `ErnieImagePipeline` | ERNIE-Image | `baidu/ERNIE-Image`, `baidu/ERNIE-Image-Turbo` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `GepardTalkerForConditionalGeneration` | Gepard-1.0 | `nineninesix/gepard-1.0` | ✅︎ | | | ✅︎ | — |
| `HiDreamImagePipeline` | HiDream-I1-Full | `HiDream-ai/HiDream-I1-Full` | ✅︎ | ✅︎ | | | — |
| `HiDreamO1ImagePipeline` | HiDream-O1-Image | `HiDream-ai/HiDream-O1-Image` | ✅︎ | | | | — |
+| `AnimaPipeline` | Anima | `circlestone-labs/Anima` | ✅︎ | ✅︎ | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/circlestone-labs/Anima.md) |
The `Recipe` column links the corresponding deployment recipe. `Published` is
preferred when a corresponding `recipes.vllm.ai` page exists; otherwise the
diff --git a/docs/serving/README.md b/docs/serving/README.md
index 8167f4c9b..472ab2a49 100644
--- a/docs/serving/README.md
+++ b/docs/serving/README.md
@@ -145,15 +145,22 @@ interchangeably.
| Incremental text input for speech synthesis | `WS /v1/audio/speech/stream` | Send text events and receive audio | [Streaming Text to Speech](speech_api.md#streaming-text-input-websocket) |
| Live video understanding | `WS /v1/video/chat/stream` | Send video frames and receive text/audio | [Streaming Video Input](video_stream_api.md) |
| Turn-based realtime audio | `WS /v1/realtime` | Stream one audio input and receive transcript/audio events | [Realtime Audio](realtime_api.md) |
-| Continuous speech-to-speech interaction | `WS /v1/realtime?duplex=1` or `WS /v1/duplex` | Listen and speak concurrently with session control | [Full Duplex](full_duplex_api.md) |
+| Continuous speech-to-speech interaction | `WS /v1/realtime?duplex=1` (alias `WS /v1/duplex`) | Listen and speak concurrently with session control | [Full Duplex](full_duplex_api.md) |
| Generated video chunks | `WS /v1/realtime/video` | Start a diffusion request and receive fragmented MP4 | [Streaming Video Output](streaming_video_output_api.md) |
| Robot policy inference | `WS /v1/realtime/robot/openpi` | Send MessagePack observations and receive action arrays | [OpenPI Robot Policy](openpi_api.md) |
All six routes are model- or configuration-dependent. In particular,
-`/v1/realtime` is not full duplex unless the client sets `duplex=1` and the
-deployment explicitly enables duplex sessions. Clients should also verify the
-duplex capability payload because the query-parameter form falls back to the
-ordinary realtime handler when duplex is unavailable.
+`/v1/realtime` is full duplex when the model is a duplex model (its pipeline
+declares a `duplex_plugin` and the deploy configuration sets
+`session_mode: duplex`); a stock Realtime client needs no vendor query
+parameter, and `duplex=0` is the explicit opt-out. Such a server serves the
+websocket route plus `POST /v1/chat/completions`, and no other turn-based
+HTTP route. On a duplex server that chat route is not the turn-based path:
+each request runs on a short-lived duplex session, so it holds one of
+`duplex_session.max_sessions` for its lifetime and answers at the model's
+real-time pace -- see [Full Duplex](full_duplex_api.md). Clients should
+also verify the duplex capability payload because the query-parameter form
+falls back to the ordinary realtime handler when duplex is unavailable.
## Related Endpoints
diff --git a/docs/serving/full_duplex_api.md b/docs/serving/full_duplex_api.md
index f648d6fdb..9b3eceb58 100644
--- a/docs/serving/full_duplex_api.md
+++ b/docs/serving/full_duplex_api.md
@@ -15,40 +15,68 @@ capability gates, see the [Realtime Duplex API guide](realtime_duplex_api.md).
| Endpoint | Protocol | Recommended use |
| --- | --- | --- |
-| `WS /v1/realtime?duplex=1` | OpenAI Realtime-style event projection | Applications and browser clients |
-| `WS /v1/duplex` | Native vLLM-Omni duplex events | Runtime integration and low-level testing |
-
-Both endpoints use the same duplex engine and require the same model-side
-adapter. Prefer the Realtime projection unless the native lifecycle events are
-specifically required.
+| `WS /v1/realtime?duplex=1` | OpenAI Realtime-style events (the normative contract) | Applications and browser clients |
+| `WS /v1/duplex` | Alias of `/v1/realtime?duplex=1` (same protocol, same handler) | Clients that prefer a dedicated path |
+| Python: `DuplexOmni` / `InlineDuplexClient` | Typed commands and events in-process | Embedding the model without a server |
## Enable Full Duplex
-The route becomes usable only when the deployment configuration explicitly
-sets:
+A model is served full duplex when its registered pipeline declares a
+`duplex_plugin` (the model's `DuplexModelPlugin`) and its deploy configuration
+sets `session_mode: duplex`. `vllm serve --omni` constructs `DuplexOmni`
+instead of `AsyncOmni`. It serves
+`/v1/realtime?duplex=1` (and its alias `/v1/duplex`),
+`POST /v1/chat/completions`, `/v1/models` and `/health`; every other
+turn-based HTTP route (speech, batch, embeddings, video, ...) answers "not
+available". Set `session_mode: turn` to select the ordinary online serving
+stack instead. The model's supported tasks and endpoint restrictions still apply.
+
+`/v1/chat/completions` is the ordinary chat service running on the duplex
+engine: a request is a turn-based generation on the same stages, served
+alongside the live websocket sessions, with the request options the chat
+service supports. It is wired only when the model's plugin declares
+`DuplexCapabilities.supports_chat_completions`; on any other duplex model the
+route reports "not available". A model that should not serve the route at all
+lists it in the deploy configuration's `endpoint_restrictions`.
+
+The deploy configuration of such a model must agree:
```yaml
session_mode: duplex
```
-The selected model pipeline must also provide a duplex serving adapter.
-Model-native deployments configure the corresponding engine runtime extension
-and control plane as part of their registered pipeline.
+A duplex-capable model must explicitly set `session_mode` to `duplex` or `turn`
+in its deploy configuration, possibly through `base_config` inheritance.
+Missing or invalid values fail at startup. The default MiniCPM-o deployment
+continues to use duplex mode.
+
+To run MiniCPM-o 4.5 with the turn-based engine:
+
+```bash
+vllm serve openbmb/MiniCPM-o-4_5 --omni \
+ --deploy-config vllm_omni/deploy/minicpmo_4_5_turn.yaml \
+ --trust-remote-code \
+ --port 8091
+```
+
+This profile inherits the default model and stage settings and overrides only
+`session_mode`. It supports ordinary HTTP requests without creating a duplex
+session handler. Mode selection happens at startup; a WebSocket query parameter
+does not switch engines. The Python `Omni` / `AsyncOmni` APIs are unchanged.
!!! warning
- `WS /v1/duplex` fails with `Duplex API is not available` when duplex is not
- enabled. By contrast, `/v1/realtime?duplex=1` falls back to the ordinary
- turn-based realtime handler when no duplex handler exists. Confirm that
+ On a deployment that is *not* duplex, `WS /v1/duplex` fails with
+ `Duplex API is not available` and `/v1/realtime?duplex=1` falls back to the
+ ordinary turn-based realtime handler. Confirm that
`session.created.session.capabilities` is present before treating the
connection as full duplex.
-The current unified-runtime integrations are:
-
-- MiniCPM-o 4.5, using `vllm_omni/deploy/minicpmo_4_5.yaml`
- (`session_mode: duplex`);
-- PersonaPlex, whose default `vllm_omni/deploy/personaplex.yaml` enables duplex;
-- Nemotron VoiceChat, via its registered duplex plugin package.
+**MiniCPM-o 4.5** (`vllm_omni/deploy/minicpmo_4_5.yaml`) is the only model
+served over this endpoint today. PersonaPlex and Nemotron VoiceChat still carry
+their pre-framework duplex code: their pipelines declare no `duplex_plugin`, so
+they run turn-based until the follow-up PRs port them to the plugin contract
+(RFC [vllm-omni#7181](https://github.com/vllm-project/vllm-omni/issues/7181)).
JoyVL is a separate HTTP interaction orchestrator and does not use these
WebSocket endpoints. See [Standalone Experimental Servers](standalone_servers.md).
@@ -86,7 +114,7 @@ A typical `/v1/realtime?duplex=1` session follows this lifecycle:
3. Send `input_audio_buffer.append` events while microphone audio arrives.
4. Send `input_audio_buffer.commit` at a user-turn boundary when required by
the model policy.
-5. Consume `response.created`, transcript deltas, `response.audio.delta`, and
+5. Consume `response.created`, transcript deltas, `response.output_audio.delta`, and
`response.done` or `response.listen` events.
6. Send `playback.ack` after audio has been played when the session advertises
playback acknowledgement support.
@@ -101,22 +129,23 @@ deferred, treated as a short acknowledgement, or used to interrupt output.
The `session.created` payload includes capability fields such as
`supports_barge_in`, `supports_playback_ack`, `supports_multi_session`,
`supports_session_resume`, and `chunk_period_ms`. Treat this payload as the
-runtime contract.
+runtime contract and branch on the flags, never on the model name: a model
+that supports native overlapping speech may still advertise
+`supports_barge_in=false` when destructive output interruption and model-state
+rewind are not validated for it. Capacity and session-resume behavior also
+depend on the selected deployment configuration. The per-model table lives in
+the [Realtime Duplex API guide](realtime_duplex_api.md#capability-negotiation-by-model).
-For example, PersonaPlex supports native overlapping speech but currently
-advertises `supports_barge_in=false`; destructive output interruption and
-model-state rewind have not been validated for that integration. Capacity and
-session-resume behavior also depend on the selected deployment configuration.
+## Python API
-## Native Protocol
+`vllm_omni.entrypoints.duplex_omni.DuplexOmni` runs the same engine-resident
+sessions in-process: `open_session()` returns a `DuplexSessionHandle` whose
+`submit()` takes typed `DuplexCommand` objects and whose `events()` yields
+typed `DuplexEvent` objects (each with a `to_realtime()` wire rendering).
+`vllm_omni.clients.inline_duplex.InlineDuplexClient` exposes that handle
+behind the `DuplexClient` API. See the
+[Realtime Duplex API guide](realtime_duplex_api.md#using-the-python-api).
-`WS /v1/duplex` exposes lower-level lifecycle names including
-`session.create`, `input_audio_buffer.append`, `turn.signal`, `playback.ack`,
-and `session.close`. It returns native session, input, response, overlap, and
-error events. This protocol is experimental and may evolve with the runtime;
-applications should use the provided Realtime client where possible.
-
-See the [MiniCPM-o example](https://github.com/vllm-project/vllm-omni/tree/main/examples/online_serving/minicpmo),
-[PersonaPlex example](https://github.com/vllm-project/vllm-omni/tree/main/examples/online_serving/personaplex),
+See the [MiniCPM-o example](https://github.com/vllm-project/vllm-omni/tree/main/examples/online_serving/minicpmo)
and the [full-duplex runtime design](../design/fullduplex.md)
for model-specific validation and architecture details.
diff --git a/docs/serving/realtime_api.md b/docs/serving/realtime_api.md
index 153564140..e44ff7ed0 100644
--- a/docs/serving/realtime_api.md
+++ b/docs/serving/realtime_api.md
@@ -43,8 +43,8 @@ event is base64-encoded raw PCM16.
| Client to server | `input_audio_buffer.commit` with `final: true` | Marks the end of input |
| Server to client | `transcription.delta` | Carries incremental response text |
| Server to client | `transcription.done` | Carries final text and token usage |
-| Server to client | `response.audio.delta` | Carries incremental PCM16 response audio |
-| Server to client | `response.audio.done` | Marks the end of response audio |
+| Server to client | `response.output_audio.delta` | Carries incremental PCM16 response audio |
+| Server to client | `response.output_audio.done` | Marks the end of response audio |
| Server to client | `error` | Reports an invalid event, model, or audio payload |
A minimal client sends events in this order:
@@ -63,7 +63,7 @@ engine is consuming the stream.
## Audio Handling
- Input is mono PCM16 at 16 kHz for the Qwen3-Omni example.
-- `response.audio.delta.audio` contains base64-encoded PCM16 bytes.
+- `response.output_audio.delta.audio` contains base64-encoded PCM16 bytes.
- Read `sample_rate_hz` from each audio event instead of assuming an output
rate. Qwen3-Omni output is typically 24 kHz.
- Concatenate audio deltas in receive order to construct the output waveform.
diff --git a/docs/serving/realtime_duplex_api.md b/docs/serving/realtime_duplex_api.md
index d37d02291..cd8832c82 100644
--- a/docs/serving/realtime_duplex_api.md
+++ b/docs/serving/realtime_duplex_api.md
@@ -1,17 +1,47 @@
# Realtime Duplex API
vLLM-Omni serves full-duplex speech models over a WebSocket endpoint,
-`/v1/realtime`, that speaks the OpenAI Realtime vocabulary plus a
+`/v1/realtime?duplex=1`, that speaks the OpenAI Realtime vocabulary plus a
set of duplex extensions: the model decides when to listen and when to speak,
the user can talk over the assistant, playback progress is reported back so
history stays honest, and a dropped connection can resume the same session.
This page covers how to run a duplex deployment, how to drive it from Python
with `vllm_omni.clients.duplex.DuplexClient`, and the complete wire contract.
-The handler is enabled by either a model-native `session_mode: duplex`
-deployment or an explicit `duplex_session` configuration for turn-based
-Server VAD. The runtime architecture is described in
-[Full-Duplex Runtime (MiniCPM-o 4.5)](../design/fullduplex.md).
+The endpoint is served for models that ship a duplex plugin — including
+MiniCPM-o 4.5 and Qwen3-Omni — and just for deploy configurations that declare
+`session_mode: duplex`. PersonaPlex and Nemotron VoiceChat are ported to the
+plugin contract in follow-up PRs and are not served over this endpoint yet. The runtime architecture
+is described in [Full-Duplex Runtime (MiniCPM-o 4.5)](../design/fullduplex.md).
+
+## Qwen3-Omni conversation history
+
+Qwen3-Omni keeps incoming conversation items independently addressable by item
+ID. Before applying the model's chat template, it merges consecutive user
+items into one multimodal user message. An assistant message, including an
+empty message reserved for an interrupted or unheard answer, separates turns.
+The final user message places media before text instructions, following the
+[official Qwen3-Omni demo's `format_history()`](https://github.com/QwenLM/Qwen3-Omni/blob/e4235853125589c789f06a2dd83e9f4126df5e9d/web_demo.py#L84-L179).
+
+A camera image or text item may arrive after an audio commit and before
+`response.create`. Those items belong to the same pending user turn: the
+committed audio remains associated with its history item even when that item
+is no longer last. Prompt preparation snapshots the selected inputs; later
+items do not modify an already prepared request.
+
+The duplex adapter retains its existing budgets: at most four audio payloads
+(including the current input), an 8 MiB encoded-audio history budget (keeping
+at least the newest payload), and at most eight prompt images. The recent
+16-message window is rounded outwards to a complete turn. If any audio in an
+older turn is no longer retained, the entire turn's model context, including
+its images/text and assistant replies, is omitted. Images from that turn are
+therefore **not** permanent session-wide model context. These limits differ
+from the demo's one image-bearing turn and five audio-bearing turns; the
+shared rule is grouping before pruning, rather than copying its UI budgets.
+
+Grouping and pruning affect only the model-input view. They do not merge or
+delete the source conversation items exposed to clients, and do not change
+playback acknowledgement or interruption handling.
## Quick Start
@@ -26,11 +56,24 @@ vllm-omni serve openbmb/MiniCPM-o-4_5 \
```
`vllm_omni/deploy/minicpmo_4_5.yaml` declares `session_mode: duplex` and
-`duplex_session.max_sessions: 4`, which mounts the WebSocket routes
-`ws://:8099/v1/realtime` (this page) and `ws://:8099/v1/duplex`
-(the native dialect for raw-protocol clients) next to the usual HTTP API.
-PersonaPlex and Nemotron VoiceChat use `vllm_omni/deploy/personaplex.yaml` and
-`vllm_omni/deploy/nemotron_labs_voicechat_duplex.yaml` in the same way.
+`duplex_session.max_sessions: 4`. Because the MiniCPM-o 4.5 pipeline declares a
+`duplex_plugin`, `vllm-omni serve` runs it through `DuplexOmni`: the server
+mounts `ws://:8099/v1/realtime?duplex=1` (this page; `ws://:8099/v1/duplex`
+is an alias of the same route), `POST /v1/chat/completions`, `/v1/models` and
+`/health`, and nothing else. The remaining turn-based HTTP routes (speech,
+batch, embeddings, video, ...) are not served in duplex mode.
+
+`/v1/chat/completions` uses the ordinary chat service on the same engine
+when the plugin declares `supports_chat_completions`. HTTP chat requests do
+not create duplex sessions or consume session admission slots. Their options
+follow the [Chat Completions API](chat_completions_api.md) and the model's
+capabilities and endpoint restrictions.
+
+For a turn-based online deployment, use `minicpmo_4_5_turn.yaml` or explicitly
+set `session_mode: turn` in a deploy configuration derived from the model's
+profile. This selects `AsyncOmni` and the ordinary API initialization instead
+of the duplex handler. See [Full Duplex](full_duplex_api.md#enable-full-duplex)
+for the command and configuration requirements.
### Run the Example Client
@@ -81,25 +124,24 @@ ships a preset:
| Preset | Input / output audio | What it sets |
| --- | --- | --- |
-| `vllm_omni.clients.minicpmo_4_5.create_duplex_session_config(ref_audio=...)` | 16 kHz `pcm16` / 24 kHz `pcm16` | `extra_body.native_duplex=True` (opt in to the model-native lane), `force_listen_count=0`, `overlap_policy="listen_only"`, `playback_commit_policy="ack_only"`; `ref_audio` is the assistant voice clip |
+| `vllm_omni.clients.minicpmo_4_5.create_duplex_session_config(ref_audio=...)` | 16 kHz `pcm16` / 24 kHz `pcm16` | `force_listen_count=0`, `overlap_policy="listen_only"`, `playback_commit_policy="ack_only"`; `ref_audio` is the assistant voice clip |
| `vllm_omni.clients.personaplex.create_duplex_session_config(voice="NATF2.pt", persona="")` | 24 kHz `pcm_f32le` / 24 kHz `pcm16` | bundled `.pt` voice prompt and the persona as `instructions` |
| `SessionConfig(...)` | 16 kHz `pcm16` / 24 kHz `pcm16` | model-neutral defaults; pass `extra_body`, `turn_detection`, `overlap_policy`, `playback_commit_policy`, `instructions`, `voice`, `temperature` yourself |
Keyword overrides on a preset replace the corresponding `SessionConfig`
field, e.g. `create_duplex_session_config(ref_audio=..., temperature=0.6)`.
-Constructor options: `session_id` (names the session the client creates; it
-does not resume or take over an existing session — entering the client always
-performs the `session.update` handshake), `reconnect` (a `ReconnectPolicy`,
-default five attempts with 0.25–4 s jittered backoff; `None` disables
-auto-resume), `heartbeat_interval_s` (default 30 s; `None` disables the lease
-heartbeat), `handshake_timeout_s`, and `connect` (a custom transport factory,
-used by tests and benchmarks). Resume is supported only as automatic reconnect
-within the same `DuplexClient` instance (see
-[Reconnect and resume](#reconnect-and-resume)); re-attaching from a new client
-or process requires the wire-level `session.resume` handshake
-(`resume_token`, `incarnation`, `last_received_server_event_seq`), which this
-client does not expose.
+Constructor options: `reconnect` (a `ReconnectPolicy`, default five
+attempts with 0.25–4 s jittered backoff; `None` disables auto-resume),
+`heartbeat_interval_s` (default 30 s; `None` disables the lease heartbeat),
+`handshake_timeout_s`, and `connect` (a custom transport factory, used by
+tests and benchmarks). The session id is allocated by the server and becomes
+`client.session_id` once `session.created` arrives; a client cannot choose
+it. Resume is supported only as automatic reconnect within the same
+`DuplexClient` instance (see [Reconnect and resume](#reconnect-and-resume));
+re-attaching from a new client or process requires the wire-level
+`session.resume` handshake (`resume_token`,
+`last_received_server_event_seq`), which this client does not expose.
### Stream audio and commit a turn
@@ -195,7 +237,7 @@ the stamped `event_id`.
On a transport drop the client reconnects with the `ReconnectPolicy` backoff
and sends `session.resume` from the last acknowledged `server_event_seq`.
Replayed events are deduplicated, consumers see one `ConnectionResumed`
-event, and `client.incarnation` / `client.resume_token` are refreshed. The
+event, and `client.resume_token` is refreshed. The
server keeps a detached session alive for `disconnect_grace_s` (30 s by
default); after that a resume fails with `session_resume_expired`. Resume is
gated by `capabilities.supports_session_resume` (MiniCPM-o 4.5 and Nemotron
@@ -238,9 +280,67 @@ definitions out of the dependency-free client. `audio_bytes(response_id)`, `coun
`openai-realtime-duplex` benchmark backend (`vllm-omni bench serve`) is built
on the same helpers.
+## Using the Python API
+
+`vllm_omni.entrypoints.duplex_omni.DuplexOmni` is the in-process Python API
+of a duplex model: sessions run inside the engine, and the class only opens,
+resumes and closes them and pipes typed commands in and typed events out
+through a `DuplexSessionHandle`.
+
+```python
+from vllm_omni.engine.duplex.commands import AppendAudio, Commit
+from vllm_omni.engine.duplex.events import AudioDelta, SessionClosed
+from vllm_omni.entrypoints.duplex_omni import DuplexOmni
+
+omni = DuplexOmni(model="openbmb/MiniCPM-o-4_5", trust_remote_code=True)
+async with await omni.open_session({"modalities": ["audio", "text"], "ref_audio": ref_audio_data_url}) as session:
+ async def consume():
+ async for event in session.events(): # typed DuplexEvent objects
+ if isinstance(event, AudioDelta):
+ play(event.audio)
+ elif isinstance(event, ResponseDone):
+ break # one turn is enough here
+ task = asyncio.create_task(consume())
+ await session.submit(AppendAudio(audio=pcm16_bytes, format="pcm16", sample_rate_hz=16000))
+ await session.submit(Commit())
+ await task
+# leaving the block closes the session (the last event is SessionClosed)
+```
+
+`open_session` takes a `DuplexSessionConfig` or the same Realtime `session`
+object the WebSocket handshake takes; the session id is always allocated by
+`DuplexOmni`. `DuplexSessionHandle` offers `submit(command)` plus one
+convenience wrapper per command (`append_audio`, `commit`, `create_response`,
+`cancel_response`, `barge_in`, `ack_playback`, `update`, `create_item`,
+`delete_item`, `truncate_item`, `heartbeat`, ...). `events()` is a
+single-consumer async iterator that ends after `session.closed` /
+`session.expired`; rejected commands come back as `ErrorEvent`s on it.
+Every event renders the wire JSON with `to_realtime()`, so anything written
+against the WebSocket protocol works unchanged on the typed stream.
+
+`vllm_omni.clients.inline_duplex.InlineDuplexClient` wraps a `DuplexOmni`
+behind the `DuplexClient` API, so the same application code runs in-process
+(no server) or over the network:
+
+```python
+from vllm_omni.clients.inline_duplex import InlineDuplexClient
+from vllm_omni.clients.minicpmo_4_5 import create_duplex_session_config
+
+config = create_duplex_session_config(ref_audio=audio_data_url(Path("reference_voice.wav")))
+async with InlineDuplexClient(omni, model="openbmb/MiniCPM-o-4_5", config=config) as client:
+ await client.stream_pcm(question)
+ await client.commit()
+ async for response in client.responses():
+ ...
+```
+
+`examples/online_serving/barge_in_client.py --inline` drives the barge-in
+scenario this way. The inline client has no transport, so it has no
+reconnect, resume, heartbeat or event-acknowledgement machinery.
+
## API Reference
-This section is the complete wire contract of `/v1/realtime`: how
+This section is the complete wire contract of `/v1/realtime?duplex=1`: how
the dialect relates to the OpenAI Realtime protocol, which surfaces each
model's capability flags gate, a catalogue of every client-to-server and
server-to-client event, and a JSON example for each. `DuplexClient` speaks
@@ -253,21 +353,26 @@ its protocol tests.
### Endpoint and transport
-The endpoint is `ws(s):///v1/realtime`. Bare connections select the
-session handler when the deployment declares either `session_mode: duplex`
-or `duplex_session`. `?duplex=1` remains a compatibility alias, while
-`?duplex=0` selects the legacy Realtime handler. Optional query parameters
-are `model`, `session_id`,
-`autostart` (`0` means resume-only), `resume`, and `native_duplex`
-(`minicpmo45_native_duplex` is accepted as a deprecated alias and folded
-into the canonical name). Every message is one JSON object per WebSocket
+The endpoint is `ws(s):///v1/realtime`. On a deployment whose pipeline
+declares a `duplex_plugin` and whose deploy configuration declares
+`session_mode: duplex`, a bare connection is already a duplex session, so a
+stock Realtime client needs no vendor query parameter. `?duplex=1` (`1`,
+`true` or `on`) is the spelling this page uses and stays valid;
+`?duplex=0` (`0`, `false`, `off`) explicitly opts out and selects the
+turn-based Realtime handler, which a duplex server does not mount --
+that connection is answered with `Realtime API is not available`.
+`ws(s):///v1/duplex` is an alias that always selects the duplex
+handler. Optional query parameters are `model`, `autostart` (`0` means
+resume-only) and `resume`. Every message is one JSON object per WebSocket
text frame, discriminated by `type`. Inbound events are applied in arrival
order through one per-session mailbox; outbound events preserve that order
and carry a monotonically increasing `server_event_seq`, which is the replay
cursor for `session.resume`. Client events may carry an `event_id`, echoed in
`error.error.event_id` when the event is rejected.
-Session identity is `(session_id, incarnation)`; `attachment_generation`
+Session identity is the server-allocated `session_id` (`duplex-`,
+announced in `session.created`; a `session_id` the client puts in the session
+object is ignored, and ids are never reused); `attachment_generation`
names the current socket; `epoch` is the interruption fence that advances on
every cancel, clear, or barge-in; `turn_id` counts committed model turns;
`response_id` (item id `item_`) names one assistant utterance.
@@ -281,12 +386,13 @@ with no OpenAI counterpart.
The event vocabulary is uniform, but several surfaces are gated by the
`capabilities` object the server returns in `session.created`; a client must
-branch on those flags rather than on the model name. The current model
-plugins advertise:
+branch on those flags rather than on the model name. MiniCPM-o 4.5 is the
+only model on the plugin contract today; the other two columns record what
+their integrations advertise once the follow-up PRs port them:
| Capability | MiniCPM-o 4.5 | PersonaPlex | Nemotron VoiceChat | Gated surface |
| --- | --- | --- | --- | --- |
-| `implementation_level` | `model_native_duplex` (when `extra_body.native_duplex=true`) | `model_native_duplex` | `model_native_duplex` | model-owned `response.listen` / `response.speak`; the chat-fallback lane otherwise |
+| `implementation_level` | `model_native_duplex` | `model_native_duplex` | `model_native_duplex` | model-owned `response.listen` / `response.speak` (constant: every duplex model is model-native) |
| `chunk_period_ms` | 1000 | 80 | 80 | the model unit that `response.listen` decisions and camera frames align to |
| `supports_session_resume` | yes | no | yes | `session.resume`, `session.resumed`, `session.replaced`, replay after a transport drop |
| `supports_barge_in` | yes | no | no | `barge_in`, `turn.signal{event:"barge_in"}`, `overlap_policy=barge_in_on_speech`, `turn_detection.server_vad` |
@@ -311,13 +417,13 @@ work unmodified. The overlap falls into the three tiers used by the event catalo
| Direction | Messages |
| --- | --- |
| Client → server | `session.update`, `input_audio_buffer.append` / `.commit` / `.clear`, `conversation.item.create` / `.retrieve` / `.truncate` / `.delete`, `response.create`, `response.cancel`, `output_audio_buffer.clear` (OpenAI: WebRTC-only; here also a WebSocket event) |
-| Server → client | `error` (same `{type, code, message, event_id, param}` envelope and the same classes `invalid_request_error` / `server_error` / `rate_limit_error`), `session.created`, `session.updated`, `input_audio_buffer.committed` / `.cleared` / `.speech_started` / `.speech_stopped`, `conversation.item.created` / `.added` / `.done` / `.deleted` / `.retrieved` / `.truncated`, `conversation.item.input_audio_transcription.completed`, `response.created`, `response.done`, `response.output_item.added` / `.done`, `response.content_part.added` / `.done`, `response.audio.delta` / `.done`, `response.audio_transcript.delta` / `.done`, `response.output_text.delta` / `.done`, `response.function_call_arguments.delta` / `.done`, `rate_limits.updated`, `output_audio_buffer.cleared` |
+| Server → client | `error` (same `{type, code, message, event_id, param}` envelope and the same classes `invalid_request_error` / `server_error` / `rate_limit_error`), `session.created`, `session.updated`, `input_audio_buffer.committed` / `.cleared` / `.speech_started` / `.speech_stopped`, `conversation.item.created` / `.added` / `.done` / `.deleted` / `.retrieved` / `.truncated`, `conversation.item.input_audio_transcription.completed`, `response.created`, `response.done`, `response.output_item.added` / `.done`, `response.content_part.added` / `.done`, `response.output_audio.delta` / `.done`, `response.output_audio_transcript.delta` / `.done`, `response.output_text.delta` / `.done`, `response.function_call_arguments.delta` / `.done`, `rate_limits.updated`, `output_audio_buffer.cleared` |
| Session fields | `model`, `modalities` / `output_modalities`, `instructions`, `voice`, `input_audio_format` / `output_audio_format` (beta spelling) **and** `audio.input.format` / `audio.output.format` objects (GA spelling), `turn_detection.server_vad {threshold, prefix_padding_ms, silence_duration_ms}`, `input_audio_transcription`, `input_audio_noise_reduction`, `tools`, `tool_choice`, `temperature`, `max_response_output_tokens`, `speed`, `tracing` |
| Object shapes | `realtime.session`, `realtime.item`, `realtime.response`; item content parts `input_text` / `input_audio` / `output_text` / `output_audio`; `function_call` and `function_call_output` items; `previous_item_id` chaining; `output_index` / `content_index` addressing |
| Sequencing | `response.created → output_item.added → content_part.added → deltas → *.done → content_part.done → output_item.done → response.done → rate_limits.updated`; `speech_started → speech_stopped → committed` |
The server deliberately emits and accepts **both** the beta and the GA
-spellings at once (`response.audio.delta` alongside `output_modalities`,
+spellings at once (`response.output_audio.delta` alongside `output_modalities`,
`conversation.item.added` alongside `conversation.item.created`) so either
generation of OpenAI client parses the stream.
@@ -327,9 +433,9 @@ A stock client ignores the extra keys; the extensions are additive.
| Message | Extension |
| --- | --- |
-| `session.created` / `session.updated` | top-level `incarnation`, `attachment_generation`, `resume_token`; inside `session`: `state`, `turn_state`, `epoch`, `turn_id`, `active_request_id`, `active_response_id`, `active_response_turn_id`, `overlap_policy`, `overlap_*_ms/rms`, `playback_commit_policy`, `playback`, `capabilities`, `ref_audio`, `extra_body`, `idle_timeout_s`, `response_format` |
+| `session.created` / `session.updated` | top-level `attachment_generation`, `resume_token`; inside `session`: `state`, `turn_state`, `epoch`, `turn_id`, `active_request_id`, `active_response_id`, `active_response_turn_id`, `overlap_policy`, `overlap_*_ms/rms`, `playback_commit_policy`, `playback`, `capabilities`, `ref_audio`, `extra_body`, `idle_timeout_s`, `response_format` |
| `response.created` / `response.done` / `response.listen` | `response_id` at top level; the raw duplex event under `response.metadata` (`duplex_event`); `status_details.reason` uses vLLM reasons (`barge_in`, `client_cancelled`, `new_response`, …) |
-| `response.audio.delta` | `format`, `sample_rate_hz`, `metadata{session_id, epoch, model_speak, end_of_turn, audio_duration_ms, audio_text_marks, playback}` |
+| `response.output_audio.delta` | `format`, `sample_rate_hz`, `metadata{session_id, epoch, model_speak, end_of_turn, audio_duration_ms, audio_text_marks, playback}` |
| `response.speak` (inserted before the first delta) | not an OpenAI event, but rides the OpenAI response envelope (`response_id`, `item_id`, `output_index`, `content_index`) |
| `input_audio_buffer.append` | `is_speech`, `video_frames` (+ `max_slice_nums`), `duration_ms`, `audio_end_ms`, per-event `format` / `sample_rate_hz` |
| `input_audio_buffer.commit` | `final`, `response_create`, `is_speech:false` (silence declaration) |
@@ -343,14 +449,9 @@ Semantic divergences hidden behind shared names:
before any commit; a commit may end in `response.listen` and no
response, and the model may open a response with no commit at all.
OpenAI: commit ⇒ item, `response.create` ⇒ exactly one response.
-- With `turn_detection={"type":"server_vad"}`, omitted `interrupt_response`
- defaults to `false` on turn-based models such as Qwen3-Omni, enabling
- endpointing and automatic responses without interruption. Explicit `true`
- is rejected there. Model-native duplex keeps the `true` default and rejects
- `false`; its VAD implies `overlap_policy=barge_in_on_speech`.
- This capability-dependent default differs from OpenAI's `true` default;
- the effective configuration is returned in `session.created` / `session.updated`.
- `semantic_vad` is unsupported.
+- `turn_detection.interrupt_response=false` is rejected;
+ `create_response` is ignored; `semantic_vad` is unsupported; the VAD
+ runs per session (Silero) and implies `overlap_policy=barge_in_on_speech`.
- `rate_limits.updated` is always an empty list (compatibility only).
- Cancellation never reuses a `response_id`; truncation is driven by
`playback.ack` as well as `conversation.item.truncate`.
@@ -359,11 +460,11 @@ Semantic divergences hidden behind shared names:
| Area | Messages / fields |
| --- | --- |
-| Session lifetime | `session.heartbeat` / `session.heartbeat_ack`, `session.event_ack`, `session.close` / `session.closed`, `session.resume` / `session.resumed` / `session.replaced` / `session.expired` / `session.resync_required`; `server_event_seq`, `resume_token`, `incarnation`, `attachment_generation`; query params `?duplex=1`, `autostart`, `resume`, `native_duplex` |
+| Session lifetime | `session.heartbeat` / `session.heartbeat_ack`, `session.event_ack`, `session.close` / `session.closed`, `session.resume` / `session.resumed` / `session.replaced` / `session.expired` / `session.resync_required`; `server_event_seq`, `resume_token`, `attachment_generation`; query params `?duplex=1`, `autostart`, `resume` |
| Turn-taking | `response.speak`, `response.listen`, `overlap.decision`, `overlap_policy`, `barge_in`, `turn.signal`, `input.cancel`, `input.text.append`, `epoch` / `turn_id` fencing, `force_listen` |
| Playback truth | `playback.ack` / `playback.acknowledged`, `playback_commit_policy` (`ack_only` \| `commit_all_on_done`), `playback{generated_ms, sent_ms, played_ms, committed_ms}`, `history_committed`, `audio_text_marks` |
-| Model negotiation | `capabilities{implementation_level, supports_input_append, supports_barge_in, supports_session_resume, chunk_period_ms, input_modes, …}`, `ref_audio`, `extra_body` (`auto_response`, `native_duplex` — deprecated alias `minicpmo45_native_duplex` is folded into it, `force_listen_count`, `duplex_initial_user_text`) |
-| Diagnostics | `runtime.control`, `duplex.function_call.done`, the full error-code vocabulary (`REALTIME_ERROR_TYPES_BY_CODE` in `vllm_omni/entrypoints/duplex/realtime_state.py`) |
+| Model negotiation | `capabilities{implementation_level, supports_input_append, supports_barge_in, supports_session_resume, chunk_period_ms, input_modes, …}`, `ref_audio`, `extra_body` (`auto_response`, `force_listen_count`, `duplex_initial_user_text`) |
+| Diagnostics | `duplex.function_call.done`, the full error-code vocabulary (`REALTIME_ERROR_TYPES_BY_CODE` in `vllm_omni/engine/duplex/events.py`) |
#### Consequences for clients
@@ -381,14 +482,61 @@ Semantic divergences hidden behind shared names:
the Tier 3 surface on top of the same Tier 1 events, so both kinds of
client can share one deployment and one event log.
+### Turn-based Chat audio metadata
+
+For turn-based models served through Chat fallback, non-empty audio choices
+produced by the Omni Chat audio encoder include `audio_metadata`, in both
+streaming and complete Chat responses. Text-only choices do not require it.
+
+| Field | Meaning |
+| --- | --- |
+| `format` | Encoding of the returned audio, such as `pcm` or `wav`; `pcm16` requests normalize to `pcm`. |
+| `sample_rate_hz` | Sample rate used by the encoder, in samples per second. |
+| `frame_count` | Samples per channel in this chunk, or in the complete waveform for a non-streaming choice. Not a byte count. |
+| `channels` | Number of audio channels. |
+
+These values describe the waveform after audio transformations. Compressed
+formats may add codec padding that is not included in `frame_count`.
+The encoder uses a model-reported sample rate when available; Qwen3-Omni
+currently omits it and uses the existing 24 kHz default. This metadata does
+not independently determine a model's sample rate.
+
+Chat fallback requests the session's output format explicitly. Its internal
+`response.output_audio.delta` carries `format`, `sample_rate_hz`, `channels`,
+and `audio_duration_ms`. The Realtime endpoint exposes output `format` and
+`sample_rate_hz` on `response.audio.delta`, with duration in
+`metadata.audio_duration_ms`; it does not currently forward `channels`.
+The duration is cumulative for the response:
+integer frames are summed before converting to milliseconds and rounding down.
+Non-empty audio without valid metadata, or a sample-rate change within one
+response, fails that fallback response with `response_error`.
+
+In this Chat fallback path, `response.done.response.metadata.playback` reports:
+
+- `generated_ms`: cumulative source-waveform duration.
+- `sent_ms`: cumulative audio accepted into the server output path, including
+ queued or journaled audio. A journaled event remains accepted if the
+ subsequent send fails; this is not proof of socket delivery.
+- `played_ms`: cumulative playback reported by the client.
+- `committed_ms`: playback position used for history reconciliation.
+
+The native model path still records audio before sending; it does not yet
+share Chat fallback's output-acceptance timing. Only client playback reports
+establish what was played. The existing duration-proportional text truncation
+remains an approximation, not word-level audio alignment.
+
### Event catalogue
Every event type the Realtime route accepts or emits, with a worked example in the next section: 21
-client→server and 42 server→client. Aliases (`push_chunk`,
-`input.audio.append`, `input_text.append`, `push_text`, `signal_turn`,
-`audio.playback_ack`, `close_session`, `close`, `session_close`) share the
-payload of their canonical event and are not listed separately; the alias
-table lives in the normative contract section of [Full-Duplex Runtime (MiniCPM-o 4.5)](../design/fullduplex.md).
+client→server and 42 server→client. Aliases (`input_text.append`,
+`push_text`, `signal_turn`, `audio.playback_ack`, `close_session`, `close`)
+share the payload of their canonical event and are not listed
+separately; the alias table is `translate_realtime_command` in
+`vllm_omni/engine/duplex/realtime_commands.py`. The pre-Realtime WAV-append
+aliases `push_chunk` and `input.audio.append` are **not** accepted on this
+route: send `input_audio_buffer.append` with one of the supported input
+formats (`pcm16`, `pcm_s16le`, `s16le`, `pcm_f32le`, `g711_ulaw`,
+`g711_alaw`).
#### Client to server
@@ -398,18 +546,18 @@ table lives in the normative contract section of [Full-Duplex Runtime (MiniCPM-o
| `session.heartbeat` | 3 | Keepalive; refreshes the engine session lease. |
| `session.event_ack` | 3 | Acknowledges received server events by `server_event_seq`; trims the resume replay journal. |
| `session.close` | 3 | Graceful close; answered with `session.closed`. |
-| `session.resume` | 3 | Re-attach a live session on a new socket (`resume_token`, `incarnation`, `last_received_server_event_seq`). |
+| `session.resume` | 3 | Re-attach a live session on a new socket (`session_id`, `resume_token`, `last_received_server_event_seq`). |
| `input_audio_buffer.append` | 2 | Append one audio chunk. OpenAI name; extended with `is_speech`, `video_frames`, `duration_ms`, `audio_end_ms`, per-event `format`/`sample_rate_hz`. |
| `input_audio_buffer.commit` | 2 | Seal the buffered utterance into a user item. OpenAI name; extended with `final`, `response_create`, `is_speech:false`. |
| `input.commit` | 3 | Alias of `input_audio_buffer.commit` accepted by the runner. |
| `input_audio_buffer.clear` | 1 | Drop un-committed input audio. |
| `input.cancel` | 3 | Cancel pending input; advances `epoch`. |
-| `input.text.append` | 3 | Append text to the open input item (chat-fallback lane only). |
+| `input.text.append` | 3 | Append text input; model-native sessions reject it with `native_text_append_unsupported`. |
| `conversation.item.create` | 1 | Add a completed user item (text, audio, or `function_call_output`) to history. |
| `conversation.item.retrieve` | 1 | Fetch a stored item by id. |
| `conversation.item.delete` | 1 | Delete a stored item by id. |
| `conversation.item.truncate` | 1 | Truncate an assistant item's audio/transcript at `audio_end_ms`. |
-| `response.create` | 1 | Explicitly request a response with per-response overrides. |
+| `response.create` | 1 | Explicitly request a response with per-response overrides. Requires committed audio input; otherwise `response_create_without_input`. |
| `response.cancel` | 1 | Cancel the active (or named) response; advances `epoch`. |
| `output_audio_buffer.clear` | 1 | Discard queued output audio; advances `epoch` (OpenAI: WebRTC-only, here also WebSocket). |
| `barge_in` | 3 | Explicit hard interrupt; requires `capabilities.supports_barge_in`. |
@@ -420,10 +568,10 @@ table lives in the normative contract section of [Full-Duplex Runtime (MiniCPM-o
| Event type | Tier | Description |
| --- | --- | --- |
-| `session.created` | 2 | Session opened. OpenAI name; adds `incarnation`, `attachment_generation`, `resume_token` and vLLM-Omni keys inside `session` (`epoch`, `turn_id`, `playback`, `capabilities`, ...). |
+| `session.created` | 2 | Session opened. OpenAI name; adds `attachment_generation`, `resume_token` and vLLM-Omni keys inside `session` (`id` is the server-allocated session id, `epoch`, `turn_id`, `playback`, `capabilities`, ...). |
| `session.updated` | 2 | Echo of the effective session config after every `session.update` (same extended `session` object). |
| `session.heartbeat_ack` | 3 | Reply to `session.heartbeat`. |
-| `session.closed` | 3 | Last event on the socket; `reason` ∈ `session_close`, `timeout`, `disconnect`, ... |
+| `session.closed` | 3 | Last event on the socket, emitted once the engine released the session; `reason` ∈ `client_close`, `disconnect`, `timeout`, `transport_error`, `shutdown`, ... |
| `session.resumed` | 3 | Resume accepted; carries the new `attachment_generation` and rotated `resume_token`; journaled events are replayed after it. |
| `session.replaced` | 3 | Sent to the superseded socket when another socket resumes the session. |
| `session.expired` | 3 | Engine lease reaped (`disconnect_grace_expired`, `idle_ttl_expired`); socket closes after it. |
@@ -443,12 +591,12 @@ table lives in the normative contract section of [Full-Duplex Runtime (MiniCPM-o
| `response.output_item.added` | 1 | Assistant item (or `function_call` item) attached to the response. |
| `response.content_part.added` | 1 | Audio/text content part opened on the assistant item. |
| `response.speak` | 3 | Model chose to speak (native lane); at most once per response, before the first audio delta. |
-| `response.audio.delta` | 2 | One ordered audio chunk. OpenAI name; adds `format`, `sample_rate_hz`, `metadata{epoch, model_speak, end_of_turn, audio_text_marks, playback}`. |
-| `response.audio_transcript.delta` | 1 | Transcript text paired one-to-one with each audio delta. |
+| `response.output_audio.delta` | 2 | One ordered audio chunk. OpenAI name; adds `format`, `sample_rate_hz`, `metadata{epoch, model_speak, end_of_turn, audio_text_marks, playback}`. |
+| `response.output_audio_transcript.delta` | 1 | Transcript text paired one-to-one with each audio delta. |
| `response.output_text.delta` | 1 | Text delta for text-modality responses. |
| `response.output_text.done` | 1 | Final text of a text-modality response. |
-| `response.audio.done` | 1 | Audio stream closed for the response. |
-| `response.audio_transcript.done` | 1 | Full transcript (concatenation of the deltas). |
+| `response.output_audio.done` | 1 | Audio stream closed for the response. |
+| `response.output_audio_transcript.done` | 1 | Full transcript (concatenation of the deltas). |
| `response.content_part.done` | 1 | Content part finalized with its transcript/text. |
| `response.output_item.done` | 1 | Assistant item finalized. |
| `response.done` | 2 | Terminal event; `status` ∈ `completed` \| `cancelled` \| `failed`. OpenAI name; vLLM-Omni `status_details.reason` values and raw duplex event under `metadata`. |
@@ -459,8 +607,8 @@ table lives in the normative contract section of [Full-Duplex Runtime (MiniCPM-o
| `duplex.function_call.done` | 3 | Raw fallback when a model tool call is malformed. |
| `output_audio_buffer.cleared` | 1 | Reply to `output_audio_buffer.clear`. |
| `playback.acknowledged` | 3 | Reply to `playback.ack`; returns the playback ledger and `history_committed`. |
-| `overlap.decision` | 3 | How an append that overlapped an active response was handled (`drop` \| `listen` \| `barge_in`). |
-| `runtime.control` | 3 | Diagnostic: an engine control signal was partially unsupported. |
+| `overlap.decision` | 3 | How an append that overlapped an active response was handled (`drop` \| `listen` \| `barge_in`); details under `metadata`. |
+| `turn.event` | 3 | A turn-taking signal the session accepted (`event`, resulting `turn_state`). |
| `error` | 2 | Error envelope. OpenAI shape and classes; vLLM-Omni error codes. |
### Message examples
@@ -479,7 +627,6 @@ additionally carries `"server_event_seq": ` (omitted below for brevity).
"event_id": "evt_0001",
"session": {
"model": "openbmb/MiniCPM-o-4_5",
- "session_id": "sess_demo_01",
"modalities": ["audio", "text"],
"instructions": "You are a concise voice assistant.",
"input_audio_format": "pcm16",
@@ -492,7 +639,6 @@ additionally carries `"server_event_seq": ` (omitted below for brevity).
"idle_timeout_s": 300,
"extra_body": {
"auto_response": true,
- "native_duplex": true,
"force_listen_count": 0
}
}
@@ -504,13 +650,12 @@ additionally carries `"server_event_seq": ` (omitted below for brevity).
```json
{
"type": "session.created",
- "incarnation": 0,
"attachment_generation": 1,
"resume_token": "rt_3f9c…",
"session": {
"object": "realtime.session",
"type": "realtime",
- "id": "sess_demo_01",
+ "id": "duplex-5f2a…",
"model": "openbmb/MiniCPM-o-4_5",
"state": "open",
"turn_state": "idle",
@@ -563,7 +708,7 @@ additionally carries `"server_event_seq": ` (omitted below for brevity).
`session.created` and every later `session.update`)
```json
-{"type": "session.updated", "session": {"object": "realtime.session", "id": "sess_demo_01", "…": "…"}}
+{"type": "session.updated", "session": {"object": "realtime.session", "id": "duplex-5f2a…", "…": "…"}}
```
`C→S session.heartbeat` / `S→C session.heartbeat_ack`
@@ -573,7 +718,7 @@ additionally carries `"server_event_seq": ` (omitted below for brevity).
```
```json
-{"type": "session.heartbeat_ack", "session_id": "sess_demo_01"}
+{"type": "session.heartbeat_ack", "session_id": "duplex-5f2a…"}
```
`C→S session.event_ack`
@@ -589,7 +734,7 @@ additionally carries `"server_event_seq": ` (omitted below for brevity).
```
```json
-{"type": "session.closed", "session_id": "sess_demo_01", "reason": "session_close"}
+{"type": "session.closed", "session_id": "duplex-5f2a…", "epoch": 3, "reason": "client_close", "event": {"type": "session.closed", "reason": "client_close"}}
```
`C→S session.resume` (new socket, `?duplex=1&autostart=0`)
@@ -597,8 +742,7 @@ additionally carries `"server_event_seq": ` (omitted below for brevity).
```json
{
"type": "session.resume",
- "session_id": "sess_demo_01",
- "incarnation": 0,
+ "session_id": "duplex-5f2a…",
"resume_token": "rt_3f9c…",
"last_received_server_event_seq": 40
}
@@ -610,8 +754,8 @@ events with `server_event_seq > 40` are replayed in order)
```json
{
"type": "session.resumed",
- "session_id": "sess_demo_01",
- "incarnation": 0,
+ "session_id": "duplex-5f2a…",
+ "session": {"id": "duplex-5f2a…", "state": "open", "epoch": 3, "turn_id": 2, "...": "the engine's current session object"},
"attachment_generation": 2,
"resume_token": "rt_a71e…"
}
@@ -620,19 +764,19 @@ events with `server_event_seq > 40` are replayed in order)
`S→C session.replaced` (sent to the superseded socket, which is then closed)
```json
-{"type": "session.replaced", "session_id": "sess_demo_01", "attachment_generation": 1}
+{"type": "session.replaced", "session_id": "duplex-5f2a…", "attachment_generation": 1}
```
`S→C session.expired`
```json
-{"type": "session.expired", "session_id": "sess_demo_01", "incarnation": 0, "reason": "disconnect_grace_expired"}
+{"type": "session.expired", "session_id": "duplex-5f2a…", "reason": "disconnect_grace_expired"}
```
`S→C session.resync_required`
```json
-{"type": "session.resync_required", "session_id": "sess_demo_01", "reason": "journal_gap"}
+{"type": "session.resync_required", "session_id": "duplex-5f2a…", "reason": "journal_gap"}
```
#### Input
@@ -653,7 +797,10 @@ events with `server_event_seq > 40` are replayed in order)
```
with camera frames (omni video; rides the append that closes a 1 s model
-unit; each entry is a **bare** base64 JPEG/PNG — no `data:` URL prefix)
+unit; each entry is a **bare** base64 JPEG/PNG — no `data:` URL prefix).
+Only a model whose Stage 0 interleaves a frame track at unit boundaries takes
+this field; a turn model has no such boundary and rejects it, expecting
+`conversation.item.create` with `input_image` content instead.
```json
{
@@ -723,7 +870,7 @@ silence/noise declaration (no runtime append; answered with `response.listen`)
"item_id": "item_9b2d…",
"event": {
"type": "input.committed",
- "session_id": "sess_demo_01",
+ "session_id": "duplex-5f2a…",
"turn_id": 1,
"epoch": 0,
"history_len": 1,
@@ -753,7 +900,7 @@ deferred commit during an active response (`event.response_create_deferred`)
"type": "input_audio_buffer.committed",
"previous_item_id": "item_9b2d…",
"item_id": "item_c044…",
- "event": {"type": "input.committed", "session_id": "sess_demo_01", "turn_id": 2, "epoch": 0, "history_len": 3, "response_create_deferred": true, "message": {"…": "…"}}
+ "event": {"type": "input.committed", "session_id": "duplex-5f2a…", "turn_id": 2, "epoch": 0, "history_len": 3, "response_create_deferred": true, "message": {"…": "…"}}
}
```
@@ -830,6 +977,10 @@ deferred commit during an active response (`event.response_create_deferred`)
{"type": "conversation.item.truncated", "item_id": "item_resp_01", "content_index": 0, "audio_end_ms": 1850, "event": {"…": "…"}}
```
+After this acknowledgement, `conversation.item.retrieve` returns the truncated
+transcript. Without text/audio alignment marks, its prefix is estimated from
+the requested position and audio duration; this is not exact word alignment.
+
#### Response lifecycle
`C→S response.create`
@@ -862,7 +1013,7 @@ deferred commit during an active response (`event.response_create_deferred`)
"status_details": null,
"output": [],
"modalities": ["audio", "text"],
- "metadata": {"duplex_event": {"type": "response.created", "session_id": "sess_demo_01", "response_id": "resp_01", "epoch": 0, "turn_id": 1}}
+ "metadata": {"duplex_event": {"type": "response.created", "session_id": "duplex-5f2a…", "response_id": "resp_01", "epoch": 0, "turn_id": 1}}
}
}
```
@@ -898,15 +1049,15 @@ deferred commit during an active response (`event.response_create_deferred`)
"item_id": "item_resp_01",
"output_index": 0,
"content_index": 0,
- "metadata": {"session_id": "sess_demo_01", "epoch": 0, "model_speak": true}
+ "metadata": {"session_id": "duplex-5f2a…", "epoch": 0, "model_speak": true}
}
```
-`S→C response.audio.delta` (one ~1 s unit, 24 kHz PCM16)
+`S→C response.output_audio.delta` (one ~1 s unit, 24 kHz PCM16)
```json
{
- "type": "response.audio.delta",
+ "type": "response.output_audio.delta",
"response_id": "resp_01",
"item_id": "item_resp_01",
"output_index": 0,
@@ -915,7 +1066,7 @@ deferred commit during an active response (`event.response_create_deferred`)
"format": "pcm16",
"sample_rate_hz": 24000,
"metadata": {
- "session_id": "sess_demo_01",
+ "session_id": "duplex-5f2a…",
"epoch": 0,
"model_speak": true,
"end_of_turn": false,
@@ -926,10 +1077,10 @@ deferred commit during an active response (`event.response_create_deferred`)
}
```
-`S→C response.audio_transcript.delta` (exactly one per audio delta)
+`S→C response.output_audio_transcript.delta` (exactly one per audio delta)
```json
-{"type": "response.audio_transcript.delta", "response_id": "resp_01", "item_id": "item_resp_01", "output_index": 0, "content_index": 0, "delta": "It is sunny"}
+{"type": "response.output_audio_transcript.delta", "response_id": "resp_01", "item_id": "item_resp_01", "output_index": 0, "content_index": 0, "delta": "It is sunny"}
```
`S→C response.output_text.delta` / `response.output_text.done` (text modality)
@@ -942,14 +1093,14 @@ deferred commit during an active response (`event.response_create_deferred`)
{"type": "response.output_text.done", "response_id": "resp_02", "item_id": "item_resp_02", "output_index": 0, "content_index": 0, "text": "Tomorrow looks cloudy."}
```
-`S→C response.audio.done` / `response.audio_transcript.done`
+`S→C response.output_audio.done` / `response.output_audio_transcript.done`
```json
-{"type": "response.audio.done", "response_id": "resp_01", "item_id": "item_resp_01", "output_index": 0, "content_index": 0}
+{"type": "response.output_audio.done", "response_id": "resp_01", "item_id": "item_resp_01", "output_index": 0, "content_index": 0}
```
```json
-{"type": "response.audio_transcript.done", "response_id": "resp_01", "item_id": "item_resp_01", "output_index": 0, "content_index": 0, "transcript": "It is sunny, 24 degrees."}
+{"type": "response.output_audio_transcript.done", "response_id": "resp_01", "item_id": "item_resp_01", "output_index": 0, "content_index": 0, "transcript": "It is sunny, 24 degrees."}
```
`S→C response.content_part.done`
@@ -980,7 +1131,7 @@ deferred commit during an active response (`event.response_create_deferred`)
"status": "completed",
"status_details": null,
"output": [{"id": "item_resp_01", "object": "realtime.item", "type": "message", "role": "assistant", "status": "completed", "content": [{"type": "output_audio", "transcript": "It is sunny, 24 degrees."}]}],
- "metadata": {"type": "response.done", "session_id": "sess_demo_01", "response_id": "resp_01", "epoch": 0, "committed": true, "playback": {"generated_ms": 2400, "sent_ms": 2400, "played_ms": 2400, "committed_ms": 2400}}
+ "metadata": {"type": "response.done", "session_id": "duplex-5f2a…", "response_id": "resp_01", "epoch": 0, "committed": true, "playback": {"generated_ms": 2400, "sent_ms": 2400, "played_ms": 2400, "committed_ms": 2400}}
}
}
```
@@ -1006,9 +1157,9 @@ deferred commit during an active response (`event.response_create_deferred`)
```json
{
"type": "response.listen",
- "session_id": "sess_demo_01",
+ "session_id": "duplex-5f2a…",
"epoch": 0,
- "response": {"object": "realtime.response", "status": "listening", "metadata": {"type": "response.listen", "session_id": "sess_demo_01", "epoch": 0, "reason": "model_listen"}}
+ "response": {"object": "realtime.response", "status": "listening", "metadata": {"type": "response.listen", "session_id": "duplex-5f2a…", "epoch": 0, "reason": "model_listen"}}
}
```
@@ -1055,7 +1206,7 @@ function-call response (Nemotron VoiceChat tools)
raw fallback when a model tool call is malformed (`call_id`/`name` missing)
```json
-{"type": "duplex.function_call.done", "event": {"type": "function_call.done", "session_id": "sess_demo_01", "epoch": 0, "call_id": null, "name": null, "arguments": "{"}}
+{"type": "duplex.function_call.done", "event": {"type": "function_call.done", "session_id": "duplex-5f2a…", "epoch": 0, "call_id": null, "name": null, "arguments": "{"}}
```
#### Playback
@@ -1071,7 +1222,7 @@ raw fallback when a model tool call is malformed (`call_id`/`name` missing)
"type": "playback.acknowledged",
"event": {
"type": "playback.acknowledged",
- "session_id": "sess_demo_01",
+ "session_id": "duplex-5f2a…",
"epoch": 0,
"item_id": "item_resp_01",
"played_ms": 1000,
@@ -1096,20 +1247,17 @@ truncating ack after an interruption (what was actually heard)
```json
{
"type": "overlap.decision",
- "session_id": "sess_demo_01",
+ "session_id": "duplex-5f2a…",
"epoch": 0,
"policy": "listen_only",
"action": "listen",
"reason": "long_overlap_speech",
- "overlap_ms": 1400,
- "buffer_audio": true,
- "defer_runtime_append": true,
- "force_listen": true
+ "metadata": {"overlap_ms": 1400, "buffer_audio": true, "defer_runtime_append": true, "force_listen": true}
}
```
```json
-{"type": "overlap.decision", "session_id": "sess_demo_01", "epoch": 0, "policy": "barge_in_on_speech", "action": "barge_in", "reason": "server_vad_utterance_active"}
+{"type": "overlap.decision", "session_id": "duplex-5f2a…", "epoch": 0, "policy": "barge_in_on_speech", "action": "barge_in", "reason": "server_vad_utterance_active"}
```
server-VAD session (`session.update` fragment) and the annotated append the
@@ -1123,12 +1271,6 @@ translator forwards internally (shown for reference; clients never see it)
{"type": "input_audio_buffer.append", "audio": "…", "format": "pcm16", "sample_rate_hz": 16000, "is_speech": true, "force_listen": true, "vad": {"backend": "silero", "is_speech": true, "speech_active": true, "speech_started": true, "speech_stopped": false, "speech_probability": 0.93}}
```
-`S→C runtime.control` (only when an engine signal was partially unsupported)
-
-```json
-{"type": "runtime.control", "session_id": "sess_demo_01", "epoch": 1, "result": {"…": "redacted engine control result"}}
-```
-
#### Errors
`S→C error` — Realtime shape
@@ -1154,71 +1296,80 @@ translator forwards internally (shown for reference; clients never see it)
```
```json
-{"type": "error", "error": {"type": "rate_limit_error", "code": "duplex_session_busy", "message": "duplex session capacity reached (max_sessions=4)"}}
+{"type": "error", "error": {"type": "rate_limit_error", "code": "resource_exhausted", "message": "duplex_session_capacity_exhausted: limit=4"}}
```
Handshake-stage errors (before the Realtime projector is bound) use the
flat native shape:
```json
-{"type": "error", "error": "Unknown or expired duplex session: sess_demo_01", "code": "session_resume_expired"}
+{"type": "error", "error": "Unknown or expired duplex session: duplex-5f2a…", "code": "session_resume_expired"}
```
#### Coverage
The examples above cover every event type the Realtime route emits or
accepts (verified by diffing the `type` literals in
-`vllm_omni/entrypoints/duplex/` against this document). Not shown, because
-they never reach a `/v1/realtime?duplex=1` client unprojected: the native
-`/v1/duplex` dialect (`session.create` / `open_session` / `session.config`,
-`input.committed`, `input.cancelled`, `audio.cancelled`,
-`response.output_audio.delta`, `response.text.delta`, `response.message`,
-`function_call.done`) — their Realtime projections are the examples above
-(see the name map at the end of this section) — and the runner-internal markers `__timeout__`,
-`__disconnect__`, `__replaced_attachment__`. Input aliases (`push_chunk`,
-`input.audio.append`, `input_text.append`, `push_text`, `signal_turn`,
-`audio.playback_ack`, `close_session`, `close`, `session_close`) share the
-payload of their canonical event (see the alias table in [Full-Duplex Runtime (MiniCPM-o 4.5)](../design/fullduplex.md)).
+`vllm_omni/engine/duplex/events.py` and `vllm_omni/engine/duplex/commands.py`
+against this document). Not shown, because they never reach a client
+unprojected: the session-internal events the runner produces
+(`input.committed`, `input.cancelled`, `audio.cancelled`,
+`response.output_audio.delta`, `response.text.delta`, `function_call.done`)
+— their Realtime projections are the examples above (see the name map at the
+end of this section). Every public event is a typed `DuplexEvent` whose
+`to_realtime()` is the wire object; every client event is parsed into a
+typed `DuplexCommand` by `command_from_realtime()`.
-#### Native to Realtime name map
+#### Internal to Realtime name map
-The native `/v1/duplex` dialect is what the session runner produces; the
-Realtime projector renames and fans out these events before they reach a
-`/v1/realtime?duplex=1` client.
+The session runner emits these session-internal events; the Realtime
+projector (`vllm_omni/engine/duplex/realtime_events.py`) renames and fans
+them out into typed events before they reach a client.
-| Native / internal event | Realtime projection |
+| Internal event | Realtime projection |
| --- | --- |
-| `session.create` (`open_session`, `session.config`) | first `session.update` |
| `input.committed` | `conversation.item.added` / `conversation.item.created`, `input_audio_buffer.committed`, `conversation.item.input_audio_transcription.completed`, `conversation.item.done` |
| `input.cancelled` | `input_audio_buffer.cleared` |
-| `response.output_audio.delta` (with `audio_transcript`) | optional `response.speak`, then `response.audio.delta` and `response.audio_transcript.delta` |
-| `response.output_audio.done` / `response.output_text.done` | `response.audio.done` / `response.output_text.done` |
+| `response.output_audio.delta` (with `audio_transcript`) | optional `response.speak`, then `response.output_audio.delta` and `response.output_audio_transcript.delta` |
+| `response.output_audio.done` / `response.output_text.done` | `response.output_audio.done` / `response.output_text.done` |
| `response.text.delta` | `response.output_text.delta` |
-| `response.message` (chat-fallback raw chunk) | passed through |
| `audio.cancelled` | optional `output_audio_buffer.cleared`, then the cancelled terminal events and `response.done` with `status: "cancelled"` |
| `function_call.done` | `response.created`, `response.output_item.added` (`function_call` item), `response.function_call_arguments.delta` / `.done`, `response.output_item.done`, `response.done` |
| `playback.acknowledged` | `playback.acknowledged` (wrapped verbatim under `event`) |
-| `runtime.control`, `session.resumed`, `session.replaced`, `session.expired`, `session.resync_required`, `session.heartbeat_ack` | passed through unchanged |
+| `session.resumed`, `session.replaced`, `session.expired`, `session.resync_required`, `session.heartbeat_ack` | passed through unchanged |
## Known Limitations
+- Only MiniCPM-o 4.5 is served over this endpoint today; PersonaPlex and
+ Nemotron VoiceChat arrive with the follow-up PRs that port them to the
+ plugin contract.
- Several surfaces are capability-gated per model (see *Capability
negotiation by model* above): PersonaPlex does not support session resume,
barge-in, or audio truncation; Nemotron VoiceChat does not support barge-in
or audio truncation; camera frames are consumed only by MiniCPM-o 4.5; tool
calls are produced only by Nemotron VoiceChat.
-- `turn_detection` supports `server_vad` or `null`, not `semantic_vad`.
- Turn-based models support endpointing without interruption, including
- `create_response=false` for explicit response creation. Model-native duplex
- requires `interrupt_response=true` and `create_response=true`.
+- A response is generated from committed **audio**, so the OpenAI text-prompt
+ shape does not drive one: `conversation.item.create` with an `input_text`
+ part adds the item to history, but a following `response.create` with no
+ committed audio is rejected with `response_create_without_input`, and
+ `input.text.append` is rejected with `native_text_append_unsupported`.
+ Text-to-speech has its own shape here — put the text in
+ `extra_body.duplex_initial_user_text` on `session.update`, then stream audio
+ units (silence is enough). The seeded turn is what the model answers.
+- `turn_detection` supports only `server_vad` with `interrupt_response=true`;
+ `semantic_vad`, `interrupt_response=false`, and `create_response` are not
+ supported.
- `input_audio_transcription` and `input_audio_noise_reduction` are accepted
and echoed but no separate transcription or noise-reduction stage runs;
transcripts come from the model.
- `rate_limits.updated` is emitted for compatibility only and always carries
an empty list.
- Session capacity is bounded by `duplex_session.max_sessions` in the deploy
- configuration; admission beyond it fails with `duplex_session_busy` or
- `resource_exhausted`.
-- The native `/v1/duplex` route speaks the internal dialect and does not
- support `session.resume`; use `/v1/realtime?duplex=1` for the contract on
- this page.
+ configuration; admission beyond it fails with `resource_exhausted`
+ (`rate_limit_error`, retryable).
+- A duplex deployment serves the websocket route and, when supported by the
+ model, ordinary `/v1/chat/completions` requests on the same engine without a
+ session per request (see [Start the Server](#start-the-server)). Other
+ turn-based HTTP routes report "not available" in duplex mode. To use the
+ ordinary online serving stack, select `session_mode: turn` as described in
+ [Full Duplex](full_duplex_api.md#enable-full-duplex).
diff --git a/docs/serving/speech_api.md b/docs/serving/speech_api.md
index f0658f278..c0b8fe5e2 100644
--- a/docs/serving/speech_api.md
+++ b/docs/serving/speech_api.md
@@ -6,8 +6,9 @@ vLLM-Omni provides an OpenAI-compatible API for text-to-speech (TTS) generation.
- **Fish Speech S2 Pro** (`fishaudio/s2-pro`) -- Dual-AR TTS with DAC codec. Supports text-to-speech and voice cloning via reference audio. Output: 44.1 kHz.
- **Voxtral TTS** (`mistralai/Voxtral-4B-TTS-2603`) -- AR + FlowMatching TTS with preset voices. Output: 24 kHz.
- **CosyVoice3** (`FunAudioLLM/Fun-CosyVoice3-0.5B-2512`) -- 2-stage talker + flow-matching code2wav. Voice cloning via `ref_audio` + `ref_text` (no presets). Output: 24 kHz.
+- **Gepard-1.0** (`nineninesix/gepard-1.0`) -- single-stage native-AR TTS with a 22.05 kHz NanoCodec. Zero-shot default voice only.
-See the [Supported Models](#supported-models) section below for the full list, including OmniVoice, VoxCPM2, and MOSS-TTS-Nano.
+See the [Supported Models](#supported-models) section below for the full list, including OmniVoice, VoxCPM2, MOSS-TTS-Nano, and Breeze-TTS-2.
!!! tip "Deployment recipes"
TTS deployment recipes are published at
@@ -40,11 +41,16 @@ vllm serve mistralai/Voxtral-4B-TTS-2603 --omni --port 8091
# CosyVoice3 (voice cloning only — supply ref_audio + ref_text per request)
vllm serve FunAudioLLM/Fun-CosyVoice3-0.5B-2512 \
--omni --port 8091 --trust-remote-code
+
+# Gepard-1.0 (zero-shot default voice; packaged gepard.yaml)
+vllm-omni serve nineninesix/gepard-1.0 --omni --port 8091 --trust-remote-code \
+ --stage-init-timeout 900 \
+ --deploy-config vllm_omni/deploy/gepard.yaml
```
### Generate Speech
-**Using curl:**
+**Qwen3-TTS CustomVoice, using curl:**
```bash
curl -X POST http://localhost:8091/v1/audio/speech \
@@ -56,7 +62,24 @@ curl -X POST http://localhost:8091/v1/audio/speech \
}' --output output.wav
```
-**Using Python:**
+**Gepard-1.0, using curl:**
+
+Gepard accepts `input` (required), `voice` (`"default"`), `response_format`,
+`stream` / `stream_format`, `max_new_tokens`, and `seed`. Unsupported fields
+include `speed`, `language`, `instructions`, `task_type`, `ref_audio`,
+`ref_text`, `extra_params`, and `word_timestamps`.
+
+```bash
+curl -X POST http://localhost:8091/v1/audio/speech \
+ -H "Content-Type: application/json" \
+ -d '{
+ "input": "Hello, this is Gepard speaking.",
+ "voice": "default",
+ "seed": 7
+ }' --output output.wav
+```
+
+**Qwen3-TTS CustomVoice, using Python:**
```python
import httpx
@@ -498,6 +521,15 @@ are cached in-process with a shared LRU so repeated requests with the same
all TTS model types; deleting a voice invalidates every model-type slot at
once.
+Decoded reference waveforms use a separate LRU cache of owned, contiguous
+float32 arrays at the source sampling rate. Its byte budget counts numeric
+buffers (four bytes per mono sample), excluding cache metadata. Numeric storage
+avoids retaining a Python float object for every sample during garbage
+collection. MOSS reference encoding consumes these arrays directly; other
+list-based interfaces, including MOSS Nano, receive temporary lists that are
+not retained in the resolve cache. The MOSS reference encoder also releases
+completed batch inputs before waiting for more work.
+
### Precomputed Custom Voices
Qwen3-TTS Base and VoxCPM2 can load offline-precomputed voices at startup.
@@ -761,6 +793,28 @@ Fish Speech uses `ref_audio` and `ref_text` for voice cloning (no `task_type` ne
| ------- | ------------- |
| `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | Voice cloning from `ref_audio` + `ref_text`. No built-in voice presets — upload a voice or pass `ref_audio`/`ref_text` per request. |
+### Gepard-1.0
+
+| Model | Description |
+| ----- | ----------- |
+| `nineninesix/gepard-1.0` | Zero-shot native-AR TTS. 22.05 kHz mono. `voice` must be omitted or `"default"`. |
+
+Gepard request fields:
+
+| Field | Behavior |
+| ----- | -------- |
+| `input` | Required. Empty/whitespace-only returns 400. |
+| `voice` | Omitted or `"default"` only. Other values 400. |
+| `response_format` | `wav` default. Non-streaming: `wav`/`pcm`/`flac`/`mp3`. `opus` returns 400 (22.05 kHz is not an Opus sample rate). Streaming: `pcm`/`wav` only. |
+| `stream` / `stream_format` | SSE (`speech.audio.*`) and raw `audio` byte streaming. |
+| `speed` | Must be `1.0`. Gepard has no native speed control. |
+| `max_new_tokens` | Frame budget (1 token = 1 frame = 1024 samples ≈ 46.4 ms). Default 1000 from deploy YAML; adapter bounds 1..4096. |
+| `seed` | Optional. Reaches the in-model 32-head sampler. The packaged YAML currently pins `seed: 42`, so serving is deterministic by default until that pin is removed. |
+| `extra_params` | Any key returns 400, including `temperature`/`top_p`/`top_k`. |
+| Cloning / style fields | `ref_audio`, `ref_text`, `speaker_embedding`, `instructions`, `language`, `task_type`, `word_timestamps`, and similar declared-but-unsupported fields return 400. |
+
+See the [Gepard section of the online TTS hub](../user_guide/examples/online_serving/text_to_speech.md#gepard-10) for launch commands. Native-AR recompute preemption is a known limitation under concurrency.
+
### OmniVoice
| Model | Description |
@@ -777,12 +831,52 @@ hidden-state dtype before the output projection. Consequently, a float32 stage c
| ------- | ------------- |
| `openbmb/VoxCPM2` | TTS + voice cloning with built-in speaker presets and uploaded-voice support. Accepts `voice` (preset or uploaded) or `ref_audio` + optional `ref_text`. |
+#### Startup LoRA adapter
+
+To serve a fine-tuned VoxCPM2 voice, set
+`voxcpm2_runtime_config.startup_lora_path` in the stage's `hf_overrides`.
+Copy `vllm_omni/deploy/voxcpm2.yaml` to a local deploy config and add this key
+alongside its existing runtime settings:
+
+```yaml
+# Under stages[0].engine_extras.hf_overrides.voxcpm2_runtime_config:
+startup_lora_path: /absolute/path/to/adapter
+```
+
+Then launch with the modified config:
+
+```bash
+vllm serve openbmb/VoxCPM2 --omni --deploy-config /absolute/path/to/voxcpm2-lora.yaml
+```
+
+The directory must be accessible to the worker and contain the native VoxCPM2
+training export: `lora_config.json` (with a `lora_config` object containing
+`r`, `alpha`, enabled groups, and target module names) and
+`lora_weights.safetensors`. PEFT checkpoints and pickle checkpoints are not
+accepted. Missing, unexpected, non-finite, or incorrectly shaped adapter
+tensors fail model loading rather than silently loading a partial adapter.
+
+The adapter is merged into the base LM, residual LM, LocDiT, and optional
+projection layers selected by its configuration, after base weight loading
+and before compilation or CUDA graph capture. All requests use that adapter;
+`voice` still selects a reference voice, not a LoRA adapter. Changing adapters
+requires restarting the server. Runtime loading/unloading, per-request
+multi-LoRA selection, and `load_format=dummy` are not supported by this path.
+Weight fusion rounds to the base model's dtype, so numerical and speech-quality
+parity should be checked against the upstream adapter on your deployment.
+
### MOSS-TTS-Nano
| Model | Description |
| ------- | ------------- |
| `OpenMOSS-Team/MOSS-TTS-Nano` | Voice cloning only. Requires `ref_audio` (or an uploaded `voice`); no built-in voice presets. `ref_text` is accepted but ignored — upstream's `voice_clone` mode does not consume a transcript. |
+### Breeze-TTS-2
+
+| Model | Description |
+| ------- | ------------- |
+| `BreezeBlue/Breeze-TTS-2` | Two-stage AR TTS (T5Gemma2 + Qwen3 talker with a depth decoder, bundled Qwen3-TTS codec) at 24 kHz. Four modes are selected from the request fields: plain (`input` + speaker tag `voice`, `S0`..`S9`), voice design (`instructions`), voice clone (`ref_audio` + `ref_text`, exactly one clip), and voice direction (reference + `instructions`). Greedy decoding only: `sample_rate` must be `24000`, `speed` must be `1.0`, and `guidance_scale`/`cfg_scale` other than `1.0`, `negative_prompt`, `temperature`/`top_p`/`top_k` overrides, `language`, and `speaker_embedding` are rejected. Streaming returns PCM `speech.audio.delta` events. See [`recipes/BreezeBlue/Breeze-TTS-2.md`](https://github.com/vllm-project/vllm-omni/blob/main/recipes/BreezeBlue/Breeze-TTS-2.md). |
+
## Error Responses
### 400 Bad Request
diff --git a/docs/serving/streaming_video_output_api.md b/docs/serving/streaming_video_output_api.md
index d01489540..b219ee019 100644
--- a/docs/serving/streaming_video_output_api.md
+++ b/docs/serving/streaming_video_output_api.md
@@ -41,7 +41,7 @@ bytes.
| Server to client | `video.start` | Confirms the request ID, format, and accepted configuration |
| Server to client | `video.chunk_metadata` | Describes the next binary media frame |
| Server to client | Binary frame | Carries one fragmented MP4 chunk |
-| Client to server | `session.interaction` | Queues an optional prompt update during generation |
+| Client to server | `session.interaction` | Queues an optional interaction (e.g., prompt. camera action) during generation |
| Server to client | `session.interaction.queued` | Confirms that an interaction was queued |
| Client to server | `session.ping` | Refreshes the stall clock |
| Server to client | `session.pong` | Acknowledges a ping |
@@ -83,10 +83,39 @@ Models that implement interaction updates can change the active prompt:
}
```
+Models that register the camera modality (for example LingBot-World) also
+accept structural SE3 payloads on `event.multi_modal_data.camera`. Coordinates
+follow Unity semantics (`+X` right, `+Y` up, `+Z` forward; same as LingBot's
+native camera frame). WASD key tokens are not admitted by the engine:
+
+```json
+{
+ "type": "session.interaction",
+ "interaction": {
+ "event_id": "forward-1",
+ "event": {
+ "multi_modal_data": {
+ "camera": {
+ "mode": "velocity",
+ "data": {
+ "translation": [0.0, 0.0, 0.05],
+ "rotation": [0.0, 0.0, 0.0, 1.0]
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+`mode` is `target` (lerp to an absolute pose over `transition_chunks`) or
+`velocity` (hold a per-frame SE3 delta until replaced). Prompt and camera may
+be combined in one interaction event.
+
This feature is pipeline-dependent. An enabled endpoint does not imply that
-the loaded model supports midway prompt updates.
+the loaded model supports midway prompt or camera updates.
The default start timeout is 10 seconds and the server reports a stall after
about 60 seconds without generation progress or a `session.ping`. See the
[streaming video generation example](https://github.com/vllm-project/vllm-omni/tree/main/examples/online_serving/streaming_video_generation)
-for prompt-update scheduling and browser playback.
+for prompt/camera-update scheduling and browser playback.
diff --git a/docs/serving/video_stream_api.md b/docs/serving/video_stream_api.md
index e1f583991..23aa4cb08 100644
--- a/docs/serving/video_stream_api.md
+++ b/docs/serving/video_stream_api.md
@@ -45,11 +45,19 @@ WebSocket /v1/video/chat/stream
| Server -> Client | `response.start` | none | Query generation started. |
| Server -> Client | `response.text.delta` | `delta` | Incremental text output. |
| Server -> Client | `response.text.done` | `text` | Final text output for the query. |
-| Server -> Client | `response.audio.delta` | `data`, `format` | Incremental generated audio, base64 WAV. |
-| Server -> Client | `response.audio.done` | none | Audio output finished. |
+| Server -> Client | `response.output_audio.delta` | `data`, `format` | Incremental generated audio, base64 WAV. |
+| Server -> Client | `response.output_audio.done` | none | Audio output finished. |
| Server -> Client | `session.done` | none | Session closed. |
| Server -> Client | `error` | `message` | Recoverable protocol or generation error. |
+### Frame Consumption Reporting
+
+When buffered frames include client-supplied `frame_id` values, the server emits `video.frames.consumed` after the first engine output. Its `frame_ids`, `frames`, and `latest_pts_ms` describe the images included in that query's prompt.
+
+Queries stride-sample the buffered frames, keeping the last frame, then exclude frames already known to have failed decoding. Excluded frames are not replaced with other buffered frames. Frames whose background decoding has not finished remain eligible through the image URL path.
+
+**Bugfix compatibility note:** `video.frames.consumed` now excludes known decode failures that were already excluded from the prompt. Older versions could report those frames and their timestamps as consumed. The event name and fields are unchanged; an empty selection reports empty lists and `latest_pts_ms: null`.
+
### `session.config` Fields
| Field | Type | Default | Description |
@@ -60,10 +68,24 @@ WebSocket /v1/video/chat/stream
| `max_frames` | integer, 1-256 | `50` | Maximum retained frame buffer size. Oldest frames are evicted first. |
| `system_prompt` | string or null | null | Optional custom system prompt. |
| `use_audio_in_video` | bool | `true` | Include streamed audio chunks in multimodal video understanding when audio is present. |
-| `sampling_params_list` | list or null | null | Optional per-stage sampling parameter overrides. |
+| `sampling_params_list` | list or null | null | Optional per-stage parameter dictionaries. Each provided entry replaces that stage's deployment sampling settings. |
| `enable_frame_filter` | bool | `true` | Enable EVS near-duplicate frame filtering. |
| `frame_filter_threshold` | float, 0.0-1.0 | `0.95` | EVS similarity threshold. Higher keeps more frames; lower drops more near-duplicates. |
+Sampling parameter bugfix: `sampling_params_list` is now forwarded to the engine;
+earlier versions accepted this field but silently used deployment defaults instead.
+Each provided entry constructs a fresh `SamplingParams` for that stage in pipeline
+order; it is not merged with that stage's deployment defaults. Fields absent from a
+provided entry use `SamplingParams` constructor defaults. Only omitted trailing
+stages keep their deployment defaults. An omitted, `null`, or empty list keeps the
+engine defaults. Invalid sampling parameters return an `error` when the query is
+submitted.
+
+For example, `[{"temperature": 0.2, "max_tokens": 64}]` configures the thinker while
+the talker and code2wav retain their deployment defaults. With only
+`[{"temperature": 0.2}]`, the thinker's `max_tokens` and `top_p` use constructor
+defaults, not its YAML values.
+
### Legacy Aliases
The server accepts these legacy field names and rewrites them before validation. New clients should send the canonical names above.
diff --git a/docs/serving/videos_api.md b/docs/serving/videos_api.md
index 09f023d27..da0fa1fd3 100644
--- a/docs/serving/videos_api.md
+++ b/docs/serving/videos_api.md
@@ -73,6 +73,10 @@ curl -L "http://localhost:8091/v1/videos/${video_id}/content" -o output.mp4
| `image_reference` | string | null | JSON-encoded reference image payload; do not combine with `input_reference` or `video_reference` |
| `video_reference` | string | null | JSON-encoded reference video payload; do not combine with `input_reference` or `image_reference` |
| `audio_reference` | string | null | JSON-encoded audio reference for speech-to-video: `{"audio_url": "..."}` — supports HTTP(s) URLs or base64 data URLs |
+| `source_video` | file | null | MiniMax H3 latent-edit source video (`.mp4` or `.mov`, up to 512 MiB) |
+| `source_audio` | file | null | Optional MiniMax H3 latent-edit source audio (`.wav` or `.mp3`, up to 512 MiB) |
+| `video_noise_mask` | file | null | UTF-8 JSON MiniMax H3 video mask; `0` preserves and `1` regenerates a token |
+| `audio_noise_mask` | file | null | UTF-8 JSON MiniMax H3 audio mask; `0` preserves and `1` regenerates a token |
| `width` | integer | model default | Output video width |
| `height` | integer | model default | Output video height |
| `num_frames` | integer | 1 | Number of generated frames |
@@ -109,6 +113,16 @@ curl -L "http://localhost:8091/v1/videos/${video_id}/content" -o output.mp4
The final content is available from `/v1/videos/{video_id}/content` after the
job status becomes `completed`.
+`queued` means the request is still waiting for diffusion scheduler admission.
+The status changes to `in_progress` when the scheduler first selects the
+request for execution.
+
+`DELETE /v1/videos/{video_id}` issues a bounded engine abort
+(`VLLM_OMNI_ABORT_TIMEOUT`, default 2s), then cancels the frontend
+task. Cancellation cleanup is also bounded and best-effort: it confirms
+the abort was queued, and the current request batch may still drain.
+The job is then re-read so a completed save is not orphaned.
+
### Synchronous Response
`POST /v1/videos/sync` blocks until generation finishes and returns raw video
@@ -204,6 +218,49 @@ upstream vLLM's media URL allowlist protection. Deployments should therefore
treat remote media URLs as untrusted and choose this setting as part of their
URL access policy.
+### MiniMax H3 Latent-Mask Editing
+
+MiniMax H3 accepts request-scoped source media and video/audio noise masks.
+At least one mask is required. A nontrivial `video_noise_mask` requires
+`source_video`, while a nontrivial `audio_noise_mask` requires either
+`source_audio` or a `source_video` with an audio stream. Mask values are in
+`[0, 1]`: `0` preserves the source, `1` regenerates it, and fractional values
+blend the two behaviors. Exact all-one masks are no-ops and do not require a
+source. Source uploads without a mask are rejected. Masks may be a JSON scalar
+or arrays matching the H3 latent/token grid; pixel-resolution masks must be
+resized or pooled by the client before upload.
+
+For an aligned output of `F` frames at `W x H`, the video latent grid is
+`[Tv, H/16, W/16]`, where `Tv = 2 + 5 * ((F - 5) / 17)`. The video mask may be
+a scalar, a flat token vector, `[Tv, H/32, W/32]`, or the full latent grid. For
+the model input, timestep, and velocity, a full-grid mask is max-pooled over
+each 2x2 spatial token and fractional values are rounded upward to 1/256
+levels. The final x0 restore uses the original, unquantized mask, so full-grid
+masks retain cell-level preservation inside a model token. The audio length is
+`Ta = round(F * 40 / 24)`, and its mask may be a scalar, `[Ta]`, `[2, Ta]`, or
+a flat `2 * Ta` vector. If source audio is short, its missing latent tail is
+forced to mask value `1` so H3 generates that portion. Each mask must be sent
+as a UTF-8 JSON file part and is limited to 8 MiB. A file may contain either a
+JSON scalar or an array.
+
+```bash
+curl -s http://localhost:8091/v1/videos/sync \
+ -F "prompt=Partially restyle the complete clip and soundtrack" \
+ -F 'extra_params={"task":"t2va","duration":4.0,"aspect_ratio":"16:9"}' \
+ -F "source_video=@source.mp4;type=video/mp4" \
+ -F "source_audio=@source.wav;type=audio/wav" \
+ -F "video_noise_mask=@video-mask.json;type=application/json" \
+ -F "audio_noise_mask=@audio-mask.json;type=application/json" \
+ -o edited.mp4
+```
+
+For example, each mask file in the request above may contain the JSON scalar
+`0.5`.
+
+The server streams source files to temporary request-scoped storage and removes
+them after synchronous or asynchronous generation finishes. Models that do not
+declare latent-mask editing support reject these fields.
+
### Speech-to-Video
For models that support audio-driven generation (e.g., Wan2.2-S2V), pass both
@@ -234,6 +291,48 @@ curl -X POST http://localhost:8091/v1/videos/sync \
-o output.mp4
```
+## Output Encoding
+
+These `extra_params` control how the server turns decoded frames into MP4 bytes.
+
+| Field | Type | Default | Description |
+| --- | --- | --- | --- |
+| `preencode_mp4` | boolean | false | Encode the MP4 on the worker while the VAE is still decoding, instead of after the full video is materialized |
+| `preencode_batch_frames` | positive integer | 17 (H3, Wan T2V/I2V); 1 (Wan S2V) | Minimum accumulated frames per worker transfer/encoding batch; used only with `preencode_mp4=true` |
+| `video_codec_options` | object | null | Encoder options passed through to the H.264 encoder, such as `{"preset": "ultrafast", "threads": "0"}` |
+
+With `preencode_mp4` enabled, each committed VAE chunk leaves the accelerator and
+is encoded while later chunks are still decoding, so host transfer and CPU
+encoding overlap the remaining decode instead of following it. The response is
+unchanged: the same complete MP4, byte-for-byte equivalent frames.
+
+```bash
+curl -X POST http://localhost:8091/v1/videos/sync \
+ -F "prompt=A small robot walking through a neon city" \
+ -F 'extra_params={"preencode_mp4": true, "preencode_batch_frames": 33, "video_codec_options": {"preset": "ultrafast"}}' \
+ -o output.mp4
+```
+
+Set `preencode_batch_frames` in `extra_params` (or `extra_args` for offline
+sampling) to tune batching. The worker accumulates complete VAE chunks until
+it has at least this many frames, then transfers and encodes them together.
+It always flushes the final partial batch. This is a threshold, not an exact
+chunk length: a value of 1 submits every native chunk immediately, and a value
+smaller than a native chunk does not split it. Larger values reduce transfers
+but retain more frames on the accelerator and delay encoding. The VAE decode
+window and output frame count stay unchanged. Wan S2V keeps its existing
+per-clip behavior by default. Zero, negative, fractional, boolean, string, and
+null values are rejected when pre-encoding is enabled.
+
+`preencode_mp4` applies to the complete-MP4 response paths only. The
+`/v1/realtime/video` WebSocket endpoint rejects it, because that path already
+overlaps encoding through its own incremental fragmented-MP4 encoder. Wan also
+rejects it together with `enable_frame_interpolation`, which needs the decoded
+frames the pre-encoded path no longer materializes.
+
+Support is per model: MiniMax-H3 and Wan 2.2 (T2V, I2V, and S2V) implement it,
+and other models ignore the flag and take the full-decode path.
+
## Storage
Set `VLLM_OMNI_SERVER_STORAGE__PATH` to control where asynchronous video outputs are
diff --git a/docs/usage/metrics.md b/docs/usage/metrics.md
index 184a76eef..1e0c1d068 100644
--- a/docs/usage/metrics.md
+++ b/docs/usage/metrics.md
@@ -12,7 +12,7 @@ curl http://localhost:8000/metrics
## Metric Namespaces
| Prefix | Source | Present when |
-|--------|--------|--------------|
+| -------- | -------- | -------------- |
| `vllm_omni:` | vLLM-Omni orchestrator / audio modality / cross-stage transfer | Pipeline-dependent |
| `vllm:` | Upstream vLLM engine, wrapped by `OmniPrometheusStatLogger` to expose `{stage, replica}` | Pipeline includes an LLM (AR) stage |
| `http_` / `process_` | Uvicorn / Python runtime | Always |
@@ -24,7 +24,7 @@ Defined in `vllm_omni/metrics/prometheus.py`. Track request lifecycle across the
### Request counts
| Metric | Type | Labels | Description |
-|--------|------|--------|-------------|
+| -------- | ------ | -------- | ------------- |
| `vllm_omni:num_requests_running` | Gauge | `model_name` | Pipeline-global in-flight requests (dispatched to engine, not yet finalized) |
| `vllm_omni:num_requests_waiting` | Gauge | `model_name` | Requests waiting in the Orchestrator queue |
| `vllm_omni:requests_success_total` | Counter | `model_name`, `finished_reason` | Total requests by completion reason. `finished_reason` ∈ {`stop`, `length`, `abort`, ...} mirroring upstream `vllm:request_success_total`; aborts cover client disconnect / cancellation paths in addition to upstream `FinishReason.ABORT` |
@@ -32,15 +32,15 @@ Defined in `vllm_omni/metrics/prometheus.py`. Track request lifecycle across the
### Latency
| Metric | Type | Labels | Description |
-|--------|------|--------|-------------|
+| -------- | ------ | -------- | ------------- |
| `vllm_omni:e2e_request_latency_s` | Histogram | `model_name` | Pipeline-global end-to-end request latency in seconds |
## Audio Modality Metrics (`vllm_omni:`)
-Emitted at request finalize, except for `audio_ttfp_s` (streaming-hook at the first audio packet) and `audio_underrun_s` / `audio_continuity_ok_total` (streaming finalize, after the chunk stream is exhausted). All carry `{model_name, stage, replica}` plus the listed extra label.
+Emitted at request finalize, except for `audio_ttfp_s` (the first audio packet/frame from streaming Chat or Speech APIs) and `audio_underrun_s` / `audio_continuity_ok_total` (Chat or Speech streaming finalize, after the chunk stream is exhausted). Non-streaming Speech latency is represented by `e2e_request_latency_s`, not TTFP. All audio metrics carry `{model_name, stage, replica}` plus the listed extra label.
| Metric | Type | Extra label | Description |
-|--------|------|-------------|-------------|
+| -------- | ------ | ------------- | ------------- |
| `vllm_omni:audio_ttfp_s` | Histogram | — | Time from request arrival to first audio packet/frame |
| `vllm_omni:audio_duration_s` | Histogram | — | Audio content duration (`audio_frames / sample_rate`) |
| `vllm_omni:audio_rtf` | Histogram | — | Real-time factor (`stage_gen_time_s / audio_duration_s`); streaming TTS SLO red line `< 1`; uses `RTF_BUCKETS` |
@@ -50,13 +50,22 @@ Emitted at request finalize, except for `audio_ttfp_s` (streaming-hook at the fi
| `vllm_omni:audio_skipped_requests_total` | Counter | `reason` | Silent-loss counter — code2wav rejected malformed codec input and returned `200 OK` with empty audio |
The continuity math comes from `vllm_omni/benchmarks/audio_continuity.py::compute_continuity_stats` so the server-side observation aligns with the bench-side definition.
+For Speech, continuity applies to raw audio, SSE, and WebSocket streaming. WAV headers and empty chunks are excluded; non-streaming Speech requests do not emit continuity samples.
+
+Continuity rate for the default 100 ms threshold can be derived from the successful-continuity counter and the underrun Histogram's request count:
+
+```promql
+sum by (model_name, stage, replica) (rate(vllm_omni:audio_continuity_ok_total{threshold_ms="100"}[5m]))
+/
+sum by (model_name, stage, replica) (rate(vllm_omni:audio_underrun_s_count[5m]))
+```
## Diffusion Metrics (`vllm_omni:`)
Per-request timing breakdowns for diffusion (image/video) stages. Emitted at request finalize when `stage_metrics.diffusion_metrics` is present. All carry `{model_name, stage, replica}`.
| Metric | Type | Description |
-|--------|------|-------------|
+| -------- | ------ | ------------- |
| `vllm_omni:diffusion_exec_s` | Histogram | DiT forward pass execution time per request in seconds |
| `vllm_omni:diffusion_exec_per_step_s` | Histogram | DiT forward pass execution time per denoising step in seconds (`exec_s / num_inference_steps`) |
| `vllm_omni:diffusion_preprocess_s` | Histogram | Diffusion input preprocessing time per request in seconds |
@@ -80,7 +89,7 @@ rate(vllm_omni:diffusion_preprocess_s_sum[5m])
Per-physical-transfer histograms tracking the data hop between adjacent stages. Labels `{model_name, from_stage, from_replica, to_stage, to_replica}` let dashboards attribute latency to specific replica edges. `from_replica` / `to_replica` are resolved from the orchestrator's sticky-routing binding (`stage_pool.get_bound_replica_id(request_id)`), so no extra plumbing through `TransferEdgeStats` is needed.
| Metric | Type | Description |
-|--------|------|-------------|
+| -------- | ------ | ------------- |
| `vllm_omni:transfer_size_bytes` | Histogram | Per-transfer payload size in bytes |
| `vllm_omni:transfer_tx_s` | Histogram | Sender-side time (serialize + submit to connector) |
| `vllm_omni:transfer_rx_s` | Histogram | Receiver-side time (recv + deserialize) |
@@ -106,7 +115,7 @@ For the full list of upstream metrics, see [the vLLM docs](https://github.com/vl
## Metric Availability by Pipeline Type
| Metric group | Multi-stage LLM (Qwen3-Omni) |
-|---|---|
+| --- | --- |
| `vllm_omni:` request tracking + latency | With `--log-stats` |
| `vllm_omni:` audio modality | With `--log-stats`, if pipeline has a talker stage |
| `vllm_omni:` transfer | With `--log-stats`, if pipeline has ≥ 2 stages |
diff --git a/docs/user_guide/diffusion/attention_backends/fastvideo_vsa.md b/docs/user_guide/diffusion/attention_backends/fastvideo_vsa.md
index c03b9ffde..df6e34b0c 100644
--- a/docs/user_guide/diffusion/attention_backends/fastvideo_vsa.md
+++ b/docs/user_guide/diffusion/attention_backends/fastvideo_vsa.md
@@ -1,24 +1,33 @@
# FastVideo VSA
-FastVideo Variable Sparse Attention (VSA) accelerates the `FastVideo/FastWan2.2-TI2V-5B-Diffusers` model's
-self-attention by partitioning the post-patch latent grid into spatiotemporal
+FastVideo Variable Sparse Attention (VSA) partitions the post-patch latent grid into spatiotemporal
blocks. For every query block, VSA scores the key/value blocks and computes
attention only against the selected top-k blocks.
-The supported checkpoint provides both text-to-video and image-to-video modes through `Wan22Pipeline`; the separate Wan I2V-14B, S2V, and VACE pipelines are outside this backend's supported scope.
+## Supported models
-MiniMax-H3 served with a FastH3 VSA adapter uses a second, H3-specific route
-through the same backend. See the
-[MiniMax-H3 recipe](https://recipes.vllm.ai/MiniMaxAI/MiniMax-H3) for its geometry,
-supported topologies, and commands.
+| Model / checkpoint | Required adapter | Tasks | Sequence parallelism |
+| --- | --- | --- | --- |
+| `FastVideo/FastWan2.2-TI2V-5B-Diffusers` | None | T2V, I2V through `Wan22Pipeline` | Disabled |
+| `MiniMaxAI/MiniMax-H3` | [FastH3 VSA (recipe)](https://github.com/vllm-project/vllm-omni/blob/main/recipes/MiniMaxAI/MiniMax-H3.md#fasth3-adapter) | T2VA | Disabled or pure Ulysses |
+| Wan I2V-14B, S2V, VACE | — | Unsupported | — |
-VSA is a CUDA-only, explicitly selected backend. It requires the
-`fastvideo-kernel` package and currently supports non-causal self-attention
-with equal query and key/value sequence lengths. Unsupported shapes, masks,
-dtypes, sequence-parallel execution, or kernel failures fall back to
-`TORCH_SDPA` and emit a warning with the reason. On the Wan route, an active
-sequence-parallel context is one of those fallbacks; the H3 route supports pure
-Ulysses and rejects ring or all-gather sequence parallelism at startup.
+## Installation
+
+Install vLLM-Omni from this checkout with the `vsa` extra:
+
+```bash
+uv pip install -e '.[vsa]'
+```
+
+The extra installs the tested kernel dependency automatically. Prebuilt kernels require
+Linux, Python 3.12, and glibc 2.34 or newer (x86-64 or aarch64). The full
+FastVideo framework and provider environment variables are not required.
+
+`FASTVIDEO_VSA` selects the attention algorithm. On SM120, the H3 integration
+currently executes FastVideo's 64-token Triton block-sparse kernel; it does not
+dispatch to FlashInfer. The `vsa` extra installs `fastvideo-kernel==0.3.4` for
+this execution path.
## Enable the backend
@@ -27,24 +36,18 @@ flag. Use `--fastvideo-vsa-topk` to set the number of key/value blocks retained
for every query block:
```bash
-vllm-omni serve \
+vllm serve --omni \
--diffusion-attention-backend FASTVIDEO_VSA \
--fastvideo-vsa-topk 64
```
-The backwards-compatible environment variable selects the backend with the
-default `topk=64`:
+
+Alternative configuration formats
+
+Equivalent structured configuration:
```bash
-export DIFFUSION_ATTENTION_BACKEND=FASTVIDEO_VSA
-vllm-omni serve
-```
-
-To tune top-k, pass the CLI backend and top-k flags together as shown above,
-or use the equivalent structured configuration:
-
-```bash
-vllm-omni serve \
+vllm serve --omni \
--diffusion-attention-config \
'{"default":{"backend":"FASTVIDEO_VSA","fastvideo_vsa_topk":64}}'
```
@@ -73,8 +76,15 @@ stages:
fastvideo_vsa_topk: 64
```
+
+
## Choose top-k
+H3 uses 64-token video blocks and keeps
+all prefix blocks; see the [FastH3 VSA recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/MiniMaxAI/MiniMax-H3.md#fasth3-adapter).
+
+### Wan top-k behavior
+
At runtime the backend logs the sequence shape and derived block count:
```text
@@ -117,8 +127,16 @@ selecting VSA does not turn a native checkpoint into a distilled model.
## Verify routing and fallback
-Check the startup and first-forward logs instead of assuming that selecting
-the backend guarantees sparse execution:
+VSA is a CUDA-only, explicitly selected backend. It requires the
+`fastvideo-kernel` package and currently supports non-causal self-attention
+with equal query and key/value sequence lengths. Unsupported shapes, masks,
+dtypes, sequence-parallel execution, or kernel failures fall back to
+`TORCH_SDPA` and emit a warning with the reason. On the Wan route, an active
+sequence-parallel context is one of those fallbacks; the H3 route supports pure
+Ulysses and rejects ring or all-gather sequence parallelism at startup.
+H3 accelerator faults propagate instead of attempting dense recovery.
+
+On the Wan route, check the startup and first-forward logs:
- `route=VSA` means top-k block selection is active.
- `route=VSA_ALL_BLOCKS` means the FastVideo DMD checkpoint retained all
@@ -126,6 +144,10 @@ the backend guarantees sparse execution:
- `route=SDPA` or `FASTVIDEO_VSA falling back to SDPA: ...` means dense SDPA
executed; the warning includes the reason.
+For H3, check `FastH3 adapter active` at startup and
+`FASTVIDEO_VSA H3 routing` during DiT execution, as described in the
+[model recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/MiniMaxAI/MiniMax-H3.md#fasth3-adapter).
+
The Wan route requires CUDA tensors in FP16 or BF16, 256-token blocks,
standard `head_size**-0.5` scaling, equal Q/K/V head counts, no attention mask,
and no active sequence-parallel context. The MiniMax-H3 route uses 64-token
diff --git a/docs/user_guide/diffusion/attention_backends/rainfusion.md b/docs/user_guide/diffusion/attention_backends/rainfusion.md
index 00f4e37a1..1d91cabfa 100644
--- a/docs/user_guide/diffusion/attention_backends/rainfusion.md
+++ b/docs/user_guide/diffusion/attention_backends/rainfusion.md
@@ -78,3 +78,35 @@ Protected video tails require a compatible MindIE-SD release.
For common configuration and selector behavior, see the
[attention backend overview](../attention_backends.md) and the
[backend selection design](../../../design/feature/attention_backend_selection.md).
+
+## Wan2.2 T2V quantization on Ascend
+
+`RAINFUSION_ATTN` accepts `diffusion_kv_cache_dtype: fp8` or `mxfp4` for
+Wan2.2 T2V A14B.
+MindIE-SD must expose `sparse_attention` with `rf_v3` and an explicit `precision`
+argument. Omni passes the selected precision directly to this API; the installed
+native operators must support that precision. Unsupported inputs or an
+incompatible Python API raise an actionable error; Omni does not change BSA
+precision automatically. Update `diffusion_kv_cache_dtype` to a precision
+supported by the installed MindIE-SD, or unset it. Native execution errors also
+propagate without retry.
+BSA FP8 and MXFP4 retain MindIE-SD's default rotation policy.
+
+`diffusion_kv_cache_skip_layers`/`diffusion_kv_cache_skip_steps` explicitly
+select floating-point BSA and keep attention sparse. The existing `block_sparse`
+warmup/layer exclusions and short-sequence threshold instead select Dense
+attention, using the configured Dense precision. Leave `block_sparse.precision`
+at its default when using `diffusion_kv_cache_dtype` as the shared Dense/BSA
+precision setting.
+
+Configure BSA quantization in the model's existing deployment YAML with
+`backend: RAINFUSION_ATTN` and stage-level `diffusion_kv_cache_dtype: fp8` or
+`mxfp4`. The precision and skip selectors are siblings of
+`diffusion_attention_config`; they are not per-role `quant` fields.
+For 40 denoising steps, `diffusion_kv_cache_skip_steps: "0,1,38,39"` and
+`diffusion_kv_cache_skip_layers: "0,39"` are recommended starting points. These
+fixed step indices do not automatically follow a changed step count. Selected
+forwards remain floating-point BSA; unsupported BSA precision is an error rather
+than an automatic fallback. See
+[quantized attention](../../quantization/quantized_kvcache.md#wan22-t2v-quantized-attention-on-ascend)
+for the shared configuration structure and index semantics.
diff --git a/docs/user_guide/diffusion/device_side_video_postprocess.md b/docs/user_guide/diffusion/device_side_video_postprocess.md
new file mode 100644
index 000000000..910e64457
--- /dev/null
+++ b/docs/user_guide/diffusion/device_side_video_postprocess.md
@@ -0,0 +1,72 @@
+# Device-side video postprocessing
+
+vLLM-Omni can convert decoded WAN2.2 video from normalized floating-point
+`[B, C, T, H, W]` tensors to contiguous `uint8` `[B, T, H, W, C]` frames before
+the worker-to-engine device-to-host transfer. This reduces the transported video
+payload by four times when the original IPC path widens bfloat16 to float32.
+
+The optimization is disabled by default. Enable it with a structured diffusion
+configuration:
+
+```python
+from vllm_omni import Omni
+
+engine = Omni(
+ model="Wan-AI/Wan2.2-TI2V-5B-Diffusers",
+ video_output_transport={"enable_device_postprocess": True},
+)
+```
+
+For the server CLI, pass the same object as JSON:
+
+```bash
+vllm serve Wan-AI/Wan2.2-TI2V-5B-Diffusers --omni \
+ --video-output-transport '{"enable_device_postprocess": true}'
+```
+
+For a deploy configuration, place it on the diffusion stage:
+
+```yaml
+stages:
+ - stage_id: 0
+ video_output_transport:
+ enable_device_postprocess: true
+```
+
+## Runtime contract
+
+WAN2.2 emits decoded video through the typed `DiffusionOutput.media` field. The
+model runner validates and splits the batch into request-local tensors before
+preparing each video for transport. The worker then performs the D2H copy and
+shared-memory IPC using the prepared representation. The engine uses the generic
+media finalizer instead of a model-specific postprocessor.
+
+The runtime leaves the video in normalized floating-point form when:
+
+- `enable_device_postprocess` is false;
+- the requested output type is not `np`; or
+- frame interpolation still requires the floating-point tensor.
+
+These are policy fallbacks, not malformed-contract errors. If the device cannot
+allocate the temporary conversion buffer, the runner logs a warning and prepares
+the request-local float representation instead. Invalid tensor layout, encoding,
+value range, lifecycle state, and non-memory runtime failures still fail before
+IPC.
+
+## Precision
+
+Device preparation converts to float32 before denormalization and quantization.
+For bfloat16 WAN output this is more precise than the historical host path,
+which denormalizes in bfloat16. Relative to that path, values may differ by at
+most one level out of 255. Float32 inputs remain byte-identical after
+quantization.
+
+## Scope
+
+WAN2.2 is the reference migration for the first contract version. Other video
+pipelines continue using their existing output paths until they can declare the
+same typed media contract without bypassing model-specific float consumers such
+as safety checks or audio/video packaging.
+
+See [RFC #6541](https://github.com/vllm-project/vllm-omni/issues/6541) for the
+contract, lifecycle, batching rules, and migration plan.
diff --git a/docs/user_guide/diffusion/mot_config.md b/docs/user_guide/diffusion/mot_config.md
index 6661c6c9a..d25c1cd00 100644
--- a/docs/user_guide/diffusion/mot_config.md
+++ b/docs/user_guide/diffusion/mot_config.md
@@ -28,7 +28,7 @@ and their optimal Triton tile configurations:
Config loading order (3-tier, see ops/mot_gemm.py):
1. $VLLM_TUNED_CONFIG_FOLDER/ (env override)
- 2. This directory: vllm_omni/diffusion/layers/mot/configs/
+ 2. This directory: vllm_omni/diffusion/models/bagel/mot/configs/
3. Conservative default config (compiles everywhere, sub-optimal perf)
If no config file matches the current device, a warning is printed with
@@ -39,7 +39,7 @@ To generate configs for your hardware:
python benchmarks/kernels/mot_linear_benchmarks.py \
--model ByteDance-Seed/BAGEL-7B-MoT \
--tp-size 1 --dtype w16a16 --tune \
- --save-dir vllm_omni/diffusion/layers/mot/configs/
+ --save-dir vllm_omni/diffusion/models/bagel/mot/configs/
For multi-GPU tuning (uses Ray for parallel search):
diff --git a/docs/user_guide/diffusion/offloader/distributed_layerwise_offload.md b/docs/user_guide/diffusion/offloader/distributed_layerwise_offload.md
index b6e67c5ca..d36afb79e 100644
--- a/docs/user_guide/diffusion/offloader/distributed_layerwise_offload.md
+++ b/docs/user_guide/diffusion/offloader/distributed_layerwise_offload.md
@@ -353,9 +353,14 @@ modes can resolve the same `None` value to different schedules.
Cache-DiT on any selected component. Prompt-embedding cache is also
incompatible when the text encoder uses `allgather`. Rank-local cache hits
could otherwise make ranks enter different weight collectives.
-- Per-tensor online FP8 linears use the ordinary loader and can run with either
- DiT transfer path. With DiT `allgather`, every rank temporarily materializes
- the complete FP8 model in host memory before DLO retains only its shard.
+- Per-tensor online FP8, INT8, and MXFP8 linears use the ordinary loader and
+ can run with either DiT transfer path. With DiT `allgather`, every rank
+ temporarily materializes the complete quantized model in host memory before
+ DLO retains only its shard. Layers too wide for the NPU quantization kernel
+ (`npu_quant_matmul` rejects an output dimension past 65535) stay
+ unquantized and load straight into host memory under offload-after-quant —
+ their runtime layout is a plain contiguous bf16 weight, the same layout DLO
+ shards on the ordinary path, so they are allowed under `allgather` too.
Other online quantization methods require rank-local transfer for the
affected component until their runtime layouts are validated.
- Resident leading layers require DiT `rank-local` transfer and a model
diff --git a/docs/user_guide/diffusion/parallelism/cfg_parallel.md b/docs/user_guide/diffusion/parallelism/cfg_parallel.md
index 544754403..1e3b998d8 100644
--- a/docs/user_guide/diffusion/parallelism/cfg_parallel.md
+++ b/docs/user_guide/diffusion/parallelism/cfg_parallel.md
@@ -19,6 +19,12 @@ CFG-Parallel accelerates diffusion models by distributing positive and negative
See supported models list in [Supported Models](../../diffusion_features.md#supported-models).
+Distilled Cosmos3 checkpoints, including `nvidia/Cosmos3-Super-Image2Video-4Step`,
+run without CFG and reject `--cfg-parallel-size` greater than 1 at startup.
+Use `--ulysses-degree 2` for two-GPU inference. These checkpoints always use
+`guidance_scale=1.0`; other explicitly requested values produce a warning, and
+`negative_prompt` does not affect generation.
+
---
## Quick Start
diff --git a/docs/user_guide/diffusion/regional_compilation.md b/docs/user_guide/diffusion/regional_compilation.md
index 448f19518..364900ff5 100644
--- a/docs/user_guide/diffusion/regional_compilation.md
+++ b/docs/user_guide/diffusion/regional_compilation.md
@@ -34,5 +34,34 @@ that provide their own `setup_compile()` implementation manage their compilation
policy independently. Compilation is lazy, so backend or graph errors can first
surface on the initial request.
+## Pinning one packed shape (MiniMax-H3)
+
+Compiled regions are keyed by input shape. MiniMax-H3 packs a request into a
+row count that follows the request -- a longer prompt, more frames, a larger
+frame -- and rounds it up to the next 64-row boundary, so two requests can land
+on two different packed lengths.
+
+Under the default `diffusion_compile_dynamic: true` a new packed length is
+absorbed by the dynamic shape, so it is not by itself a recompilation. It still
+hands the backend a shape it has not autotuned, which is the risk this knob
+exists to remove: a fixed-shape deployment (`--no-diffusion-compile-dynamic`)
+recompiles the transformer blocks per shape, and a first-seen large shape can
+push autotuning into its worst-case memory use.
+
+A request pins the length with `extra_args["pad_seq_len"]`:
+
+```python
+sampling_params = SamplingParams(extra_args={"pad_seq_len": 54080})
+```
+
+The value must be a positive multiple of 64 and must cover the rows the request
+actually uses; the packed sequence is then padded to it instead of to the next
+64-row boundary. Pick a bucket that covers every request dimension the
+deployment accepts -- prompt length, frame count, frame size and reference
+blocks all change the used row count. The server logs the effective length as
+`MiniMax H3 packed sequence: ... pad_seq_len=... used=... seq_len=...` whenever
+a request pins it. The padding rows are masked out, so their cost is the
+attention and feed-forward work on those rows.
+
Use `--enforce-eager` to disable the model runner's generic compile setup.
Pipelines that compile internally define their own eager-mode behavior.
diff --git a/docs/user_guide/examples/offline_inference/hunyuan_image3.md b/docs/user_guide/examples/offline_inference/hunyuan_image3.md
deleted file mode 100644
index 636aa0c11..000000000
--- a/docs/user_guide/examples/offline_inference/hunyuan_image3.md
+++ /dev/null
@@ -1,35 +0,0 @@
-# HunyuanImage-3.0 Image-to-Text Inference
-
-Source .
-
-
-This example demonstrates how to run HunyuanImage-3.0 Image-to-Text with the vLLM-Omni.
-
-## Local CLI Usage
-
-Download the example image:
-
-```bash
-wget https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/cherry_blossom.jpg
-```
-
-Run example:
-
-```bash
-python image_to_text.py \
- --image cherry_blossom.jpg \
- --prompt "<|startoftext|>You are an assistant that understands images and outputs text. Describe the content of the picture."
-```
-
-Key arguments:
-
-- `--model`: Model used. Default is: tencent/HunyuanImage-3.0-Instruct (Optional).
-- `--image`: Path to input image (required).
-- `--prompt`: Text description used to guide image understanding (required).
-
-## Example materials
-
-??? abstract "image_to_text.py"
- ``````py
- --8<-- "examples/offline_inference/hunyuan_image3/image_to_text.py"
- ``````
diff --git a/docs/user_guide/examples/online_serving/qwen3_omni.md b/docs/user_guide/examples/online_serving/qwen3_omni.md
index d4e13fc89..654802c5c 100644
--- a/docs/user_guide/examples/online_serving/qwen3_omni.md
+++ b/docs/user_guide/examples/online_serving/qwen3_omni.md
@@ -2,7 +2,6 @@
Source .
-
## 🛠️ Installation
Please refer to [README.md](https://github.com/vllm-project/vllm-omni/tree/main/README.md)
@@ -15,6 +14,19 @@ Please refer to [README.md](https://github.com/vllm-project/vllm-omni/tree/main/
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091
```
+For a local deployment with multiple API frontend processes sharing one set
+of stage engines, add `--api-server-count`:
+
+```bash
+vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni \
+ --api-server-count 2 --port 8091
+```
+
+This mode supports local EngineCore stages with one local process group per
+replica, including stages configured with multiple `num_replicas`. Headless,
+remote, diffusion, intra-replica data-parallel, and Ray stage deployments
+remain single-frontend.
+
The default deployment configuration situated at `vllm_omni/deploy/qwen3_omni_moe.yaml` is resolved and loaded
automatically via the model registry, obviating the necessity for the `--deploy-config` flag in standard deployment topologies.
The bundled Qwen3-Omni setup explicitly sets `moe_backend: triton` for the Thinker and Talker stages. This keeps
@@ -22,7 +34,12 @@ both stages on vLLM's Triton unquantized MoE path and avoids the unstable FlashI
some GPUs.
Asynchronous chunk streaming is **enabled by default** within the bundled configuration.
+For Thinker pipeline parallelism, configure `pipeline_parallel_size` and `devices`
+on the Thinker stage. Keep Talker and Code2Wav at `pipeline_parallel_size: 1`. The
+default asynchronous scheduling and audio chunk streaming can remain enabled.
+
To explicitly utilize a custom deployment YAML, specify the configuration path:
+
```bash
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 \
--deploy-config /path/to/deploy_config_file
@@ -46,7 +63,7 @@ Adopt the stage-based CLI architecture to independently instantiate execution pr
The example below pins Stage 0 to GPU 0 and Stage 1/2 to GPU 1 via
`CUDA_VISIBLE_DEVICES`.
-**1. Stage 0 (Thinker + API server)**
+#### 1. Stage 0 (Thinker + API server)
```bash
CUDA_VISIBLE_DEVICES=0 vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni \
@@ -56,7 +73,7 @@ CUDA_VISIBLE_DEVICES=0 vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni \
--omni-master-port 26000
```
-**2. Stage 1 (Talker)**
+#### 2. Stage 1 (Talker)
```bash
CUDA_VISIBLE_DEVICES=1 vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni \
@@ -66,7 +83,7 @@ CUDA_VISIBLE_DEVICES=1 vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni \
--omni-master-port 26000
```
-**3. Stage 2 (Code2Wav)**
+#### 3. Stage 2 (Code2Wav)
```bash
CUDA_VISIBLE_DEVICES=1 vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni \
@@ -91,6 +108,9 @@ To experiment with another supported MoE backend, set `moe_backend` in the
deployment YAML for both the Thinker and Talker stages. For the stage-based
CLI, pass `--moe-backend` directly on each stage command.
+For other stage settings, pass the option on that stage command. For example,
+to change its GPU memory utilization:
+
```bash
CUDA_VISIBLE_DEVICES=1 vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni \
--stage-id 1 \
@@ -103,11 +123,12 @@ CUDA_VISIBLE_DEVICES=1 vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni \
### Send Multi-modal Request
Get into the example folder
+
```bash
cd examples/online_serving/qwen3_omni
```
-#### Send request via python
+#### Send request via python
```bash
python openai_chat_completion_client_for_multimodal_generation.py --query-type use_image --port 8091 --host "localhost"
@@ -122,7 +143,6 @@ The Python client supports the following command-line arguments:
- `--audio-path` (or `-a`): Path to local audio file or URL. If not provided and query-type is `use_audio`, uses default audio URL. Supports local file paths (automatically encoded to base64) or HTTP/HTTPS URLs and common audio formats: MP3, WAV, OGG, FLAC, M4A. Example: `--audio-path /path/to/audio.wav` or `--audio-path https://example.com/audio.mp3`
- `--prompt` (or `-p`): Custom text prompt/question. If not provided, uses default prompt for the selected query type. Example: `--prompt "What are the main activities shown in this video?"`
-
For example, to use a local video file with custom prompt:
```bash
@@ -132,19 +152,20 @@ python openai_chat_completion_client_for_multimodal_generation.py \
--prompt "What are the main activities shown in this video?"
```
-#### Send request via curl
+#### Send request via curl
```bash
bash run_curl_multimodal_generation.sh use_image
```
## Modality control
+
You can control output modalities to specify which types of output the model should generate. This is useful when you only need text output and want to skip audio generation stages for better performance.
### Supported modalities
| Modalities | Output |
-|------------|--------|
+| ------------ | -------- |
| `["text"]` | Text only |
| `["audio"]` | Audio only |
| `["text", "audio"]` | Text + Audio |
@@ -222,7 +243,9 @@ print(response.choices[1].message.audio) # Audio response
```
## Streaming Output
+
If you want to enable streaming output, please set the argument as below. The final output will be obtained just after generated by corresponding stage. We support both text streaming output and audio streaming output. Other modalities can output normally.
+
```bash
python openai_chat_completion_client_for_multimodal_generation.py \
--query-type use_image \
@@ -255,12 +278,14 @@ The convenience script launches both the vLLM server and Gradio demo together:
```
This script will:
+
1. Start the vLLM server in the background
2. Wait for the server to be ready
3. Launch the Gradio demo
4. Handle cleanup when you press Ctrl+C
The script supports the following arguments:
+
- `--model`: Model name/path (default: Qwen/Qwen3-Omni-30B-A3B-Instruct)
- `--server-port`: Port for vLLM server (default: 8091)
- `--gradio-port`: Port for Gradio demo (default: 7861)
@@ -271,18 +296,19 @@ The script supports the following arguments:
#### Option 2: Manual Launch (Two-Step Process)
-**Step 1: Launch the vLLM API server**
+##### Step 1: Launch the vLLM API server
```bash
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091
```
If you have a custom deploy config file:
+
```bash
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 --deploy-config /path/to/deploy_config_file
```
-**Step 2: Run the Gradio demo**
+##### Step 2: Run the Gradio demo
In a separate terminal:
@@ -295,7 +321,7 @@ Then open `http://localhost:7861/` on your local browser to interact with the we
The gradio script supports the following arguments:
- `--model`: Model name/path (should match the server model)
-- `--api-base`: Base URL for the vLLM API server (default: http://localhost:8091/v1)
+- `--api-base`: Base URL for the vLLM API server (default: )
- `--ip`: Host/IP for Gradio server (default: 127.0.0.1)
- `--port`: Port for Gradio server (default: 7861)
- `--share`: Share the Gradio demo publicly (creates a public link)
diff --git a/docs/user_guide/examples/online_serving/text_to_speech.md b/docs/user_guide/examples/online_serving/text_to_speech.md
index 9a33dc858..c1107ad60 100644
--- a/docs/user_guide/examples/online_serving/text_to_speech.md
+++ b/docs/user_guide/examples/online_serving/text_to_speech.md
@@ -18,6 +18,7 @@ For the full list of supported architectures across all modalities, see
| --- | --- | --- | --- | --- | --- |
| CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | ✓ (`ref_audio`+`ref_text`) | ✓ (PCM stream) | — | — |
| Fish Speech S2 Pro | `fishaudio/s2-pro` | ✓ (`ref_audio`+`ref_text`) | ✓ (PCM stream) | — | ✓ |
+| Gepard-1.0 | `nineninesix/gepard-1.0` | — (zero-shot default voice) | ✓ (PCM / WAV stream) | `default` only | — |
| higgs-audio v2 | `bosonai/higgs-audio-v2-generation-3B-base` | ✓ (`ref_audio`+`ref_text`) | ✓ (codec_streaming) | — | — |
| GLM-TTS | `zai-org/GLM-TTS` | ✓ (`ref_audio`+`ref_text`, required) | ✓ (PCM stream) | — | ✓ |
| OmniVoice | `k2-fsa/OmniVoice` | (offline only) | — | — | — |
@@ -92,7 +93,7 @@ curl -X POST http://localhost:8091/v1/audio/speech \
}' --no-buffer | play -t raw -r 24000 -e signed -b 16 -c 1 -
```
-Adjust the player's sample rate to match the model (44.1 kHz for Fish Speech, 48 kHz for VoxCPM2, 24 kHz for the others).
+Adjust the player's sample rate to match the model (44.1 kHz for Fish Speech, 48 kHz for VoxCPM2, 22.05 kHz for Gepard, 24 kHz for the others).
For full request-shape documentation (all parameters, response formats, error codes), see the [Speech API reference](https://github.com/vllm-project/vllm-omni/tree/main/docs/serving/speech_api.md).
@@ -120,6 +121,15 @@ vllm serve FunAudioLLM/Fun-CosyVoice3-0.5B-2512 --omni --port 8091 --trust-remot
Streaming is on by default via `async_chunk: true` in `vllm_omni/deploy/cosyvoice3.yaml`. Pass `--no-async-chunk` (or `NO_ASYNC_CHUNK=1 ./cosyvoice3/run_server.sh`) for the legacy synchronous path.
+Cross-request Stage-1 flow batching is opt-in. Enable it in the server environment when concurrent requests should share a flow-estimator call:
+
+```bash
+export COSYVOICE3_BATCH_FLOW=1
+vllm serve FunAudioLLM/Fun-CosyVoice3-0.5B-2512 --omni --port 8091 --trust-remote-code
+```
+
+Batching preserves output lengths and streaming cache alignment, but the different GEMM shapes can produce small waveform differences compared with processing each request separately. Leave `COSYVOICE3_BATCH_FLOW` unset (or set it to `0`) when request-independent numerical behavior is required. Set `COSYVOICE3_BATCH_FLOW_DEBUG=1` to log the observed group-size distribution and enable detailed Stage-1 profiler scopes; diagnostics are disabled by default to avoid per-step profiling overhead.
+
### CLI client
The client defaults to the official upstream zero-shot prompt, so it runs without extra flags:
@@ -203,6 +213,61 @@ python fish_speech/gradio_demo.py --api-base http://localhost:8091 # if server
---
+## Gepard-1.0
+
+Single-stage native AR TTS at 22.05 kHz mono. Zero-shot only: omit `voice` or pass `"default"`. Voice cloning from reference audio is not available yet.
+
+### Prerequisites
+
+Same NeMo NanoCodec install as the [offline Gepard section](https://github.com/vllm-project/vllm-omni/tree/main/examples/offline_inference/text_to_speech/README.md#gepard-10). On a host whose CUDA toolkit cannot build kernels — no `nvcc`/`ninja`, or a consumer Blackwell (`sm_120`) card — also `export VLLM_USE_FLASHINFER_SAMPLER=0` before launch. That env is not a deploy-YAML field.
+
+### Launch
+
+```bash
+vllm-omni serve nineninesix/gepard-1.0 --omni --port 8091 --trust-remote-code \
+ --stage-init-timeout 900 \
+ --deploy-config vllm_omni/deploy/gepard.yaml
+# or:
+./gepard/run_server.sh
+```
+
+`--stage-init-timeout 900` matches the online e2e fixture and the offline example. Serve defaults to 300s, which is often too short for a cold download of the talker plus NanoCodec.
+
+The packaged `vllm_omni/deploy/gepard.yaml` must be passed with `--deploy-config`. The checkpoint self-identifies as `qwen3_5_text`, so omitting the YAML launches a diffusion fallback instead of the Gepard pipeline. The YAML sets `async_chunk: false`, `max_num_seqs: 4`, and currently pins `seed: 42`, so serving is deterministic by default until that YAML seed is removed. Pass an explicit per-request `seed` in tests and clients rather than depending on either default.
+
+### Sending requests
+
+```bash
+python examples/online_serving/text_to_speech/gepard/speech_client.py \
+ --text "Hello, this is Gepard speaking."
+
+python examples/online_serving/text_to_speech/gepard/speech_client.py \
+ --text "Hello, this is Gepard speaking." --seed 7 --stream --output output.pcm
+```
+
+Gepard accepts `input` (required), `voice` (`"default"`), `response_format`, `stream` / `stream_format`, `max_new_tokens`, and `seed`. Unsupported fields include `speed`, `language`, `instructions`, `task_type`, `ref_audio`, `ref_text`, `extra_params`, and `word_timestamps`.
+
+```bash
+curl -X POST http://localhost:8091/v1/audio/speech \
+ -H "Content-Type: application/json" \
+ -d '{
+ "input": "Hello, this is Gepard speaking.",
+ "voice": "default",
+ "seed": 7
+ }' --output output.wav
+```
+
+### Notes
+
+- Output: 22.05 kHz mono. `max_new_tokens` is a **frame** budget (1 token = 1 frame = 1024 samples ≈ 46.4 ms at 21.5 fps; adapter bounds 1..4096).
+- Supported request fields: `input` (required), `voice` (`default` only), `response_format` (`wav` default; `wav/pcm/flac/mp3` non-streaming; `opus` 400 because 22.05 kHz is not an Opus sample rate; streaming `pcm/wav` only), `stream` / `stream_format`, `max_new_tokens`, `seed`.
+- Unsupported: `speed`, `extra_params` (including `temperature`/`top_p`/`top_k`), `ref_audio`, `ref_text`, `speaker_embedding`, `task_type`, `instructions`, `language`, and `word_timestamps`.
+- Concurrent requests at `max_num_seqs: 4` are supported. Native-AR recompute preemption is a known limitation of this architecture (a request that is preempted mid-generation can resume incorrectly); keep concurrency at or below `max_num_seqs` and treat preemption as out of scope until the platform fix lands.
+- Optional comparison against the upstream Gepard reference server needs Blackwell/Hopper + CUDA 13 + Postgres and is not part of CI.
+- For the request-field table, see the [Speech API Gepard section](https://github.com/vllm-project/vllm-omni/blob/main/docs/serving/speech_api.md#gepard-10).
+
+---
+
## higgs-audio v2
2-stage TTS at 24 kHz: a vLLM-native Llama-3.2-3B talker with a DualFFN audio expert (Stage 0) feeding a HiggsAudio codec decoder (Stage 1) that streams chunks back to the client.
@@ -348,6 +413,22 @@ vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice --omni --port 8091
./qwen3_tts/run_server.sh Base
```
+For a local deployment with multiple API frontend processes sharing one set
+of TTS stage engines, add `--api-server-count`:
+
+```bash
+vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \
+ --omni --api-server-count 2 --port 8091
+```
+
+This mode supports local EngineCore stages with one local process group per
+replica, including stages configured with multiple `num_replicas`. Headless,
+remote, intra-replica data-parallel, and Ray stage deployments are not
+supported. Runtime voice upload and deletion are disabled with multiple
+API frontends because their registries are process-local. Built-in voices,
+inline `ref_audio`, and voices restored from `custom_voice_dir` at startup are
+supported.
+
### Executor backend
Single-GPU serves now default to the uniproc executor (lower IPC overhead, the Base cloning use case from [#2603](https://github.com/vllm-project/vllm-omni/issues/2603) / [#2604](https://github.com/vllm-project/vllm-omni/pull/2604)). `vllm_omni/deploy/qwen3_tts.yaml` is the only Qwen3-TTS deploy config; pass `--deploy-config ` to override.
@@ -413,6 +494,10 @@ checkpoint. When a request names an uploaded voice, the server infers
`task_type="Base"`. Built-in presets such as `vivian` and `ryan` remain
CustomVoice speakers and require a CustomVoice checkpoint.
+The runtime upload and delete routes require a single API frontend; with
+`--api-server-count > 1`, use inline `ref_audio` or restore precomputed voices
+from `custom_voice_dir` at startup.
+
### Precomputed custom voices
For reused Base voice-cloning speakers, precompute the reference artifacts once
diff --git a/docs/user_guide/quantization/modelopt.md b/docs/user_guide/quantization/modelopt.md
index d8da4e1a9..07d05730e 100644
--- a/docs/user_guide/quantization/modelopt.md
+++ b/docs/user_guide/quantization/modelopt.md
@@ -204,6 +204,91 @@ omni = Omni(
| `--linear-backend cutlass` | str | auto | Select the validated CUTLASS linear backend for supported ModelOpt NVFP4 or mixed FP8/NVFP4 diffusion stages |
| `--moe-backend cutlass` | str | auto | Select the validated CUTLASS MoE backend for supported ModelOpt mixed MoE checkpoints |
+## Cosmos3 Mixed-Precision Schedule
+
+The experimental Cosmos3 schedule can use the checkpoint-native quantized GEMM for middle denoising
+steps and dense 16-bit activation execution for selected first and last steps.
+The schedule supports serialized ModelOpt FP8 and NVFP4 checkpoints:
+
+| Detected weight format | Native middle steps | A16 boundary steps |
+|------------------------|---------------------|--------------------|
+| FP8 | W8A8 | W8A16 |
+| NVFP4 | W4A4 | W4A16 |
+
+The native and A16 paths share one quantized weight representation. The A16
+path dequantizes the live backend weight and calls `F.linear`; it does not keep
+a second checkpoint-weight snapshot. Scheduled FP8 currently requires
+serialized tensorwise scales and a backend that retains canonical FP8 weights.
+Scheduled NVFP4 requires a supported CUTLASS-compatible native or FlashInfer
+layout. SmoothQuant, Marlin-repacked FP8, and per-channel/per-token FP8
+schedules fail closed.
+
+The reasoner uses dense A16 execution by default when its weights are FP8 or
+NVFP4. Set the nested `reasoner` field to `native` to retain the checkpoint-native
+W8A8/W4A4 path. A BF16 reasoner remains BF16 in either mode. The schedule
+currently requires tensor parallel size 1 and one active request per worker,
+does not support HSDP, and does not support block-scaled FP8. Its live-weight
+design is compatible with model-level and standard layer-wise offload, but
+those paths still require GPU end-to-end validation. Distributed layer-wise
+offload is rejected because its direct loader bypasses ModelOpt post-load
+transformations.
+
+A checkpoint can opt in by adding a versioned policy to its authoritative
+`transformer/config.json`. The same policy schema is used for FP8 and NVFP4:
+
+```json
+{
+ "quantization_config": {
+ "quant_method": "modelopt",
+ "quant_algo": "FP8",
+ "runtime": {
+ "diffusion_step_policy": {
+ "schema_version": 1,
+ "type": "first_last_n",
+ "index_space": "denoising_loop_iteration",
+ "scope": ["transformer"],
+ "default_mode": "native",
+ "first_steps": {"count": 3, "mode": "a16"},
+ "last_steps": {"count": 3, "mode": "a16"},
+ "overlap": "a16",
+ "reasoner": "a16"
+ }
+ }
+ }
+}
+```
+
+The example preserves the checkpoint's existing `quant_method` and
+`quant_algo`; only `runtime` is added.
+
+When present and valid, the checkpoint policy is the default. An explicit
+runtime object overrides it. To retain ordinary checkpoint-native execution
+for every step, disable only the schedule:
+
+```bash
+vllm serve /path/to/Cosmos3-Nano-modelopt \
+ --omni \
+ --additional-config '{"cosmos3_mixed_precision":{"enabled":false}}'
+```
+
+This does not disable checkpoint quantization; it prevents installation of the
+mixed-step runtime.
+
+```bash
+vllm serve /path/to/Cosmos3-Nano-modelopt \
+ --omni \
+ --additional-config \
+ '{"cosmos3_mixed_precision":{"first_steps":3,"last_steps":3,"reasoner":"a16"}}'
+```
+
+The presence of `cosmos3_mixed_precision` supplies an explicit runtime
+override; an empty object uses the defaults shown above.
+The schedule uses vLLM's selected native backend and fails during loading if
+its live weight layout cannot support A16 dequantization.
+The runtime selects FP8 or NVFP4 from the checkpoint's ModelOpt configuration.
+BF16 linears are untouched. Mixed FP8/NVFP4 checkpoints are not supported by
+this schedule.
+
## Validation and Notes
1. Compare the ModelOpt checkpoint against the BF16 baseline with the same
diff --git a/docs/user_guide/quantization/mxfp4.md b/docs/user_guide/quantization/mxfp4.md
index b18b3e7b3..00ff97c01 100644
--- a/docs/user_guide/quantization/mxfp4.md
+++ b/docs/user_guide/quantization/mxfp4.md
@@ -6,22 +6,25 @@ W4A4 MXFP4 (Microscaling FP4) quantizes both weights and activations to FP4
(`float4_e2m1fn_x2`, packed 2 values per byte) using the OCP MX format: groups
of 32 K-dimension elements share a single `float8_e8m0fnu` exponent scale.
+On Ascend, single-scale MXFP4 also supports Universal Optimal Scaling
+(UOS, `Qmax=7.25`), described in the single-scale scaling section below.
+
vLLM-Omni provides two quantization methods with different scale structures:
| Method | Scale structure | Mode | Use case |
-|--------|----------------|------|----------|
-| `mxfp4` | Single-scale (per-32 fine only) | Online only | Quick accuracy baseline; no checkpoint prep needed |
-| `mxfp4_dualscale` | Dual-scale (fine per-32 + coarse per-512 + per-channel `mul_scale`) | Online + Offline | Production; better accuracy; offline recommended |
+| -------- | ---------------- | ------ | ---------- |
+| `mxfp4` | Single-scale (per-32 fine only) | Online + pre-quantized single-scale weights | Online baseline, or an existing single-scale checkpoint in the layout below |
+| `mxfp4_dualscale` | Dual-scale (fine per-32 + coarse per-512 + per-channel `mul_scale`) | Online + Offline | W4A4 with dual-level scales; offline mode loads calibrated Smooth tensors |
-!!! tip "Recommended: `mxfp4_dualscale` offline"
- For production deployments, use the `mxfp4_dualscale` offline mode with a
+!!! tip "Existing W4A4-only DualScale deployments"
+ For W4A4-only deployments, the existing `mxfp4_dualscale` offline mode uses a
pre-quantized checkpoint produced by msModelSlim. Offline checkpoints load
- calibrated `mul_scale` tensors from disk, providing measurably better accuracy
- than any online method. The one-time preprocessing cost amortises across all
- subsequent inference runs.
+ calibrated `mul_scale` tensors from disk. Quality depends on the model,
+ calibration recipe and BF16 fallback policy; evaluate the checkpoint against
+ your quality target before deployment.
- Use `mxfp4` online only for quick experimentation where preprocessing time
- is not acceptable and accuracy loss is tolerable.
+ W4A8 layer/step fallback requires single-scale `mxfp4`, online or offline.
+ DualScale supports W4A4 and BF16 routing only.
!!! warning "Online single-scale ≠ Offline dual-scale"
`mxfp4_dualscale` offline mode uses `NPUMxfp4DualScaleLinearMethod`:
@@ -34,61 +37,244 @@ vLLM-Omni provides two quantization methods with different scale structures:
## Hardware Support
-| Device | Support |
-|--------|---------|
-| NVIDIA Blackwell GPU (SM 100+) | ⭕ |
-| NVIDIA Ada/Hopper GPU (SM 89+) | ⭕ |
-| NVIDIA Ampere GPU (SM 80+) | ⭕ |
-| AMD ROCm (gfx950 / MI355X) | ✅ |
-| Intel XPU | ⭕ |
-| Ascend NPU (Atlas 950 A5) | ✅ |
+The following matrix describes these vLLM-Omni implementations, rather than
+hardware support for the MXFP4 format in general.
-Legend: `✅` supported, `❌` unsupported, `⭕` not verified in this guide.
+| Device | Single-scale online OCP | Single-scale offline | UOS / W4A8 fallback | DualScale |
+| -------- | -------- | -------- | -------- | -------- |
+| Ascend NPU (Atlas 950 A5) | ✅ | ✅ | ✅ | ✅ |
+| AMD ROCm (gfx950 / MI355X) | ✅ | ❌ | ❌ | ❌ |
+| Other AMD architectures | ❌ | ❌ | ❌ | ❌ |
+| NVIDIA GPU | ❌ | ❌ | ❌ | ❌ |
+| Intel XPU | ❌ | ❌ | ❌ | ❌ |
+
+Legend: `✅` supported by the implementation, `❌` unsupported.
+ROCm uses the existing AITER online path and requires `mxfp4_scale_alg=0`.
## Model Type Support
### Diffusion Model (Wan2.2)
| Model | Online | Offline | Notes |
-|-------|--------|---------|-------|
-| Wan2.2-T2V-A14B | `mxfp4` / `mxfp4_dualscale` | `mxfp4_dualscale` | MoE cascade (`transformer` + `transformer_2`); both transformers quantized with the same config |
-| Wan2.2-I2V-A14B | `mxfp4` / `mxfp4_dualscale` | `mxfp4_dualscale` | MoE cascade; same scheme as T2V-A14B |
-| Wan2.2-TI2V-5B | ❌ | ❌ | Parameter count too small; W4A4 causes unacceptable accuracy loss |
+| ------- | -------- | --------- | ------- |
+| Wan2.2-T2V-A14B | `mxfp4` / `mxfp4_dualscale` | `mxfp4` / `mxfp4_dualscale` | Cascade experts resolve checkpoint metadata independently; explicit global runtime policy applies to both |
+| Wan2.2-I2V-A14B | `mxfp4` / `mxfp4_dualscale` | `mxfp4` / `mxfp4_dualscale` | Per-expert configuration uses the same interface as T2V; validate I2V checkpoints separately |
+| Wan2.2-TI2V-5B | Requires validation | Requires validation | This guide does not establish checkpoint compatibility or generation quality for TI2V-5B |
-The choice between `mxfp4` and `mxfp4_dualscale` in **online mode** is about
-quantization quality, not model compatibility — both work on cascade (A14B) and
-single-transformer models alike, the same as `mxfp8` online:
+Both methods can be integrated with single-transformer and cascade models.
+This alone does not establish support or quality for every model variant:
-- `mxfp4`: single-scale, lower overhead, simpler compute, online only
-- `mxfp4_dualscale`: dual-scale + optional BF16 fallback, better accuracy, online **and** offline
+- `mxfp4`: single-scale; online and pre-quantized weights, with optional W4A8 fallback on NPU
+- `mxfp4_dualscale`: dual-scale; online and offline, with BF16 fallback
-**Offline** checkpoints for A14B are always in `mxfp4_dualscale` format (produced
-by the merge script); there is no offline `mxfp4` single-scale format.
+The existing `merge_mxfp4_dualscale_checkpoint.py` produces **offline
+`mxfp4_dualscale`** checkpoints. Single-scale weights must satisfy the separate
+layout described below; changing a DualScale checkpoint's method name to
+`mxfp4` does not convert its weights. Prepare single-scale checkpoints with
+external tooling. Real exported checkpoint compatibility and model quality
+require validation of that checkpoint; the synthetic tests do not establish
+UOS export or video acceptance.
!!! note "Per-layer BF16 fallback in offline cascade models"
- The A14B offline checkpoint uses `quant_method: mxfp4_dualscale`. Most
+ An A14B offline DualScale checkpoint uses `quant_method: mxfp4_dualscale`. Most
linear layers are stored as W4A4 MXFP4 DualScale; precision-sensitive layers
retain their original BF16 weights and are listed in `ignored_layers` inside
each transformer's `config.json`. The two transformers may have different
`ignored_layers` sets — the pipeline reads each transformer's own `config.json`
- and rebuilds the config locally when they differ, so routing is always
- per-transformer-accurate.
-
-!!! warning "TI2V-5B not supported"
- Wan2.2-TI2V-5B is excluded from W4A4 quantization. Its smaller parameter
- count makes it significantly more sensitive to 4-bit quantization noise,
- resulting in unacceptable accuracy loss. Use [MXFP8](mxfp8.md) for TI2V-5B.
+ and resolves the storage format and BF16 layer policy independently.
## Configuration
+### W4A8 fallback at selected layers and denoising steps (Ascend NPU)
+
+For single-scale `mxfp4`, set `w4a8_fallback_steps` to a list of zero-based
+request denoising loop indices (not scheduler timestep values).
+Set `w4a8_fallback_layers` to exact runtime Linear paths, for example
+`["blocks.10.attn1.to_qkv", "blocks.12.ffn.net_2"]`. A selected layer uses W4A8
+at every step; at a selected step, all other quantized layers also use W4A8.
+The rules are combined with **OR**. W4A8 uses MXFP8 activations with MXFP4
+weights. Omitting both lists or passing `[]` keeps the existing W4A4 behavior. Existing
+`ignored_layers` take priority: **BF16 > (layer OR step) W4A8 > W4A4**.
+DualScale's existing leading BF16 block rule is unchanged; it does not enable
+W4A8.
+
+```python
+from vllm_omni import Omni
+
+omni = Omni(
+ model="",
+ quantization_config={
+ "method": "mxfp4",
+ "w4a8_fallback_layers": ["blocks.10.attn1.to_qkv"],
+ "w4a8_fallback_steps": [0, 1, 38, 39],
+ },
+)
+```
+
+Both policies also work with offline single-scale `mxfp4`. Any nonempty
+W4A8 list with `mxfp4_dualscale`, online or offline, raises an explicit error
+at configuration construction. Empty lists preserve its existing W4A4 path.
+Each expert's checkpoint still determines its serialized
+format and BF16 layer routing. A serialized checkpoint config is the complete
+storage contract: `ignored_layers`, or its `modules_to_not_convert` alias,
+selects the BF16 layers, and omitting both means no explicit BF16 layers. A
+method-only checkpoint value such as `"mxfp4"` or
+`{"quant_method": "mxfp4"}` carries no storage policy and leaves a caller's
+online `ignored_layers` unchanged. An explicitly supplied quantization config
+retains its W4A8 layer and step lists when the loader rebuilds either expert's
+config from disk, including empty lists that disable checkpoint policies.
+Within an explicit runtime config, an omitted W4A8 list defaults to `[]`;
+individual omitted fields do not inherit saved policies. Without an explicit
+runtime config, auto-detection uses the saved checkpoint policies.
+
+Layer names are relative to each expert (`blocks.10...`, without a
+`transformer.` or `transformer_2.` prefix). A global config applies the same
+list to both experts; per-component `transformer`/`transformer_2` configs can
+select different lists. Wan selects these expert names exactly: a `transformer`
+entry does not match `transformer_2`; an omitted expert uses `default` if set.
+A component mapped to `null` (or an unmatched component
+without a default) stays unquantized and requires a BF16 checkpoint; loading a
+quantized checkpoint for that disabled component raises an error. A top-level
+`quantization_config=None` retains checkpoint auto-detection.
+Names are exact, with no regex, wildcard, block expansion,
+or checkpoint-name remapping. Wan validates them against its runtime Linear
+modules and logs the selected paths/count and BF16 overrides per PP rank.
+Use `attn1.to_qkv` for fused self-attention; `attn1.to_q`, `to_k`, or `to_v`
+checkpoint names raise an error. Cross-attention `attn2.to_q/to_k/to_v` remains
+separate. Layer-only fallback does not require a denoise ForwardContext.
+
+The text-to-video example accepts the JSON directly:
+
+```bash
+ASCEND_RT_VISIBLE_DEVICES=0,1,2,3 \
+python examples/offline_inference/text_to_video/text_to_video.py \
+ --model /path/to/Wan2.2-T2V-A14B-Diffusers \
+ --quantization-config '{"method":"mxfp4","w4a8_fallback_steps":[0,2]}' \
+ --tensor-parallel-size 1 --ulysses-degree 2 --cfg-parallel-size 2 \
+ --enforce-eager --height 480 --width 832 --num-frames 81 \
+ --num-inference-steps 40 --seed 42 --output wan22-w4a8.mp4
+```
+
+Use either `--quantization` or `--quantization-config` in this example.
+Serving accepts the same JSON through `--diffusion-quantization-config`.
+
+The list uses one request's loop index across both high- and low-noise experts,
+and both CFG branches see the same index. A new request starts at step zero.
+Indices outside the request's step range are never selected. A pipeline must
+publish `ForwardContext.denoise_step_idx`; requesting step fallback without
+that context raises an error instead of silently ignoring the list.
+
+Single-scale W4A4 and W4A8 reuse the same prepared `weight` and `weight_scale`.
+Online loading quantizes the original BF16/FP16 weight once; offline loading
+packs the numeric FP4 values and reshapes the saved E8M0 scale without
+requantizing the weight. Only activation precision changes during forward.
+There is no DualScale decoding, second W4 representation or `w4a8_weight` /
+`w4a8_weight_scale` cache. No weight quantization runs inside the denoising loop.
+
+The mixed FP8/FP4 GEMM uses BF16 output and a matching BF16 row bias for
+compatibility with the target A5 runtime. An FP16 caller receives that result
+converted back to FP16; its output shape and dtype are preserved.
+
+The single-scale fallback validation target is eager Wan2.2 T2V A14B on A5.
+Quality scores, optimal fallback lists, real UOS exports and additional
+compile/offload combinations require separate evaluation. Existing W4A4-only
+DualScale still needs complete 512-channel input groups; use TP=1 with
+sequence/CFG parallelism for its initial multi-card run.
+
+### `mxfp4` — Pre-Quantized Single-Scale Weights (Ascend NPU)
+
+Set the following fields in each transformer's `config.json` when a
+single-scale checkpoint uses calibrated Smooth scaling:
+
+```json
+{
+ "quantization_config": {
+ "quant_method": "mxfp4",
+ "is_checkpoint_mxfp4_serialized": true,
+ "require_smooth_scale": true,
+ "ignored_layers": []
+ }
+}
+```
+
+Each quantized Linear must provide tensors under its model parameter name:
+
+| Tensor | Checkpoint layout | Meaning |
+| -------- | ------------------- | --------- |
+| `weight` | `(N, K)` numeric FP4 values | Packed to FP4 after loading |
+| `weight_scale` | `(N, ceil(K / 32))`, `uint8` | Raw E8M0 exponent bytes, required |
+| `mul_scale` | `(K,)`, FP32 | Smooth pre-scale, required when declared above |
+
+Both W4A4 and W4A8 quantize `x * mul_scale`. The Smooth tensor is converted
+to the layer's activation dtype after loading. Row-parallel layers load the
+corresponding input-channel shard. Pre-fused QKV weights must use one shared
+Smooth tensor for the fused projection.
+
+For backward compatibility, `require_smooth_scale` defaults to `false`:
+an absent tensor uses identity scaling, while a present tensor is still loaded.
+Without a declaration, an absent Smooth tensor cannot be distinguished from a
+legacy unsmoothed checkpoint. Calibrated checkpoints should declare
+`require_smooth_scale: true` so missing or misnamed Smooth tensors fail loading;
+incorrect tensor shapes, non-floating tensors, NaN/Inf and nonpositive values
+also fail. Values that overflow or underflow to zero in the activation dtype
+are rejected during weight processing. The flag requires serialized single-scale
+weights and is rejected for online quantization. It does not make DualScale
+scales optional or change missing-parameter policies for other backends.
+Either the checkpoint or an explicit caller can require Smooth: a `true`
+declaration is retained when per-expert metadata rebuilds the configuration,
+including when the other source omits the flag or sets it to `false`.
+
+Prepare the canonical numeric FP4 checkpoint before inference using external
+tooling. Runtime loading does not parse producer-specific export formats.
+Retain the producer recipe to identify the weight-generation algorithm and
+Smooth calibration; tensor storage alone does not establish UOS provenance.
+
+### `mxfp4` — Single-Scale Block Scaling (OCP / UOS)
+
+`mxfp4_scale_alg` selects the MXFP4 block-scaling method on NPU.
+UOS is a scaling rule, not a separate activation dtype or an activation-only
+algorithm. Both choices use FP4 E2M1 values with shared E8M0 scales.
+
+| Mode | Weight preparation | A4 activation quantization | W4A8 fallback |
+| --- | --- | --- | --- |
+| Online | Selected scaling method, once at loading | Selected scaling method | Same W4; OCP MXFP8 A8 |
+| Offline | Load existing W4 and scales unchanged | Selected scaling method | Same W4; OCP MXFP8 A8 |
+
+The available settings are:
+
+| Setting | MXFP4 scaling method | NPU parameters |
+| --- | --- | --- |
+| `0` (default) | OCP MX | `scale_alg=0` |
+| `2` | UOS (`Qmax=7.25`) | `scale_alg=2, dst_type_max=7.25` |
+
+Universal Optimal Scaling (UOS, `Qmax=7.25`) is derived in
+[MXAttention: Data-Free Optimal Scaling and Pre-Normalization Quantization for MXFP4 Attention](https://arxiv.org/abs/2607.24377),
+Section 4.1. The value `7.25` is the boundary used to select the shared
+power-of-two scale; the maximum finite E2M1 value remains `6`.
+This option applies UOS to Linear quantization. It does not implement the
+paper's full MXAttention pipeline or Pre-Normalization Quantization (PNQ).
+
+Both use FP4 E2M1 with E8M0 scales, `axis=-1`, `block_size=32`,
+`round_mode="rint"`. W4A8 always quantizes the activation to
+`float8_e4m3fn` with `scale_alg=0` and the same grouping/rounding, without
+UOS parameters. A4 and A8 share the prepared W4 and scale. UOS is currently
+NPU-only; ROCm retains its existing AITER online algorithm.
+
+An explicit UOS online configuration is
+`{"method": "mxfp4", "mxfp4_scale_alg": 2}`. For offline checkpoints the
+setting affects **only A4 activations**. Existing weights are never requantized:
+a UOS checkpoint can run UOS W4 + UOS A4 or UOS W4 + OCP A4, and both fall back to
+UOS W4 + OCP A8. Record the weight-generation algorithm separately from the
+runtime activation setting. Smooth remains active with either setting. The
+UOS configuration explicitly selects `2`; attention computation is unchanged.
+
### `mxfp4` — Single-Scale Online Mode
-Online mode requires no pre-processing. vLLM-Omni quantizes BF16 weights to
-MXFP4 at load time using `npu_dynamic_mx_quant`. A single block scale
-(`float8_e8m0fnu`, one per 32 K elements) is computed on the fly; no
-calibration `mul_scale` is available. Applies equally to single-transformer
-and cascade (A14B) models — both transformers in a cascade receive the same
-quantization config automatically.
+Online mode quantizes BF16/FP16 weights once at loading and computes activation
+scales during forward. Each block of 32 K elements shares one E8M0 scale;
+no calibrated `mul_scale` is available. An explicit global quantization config
+applies to both cascade experts. Per-component configs can select different
+policies; checkpoint auto-detection resolves each expert independently.
```python
from vllm_omni import Omni
@@ -103,7 +289,7 @@ outputs = omni.generate(
```bash
# Single-transformer or cascade model — same command
-python text_to_video.py --model --quantization mxfp4
+python examples/offline_inference/text_to_video/text_to_video.py --model --quantization mxfp4
# Online serving
vllm serve --omni --quantization mxfp4
@@ -113,19 +299,19 @@ vllm serve --omni --quantization mxfp4
Online DualScale mode computes both fine and coarse scales on the fly from BF16
weights using `npu_dynamic_dual_level_mx_quant`. Applies equally to
-single-transformer and cascade (A14B) models. Compared to `mxfp4` online,
-DualScale provides better quantization accuracy at higher compute cost.
+single-transformer and cascade (A14B) models with compatible Linear layers.
+Compare quality and performance using the intended model and workload.
The default configuration keeps the leading 5 transformer blocks in BF16
-(`num_bf16_fallback_layers=5`). Accuracy evaluation on Wan2.2-A14B shows this
-is sufficient to meet quality requirements and is the recommended setting.
+(`num_bf16_fallback_layers=5`). This is an implementation default; select and
+validate the BF16 policy against your own quality target.
```python
omni = Omni(model="", quantization="mxfp4_dualscale")
```
```bash
-python text_to_video.py --model --quantization mxfp4_dualscale
+python examples/offline_inference/text_to_video/text_to_video.py --model --quantization mxfp4_dualscale
```
If accuracy debugging identifies additional precision-sensitive layers, they
@@ -136,21 +322,22 @@ omni = Omni(
model="",
quantization_config={
"method": "mxfp4_dualscale",
- "ignored_layers": ["blocks.10.attn1.to_q"], # explicit per-layer override
+ "ignored_layers": ["blocks.10.attn1.to_qkv"], # explicit per-layer override
},
)
```
BF16 fallback routing in online mode applies two rules in priority order:
-1. **`ignored_layers`** (explicit per-layer override): any layer whose prefix
- matches is kept in BF16 regardless of block index.
+1. **`ignored_layers`** (explicit per-layer override): a listed runtime Linear
+ module is kept in BF16 regardless of block index. Use exact module paths,
+ not arbitrary block prefixes or names ending in `.weight`.
2. **`num_bf16_fallback_layers`** (coarse leading-block rule): the first N
transformer blocks (`blocks.0` … `blocks.N-1`) fall back to BF16. Defaults
- to `5` (recommended). Layers outside `blocks.N.*`
- (e.g. `condition_embedder`) are always quantized.
+ to `5`. Layers outside `blocks.N.*` are quantized unless explicitly listed
+ in `ignored_layers`.
-### `mxfp4_dualscale` — DualScale Offline Mode (Recommended)
+### `mxfp4_dualscale` — DualScale Offline Mode
Offline mode loads a pre-quantized DualScale checkpoint from msModelSlim. A
preprocessing step converts the raw quantized output to the diffusers format
@@ -169,8 +356,8 @@ inside `config.json`. At runtime, each layer's prefix is matched against
Each quantized linear layer stores four tensors:
| Tensor | Shape | dtype | Description |
-|--------|-------|-------|-------------|
-| `weight` | `(N, K)` | float8_e4m3fn | FP4 packed (2 values per byte) |
+| -------- | ------- | ------- | ------------- |
+| `weight` | `(N, K)` | float8_e4m3fn | Numeric FP4 values; packed into two values per byte after loading |
| `weight_scale` | `(N, K//32)` | uint8 | Fine block scale (`float8_e8m0fnu` bit pattern) |
| `weight_dual_scale` | `(N, K//512, 1)` | float32 | Coarse block scale |
| `mul_scale` | `(K,)` | float32 | Per-input-channel smooth pre-scale (from calibration) |
@@ -223,7 +410,7 @@ python vllm_omni/quantization/tools/merge_mxfp4_dualscale_checkpoint.py \
```
| Argument | Description |
-|----------|-------------|
+| ---------- | ------------- |
| `--model-type` | Model variant: `Wan2.2-T2V-A14B` or `Wan2.2-I2V-A14B` |
| `--original-model` | Root directory of the original BF16 diffusers model |
| `--quant-path` | Root directory of the msModelSlim quantized output |
@@ -251,14 +438,14 @@ The `quantization_config` injected into `config.json` for each transformer:
```
`ignored_layers` lists every linear layer that retains its original BF16 weight,
-using vllm-omni model parameter names (QKV-fused, FFN underscored, `to_out`
+using vllm-omni runtime module paths (QKV-fused, FFN underscored, `to_out`
unindexed). The exact entries are determined by the quantization tool (msModelSlim)
and may differ between `transformer` and `transformer_2` in a cascade model.
#### Step 3 — Serve
```bash
-python text_to_video.py --model /path/to/Wan2.2-T2V-A14B-MXFP4-DualScale
+python examples/offline_inference/text_to_video/text_to_video.py --model /path/to/Wan2.2-T2V-A14B-MXFP4-DualScale
# Online serving
vllm serve /path/to/Wan2.2-T2V-A14B-MXFP4-DualScale --omni
@@ -275,25 +462,32 @@ omni = Omni(model="/path/to/Wan2.2-T2V-A14B-MXFP4-DualScale")
## Parameters
-### `mxfp4` (single-scale, online only)
+### `mxfp4` (single-scale, online + offline)
| Parameter | Type | Default | Description |
-|-----------|------|---------|-------------|
+| ----------- | ------ | --------- | ------------- |
| `method` | str | — | `"mxfp4"` |
-| `ignored_layers` | list[str] | `[]` | Layer prefixes to keep in BF16 |
+| `mxfp4_scale_alg` | int | `0` | MXFP4 block scaling: `0` OCP MX, `2` UOS (`Qmax=7.25`, NPU only). Online: W4 and A4; offline: A4 only. A8 remains OCP MXFP8 |
+| `is_checkpoint_mxfp4_serialized` | bool | `false` | Load pre-quantized single-scale weights and scales; declared in checkpoint `config.json` |
+| `require_smooth_scale` | bool | `false` | Require calibrated Smooth tensors for offline single-scale weights |
+| `ignored_layers` | list[str] | `[]` | Exact runtime Linear module paths to keep in BF16 |
+| `w4a8_fallback_layers` | list[str] | `[]` | Exact runtime Linear paths that always use A8; NPU only |
+| `w4a8_fallback_steps` | list[int] | `[]` | Zero-based denoise steps that use A8; NPU only |
### `mxfp4_dualscale` (dual-scale, online + offline)
| Parameter | Type | Default | Description |
-|-----------|------|---------|-------------|
+| ----------- | ------ | --------- | ------------- |
| `method` | str | — | `"mxfp4_dualscale"` |
| `is_checkpoint_serialized` | bool | `False` | `True` for offline DualScale checkpoints; auto-set from `config.json` when using the preprocessing script |
-| `ignored_layers` | list[str] | `[]` | Layer prefixes to keep in BF16. **Works in both modes**: offline — populated by the merge script for interleaved sensitive layers; online — user-supplied for explicit per-layer precision override |
-| `num_bf16_fallback_layers` | int | `5` | **Online mode only**: leading N transformer blocks (`blocks.0` … `blocks.N-1`) kept in BF16. Applied after `ignored_layers`; ignored in offline mode. Default of `5` is the evaluated recommended value for Wan2.2-A14B |
+| `ignored_layers` | list[str] | `[]` | Exact runtime Linear module paths to keep in BF16. **Works in both modes**: offline — populated by the merge script for interleaved sensitive layers; online — user-supplied for explicit per-layer precision override |
+| `num_bf16_fallback_layers` | int | `5` | **Online mode only**: leading N transformer blocks (`blocks.0` … `blocks.N-1`) kept in BF16. Applied after `ignored_layers`; ignored in offline mode. Validate the default of `5` against the intended model and quality target |
+| `w4a8_fallback_layers` | list[str] | `[]` | Must remain empty; DualScale does not support W4A8 fallback |
+| `w4a8_fallback_steps` | list[int] | `[]` | Must remain empty; DualScale does not support W4A8 fallback |
#### BF16 fallback priority (online mode)
-```
+```text
for each linear layer:
if prefix in ignored_layers → BF16 (explicit override, highest priority)
elif block_idx < num_bf16_fallback_layers → BF16 (coarse leading-block rule)
@@ -307,8 +501,9 @@ unless they appear in `ignored_layers`.
1. **Online single-scale (`mxfp4`)** quantizes BF16 weights at load time using
`npu_dynamic_mx_quant` (single-scale). No calibration `mul_scale` is
- available — all output partitions receive an identity pre-scale. No offline
- checkpoint format exists for this method.
+ available — all output partitions receive an identity pre-scale. Offline
+ single-scale checkpoints use the canonical numeric FP4 layout
+ described above.
2. **Online dual-scale (`mxfp4_dualscale`, `is_checkpoint_serialized=False`)**
quantizes BF16 weights using `npu_dynamic_dual_level_mx_quant` (fine + coarse
@@ -316,7 +511,7 @@ unless they appear in `ignored_layers`.
explicit `ignored_layers` stay in BF16 for accuracy.
3. **Offline dual-scale (`mxfp4_dualscale`, `is_checkpoint_serialized=True`)** —
- **recommended for production** — loads four tensors per quantized layer: FP4
+ loads four tensors per quantized layer: FP4
weight, fine scale (`uint8` reinterpreted as `float8_e8m0fnu`), coarse scale
(`float32`), and per-input-channel `mul_scale` (`float32`). BF16 fallback
layers have no quantization tensors and are routed via `ignored_layers`.
@@ -325,21 +520,21 @@ unless they appear in `ignored_layers`.
layout as `float8_e8m0fnu`) and reinterpreted at load time without a lossy
float32 round-trip.
-5. **Cascade model config propagation**: in a cascade model (transformer +
- transformer_2), vLLM-Omni reads each transformer's own `config.json` and
- rebuilds the quant config locally when `ignored_layers` differs between
- transformers, ensuring per-layer routing is accurate for each. The first
- transformer's config is propagated to `od_config` so the second transformer
- can reuse it as a starting point.
+5. **Cascade model configuration**: checkpoint auto-detection resolves each
+ expert's own `config.json` independently; the first expert's detected config
+ is not reused as the second expert's policy. An explicit global runtime
+ config applies to both experts, while per-component configs select policies
+ by the exact `transformer` / `transformer_2` names. Serialized checkpoint
+ metadata still determines each expert's storage format and BF16 layers.
6. **Self-attention QKV fusion**: Q, K, V projection weights are fused into a
single `QKVParallelLinear` layer at runtime. `ignored_layers` entries use the
fused name (`attn1.to_qkv`), written automatically by the merge script.
-7. W4A4 carries higher quantization noise than W8A8 (16 vs 256 levels). The
- DualScale offline method mitigates this with calibrated `mul_scale` smooth
- quantization. Use `ignored_layers` and `num_bf16_fallback_layers` to trade
- off compression vs. accuracy for precision-sensitive layers.
+7. FP4 has fewer representable values than FP8, so evaluate quantization
+ error and generation quality for the chosen recipe. Offline Smooth scaling
+ and BF16 fallback can help with sensitive layers.
+ `num_bf16_fallback_layers` applies only to online DualScale.
## Adapting MXFP4 for a New Model
@@ -350,27 +545,26 @@ a merge script for offline checkpoints.
### Step 1 — Discover runtime layer names
-`ignored_layers` entries must match the **runtime parameter names** used inside
-vllm-omni, which may differ from the names stored in the diffusers checkpoint.
-The canonical source of truth is the model's own `named_parameters()`.
+`ignored_layers` entries use exact **runtime Linear module paths**, without
+`.weight`. Inspect the actual transformer instance in model initialization or
+a developer harness; `Omni` does not expose an `omni.pipeline.transformer` API.
+For a transformer instance named `transformer`:
```python
-from vllm_omni import Omni
+from vllm.model_executor.layers.linear import LinearBase
-# Load the model without quantization to inspect parameter names.
-omni = Omni(model="/path/to/your-model") # no --quantization flag
-for name, _ in omni.pipeline.transformer.named_parameters():
- if "weight" in name and "scale" not in name:
+for name, module in transformer.named_modules():
+ if isinstance(module, LinearBase):
print(name)
```
-Compare the printed names against the diffusers checkpoint keys
+Compare these module paths against the corresponding diffusers checkpoint keys
(`safetensors.safe_open` or `torch.load`) to identify any renames your model
applies. Common patterns that differ in Wan2.2 (and may appear in other
models):
| Diffusers checkpoint name | vllm-omni runtime name | Reason |
-|---------------------------|------------------------|--------|
+| --------------------------- | ------------------------ | -------- |
| `attn1.to_q`, `attn1.to_k`, `attn1.to_v` | `attn1.to_qkv` | Self-attention Q/K/V fused into `QKVParallelLinear` |
| `ffn.net.0.proj` | `ffn.net_0.proj` | Dots in sub-module names replaced with underscores |
| `ffn.net.2` | `ffn.net_2` | Same underscore rule |
@@ -382,8 +576,9 @@ fused runtime parameters.
!!! warning "Partial QKV fallback is not allowed"
If your model fuses Q, K, V into a single layer, `ignored_layers` must
- include **all three or none**. A partial fallback (e.g. `to_q` in BF16 but
- `to_k`, `to_v` quantized) cannot be expressed at runtime because they share
+ name the fused runtime module (for Wan, `attn1.to_qkv`). The source
+ checkpoint must keep **all three or none** in BF16. A partial fallback
+ (e.g. `to_q` in BF16 but `to_k`, `to_v` quantized) cannot be expressed at runtime because they share
one `QKVParallelLinear`. The merge script enforces this and raises an error
if only some of the trio appear as non-quantized.
@@ -392,12 +587,13 @@ fused runtime parameters.
#### Online mode
Pass `ignored_layers` directly in the quantization config using the **runtime
-names** discovered in Step 1. No code changes to the model are required.
+names** discovered in Step 1. This assumes the model already passes its
+quantization config and module prefixes to compatible vLLM Linear layers.
```python
omni = Omni(
model="/path/to/your-model",
- quantization={
+ quantization_config={
"method": "mxfp4_dualscale",
"ignored_layers": [
"blocks.0.attn1.to_qkv", # runtime name, not diffusers name
@@ -409,9 +605,14 @@ omni = Omni(
```
```bash
-# CLI does not support list-typed ignored_layers directly.
-# Use the Python API or set ignored_layers in config.json (offline).
-python your_script.py --model /path/to/your-model --quantization mxfp4_dualscale
+# The text-to-video example accepts list-valued fields through JSON.
+python examples/offline_inference/text_to_video/text_to_video.py \
+ --model /path/to/your-model \
+ --quantization-config '{"method":"mxfp4_dualscale","ignored_layers":["blocks.0.attn1.to_qkv"]}'
+
+# Serving accepts the same configuration.
+vllm serve /path/to/your-model --omni \
+ --diffusion-quantization-config '{"method":"mxfp4_dualscale","ignored_layers":["blocks.0.attn1.to_qkv"]}'
```
The `num_bf16_fallback_layers` coarse rule is an alternative to listing layers
@@ -436,9 +637,13 @@ the merge script is correct. The injected block:
}
```
-To add a layer manually (e.g. to pin an additional layer to BF16 without
-re-running the merge script), edit `config.json` inside the transformer
-subfolder. Use runtime names, not diffusers checkpoint names.
+Adding an offline BF16 fallback layer requires both the original BF16 weight
+from the base model and a matching `ignored_layers` entry. Regenerate the
+checkpoint with the revised storage policy, restoring the original weight
+(and bias, if present) and removing that layer's quantization tensors.
+Editing `config.json` alone does not reconstruct BF16 weights from stored FP4
+values. For fused QKV, restore all three projections together. Use runtime
+module paths in `ignored_layers`.
### Step 3 — Write a merge script for offline mode
@@ -474,6 +679,7 @@ renaming rules and collect the results.
!!! tip "Validate before serving"
After producing the offline checkpoint, load it without a `--quantization`
flag and verify that vLLM-Omni auto-detects the correct method. Check that
- the layer count reported in the startup log matches expectations: quantized
- layer count + `ignored_layers` count should equal total linear layer count.
- Any mismatch indicates a name-mapping bug in the merge script.
+ each intended runtime Linear module resolves to the expected quantized or
+ BF16 method and that every BF16 entry has original BF16 weights. Account
+ for fused modules and, in online DualScale, the leading-block fallback rule;
+ the raw length of `ignored_layers` alone is not a reliable layer count.
diff --git a/docs/user_guide/quantization/overview.md b/docs/user_guide/quantization/overview.md
index 8319077a2..7e7452bec 100644
--- a/docs/user_guide/quantization/overview.md
+++ b/docs/user_guide/quantization/overview.md
@@ -13,7 +13,7 @@ For the internal architecture and backend extension points, see the
| Mode | Guide | Description | Methods |
| ------ | ------- | ------------- | --------- |
| Online quantization | [Online Quantization](online.md) | vLLM-Omni computes quantized weights and scales while loading the model. | FP8 W8A8, Int8 W8A8, BitsAndBytes W4, MXFP8 W8A8, MXFP4 W4A4 |
-| Runtime attention quantization | [Quantized KV Cache](quantized_kvcache.md) | vLLM-Omni dynamically quantizes eligible diffusion Flash Attention tensors during inference. | FP8 FA |
+| Runtime attention quantization | [Quantized KV Cache](quantized_kvcache.md) | vLLM-Omni dynamically quantizes eligible diffusion attention tensors during inference. | FP8, MXFP8, MXFP4 |
| Pre-quantized checkpoints | Method-specific guides | The checkpoint or an offline quantizer provides quantized weights and scales before serving. | ModelOpt, AutoRound, TorchAO, msModelSlim, serialized Int8, offline MXFP8, offline MXFP4 DualScale |
## Hardware Support
diff --git a/docs/user_guide/quantization/quantized_kvcache.md b/docs/user_guide/quantization/quantized_kvcache.md
index 99a90e08a..30ba53593 100644
--- a/docs/user_guide/quantization/quantized_kvcache.md
+++ b/docs/user_guide/quantization/quantized_kvcache.md
@@ -2,41 +2,36 @@
## Overview
-In DiT-based image and video generation, Flash Attention can take a large share
-of denoising time, especially for high-resolution or long-frame workloads.
-vLLM-Omni supports online FP8 quantization for eligible diffusion Flash
-Attention (FA) to reduce FA latency while keeping model weights in their
-original dtype.
+In DiT-based image and video generation, attention can take a large share of
+denoising time, especially for high-resolution or long-frame workloads.
+vLLM-Omni supports online Q/K/V quantization for eligible diffusion attention
+paths while keeping model weights in their original dtype.
-This feature is configured through `diffusion_kv_cache_dtype` on
-`OmniDiffusionConfig` (CLI: `--diffusion-kv-cache-dtype`). It is intentionally
-**not** the same as vLLM's `--kv-cache-dtype`, which controls autoregressive
-language-model KV cache storage and defaults to `"auto"`. Diffusion FA
-quantization uses the dedicated diffusion flags so omni serve does not inherit
-that default.
+Runtime attention precision is configured through `diffusion_kv_cache_dtype`
+on `OmniDiffusionConfig` (CLI: `--diffusion-kv-cache-dtype`). This setting is
+separate from vLLM's `--kv-cache-dtype`, which controls autoregressive
+language-model KV cache storage.
In vLLM-Omni diffusion pipelines, this is a runtime FA path: Q/K/V tensors are
dynamically quantized before the attention operator. It does not quantize model
weights and is separate from [FP8 W8A8](fp8.md), [Int8 W8A8](int8.md), or
pre-quantized checkpoint formats.
-If `diffusion_kv_cache_dtype` is not set, behavior is unchanged and attention
-runs in the native dtype.
+If `diffusion_kv_cache_dtype` is not set, attention runs in the native dtype.
## Hardware Support
-| Device | FP8 FA |
-|--------|--------|
-| Ascend NPU | ✅ |
-| NVIDIA GPU | ❌ |
-| AMD ROCm | ❌ |
-| Intel XPU | ❌ |
+| Device | Dense FP8 | Dense MXFP8/MXFP4 | BSA FP8/MXFP4 |
+|--------|------------|-------------------|----------------|
+| Ascend NPU | ✅ | ✅ | ✅ |
+| NVIDIA GPU | ❌ | ❌ | ❌ |
+| AMD ROCm | ❌ | ❌ | ❌ |
+| Intel XPU | ❌ | ❌ | ❌ |
Legend: `✅` supported, `❌` unsupported.
-FP8 FA is currently implemented only for the NPU Flash Attention backend. Other
-backends do not support `diffusion_kv_cache_dtype="fp8"` for diffusion attention
-and fall back to native dtype execution.
+These runtime methods are implemented only by the NPU Flash Attention and BSA
+backends. Other backends reject an incompatible explicit configuration.
## Model Type Support
@@ -44,7 +39,7 @@ and fall back to native dtype execution.
| Model | Scope | Status | Notes |
|-------|-------|--------|-------|
-| Wan2.2 | Eligible DiT full-attention FA on Ascend NPU | Tested | Compare quality and latency against a BF16 baseline before production use |
+| Wan2.2 | Self-attention on Ascend NPU | NPU interface and T2V smoke tested | Compare quality and latency against a BF16 baseline before production use |
| Other diffusion models | Eligible DiT full-attention FA on Ascend NPU | Not tested | You can try `diffusion_kv_cache_dtype="fp8"`; tune `diffusion_kv_cache_skip_steps` and `diffusion_kv_cache_skip_layers` when higher precision is needed |
### Multi-Stage Omni/TTS Model (Qwen3-Omni, Qwen3-TTS)
@@ -105,12 +100,13 @@ The legacy keyword aliases `kv_cache_dtype`, `kv_cache_skip_steps`, and
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
-| `diffusion_kv_cache_dtype` | str \| None | `None` | Set to `"fp8"` to enable dynamic FP8 FA on supported attention backends |
+| `diffusion_kv_cache_dtype` | str \| None | `None` | Runtime attention method. Dense FA supports `fp8`, `mxfp8`, and `mxfp4`; BSA supports `fp8` and `mxfp4` |
| `diffusion_kv_cache_skip_steps` | str \| None | `None` | Denoising step selector to keep in native dtype, for example `"0,1,4-6"` |
| `diffusion_kv_cache_skip_layers` | str \| None | `None` | Transformer layer selector to keep in native dtype, for example `"0-2,10"` |
Selectors use comma-separated integers and inclusive ranges. Listed steps or
-layers skip FP8 FA; all other eligible full-attention forwards use the FP8 path.
+layers run floating-point attention through the selected Dense or BSA backend;
+all other eligible forwards use `diffusion_kv_cache_dtype`.
## Validation and Notes
@@ -123,3 +119,60 @@ layers skip FP8 FA; all other eligible full-attention forwards use the FP8 path.
4. Report both latency and quality results when enabling this option for a new
model. For image or video models, include visual comparison and quantitative
metrics when available, such as PSNR or SSIM.
+
+## Wan2.2 T2V quantized attention on Ascend
+
+Wan2.2 T2V A14B supports quantized self-attention through MindIE-SD. Model
+weights are unchanged, and model-level opt-out keeps cross-attention in its
+native dtype.
+
+| Attention path | self-attention `backend` | Supported `diffusion_kv_cache_dtype` |
+| --- | --- | --- |
+| Dense FA | `FLASH_ATTN` | `fp8`, `mxfp8`, `mxfp4` |
+| BSA | `RAINFUSION_ATTN` | `fp8`, `mxfp4` |
+
+Configure the method and fallback selectors once at the diffusion stage. The
+per-role attention config only selects the self-attention backend:
+
+```yaml
+stages:
+ - stage_id: 0
+ diffusion_kv_cache_dtype: "mxfp8"
+ diffusion_kv_cache_skip_layers: "0,39"
+ diffusion_kv_cache_skip_steps: "0,1,38,39"
+ diffusion_attention_config:
+ per_role:
+ self:
+ backend: FLASH_ATTN # Use RAINFUSION_ATTN with fp8 or mxfp4 for BSA.
+```
+
+For 40 denoising steps, `diffusion_kv_cache_skip_steps: "0,1,38,39"` and
+`diffusion_kv_cache_skip_layers: "0,39"` are recommended starting points. Steps
+are zero-based across the complete request and do not reset when Wan switches
+transformers; layer indices are zero-based and local to each transformer.
+Selectors accept comma-separated indices and inclusive ranges such as
+`"0,3-5"`. A selected forward uses floating-point attention while preserving
+the Dense or BSA path.
+
+Unsupported precision/input combinations raise an error and must be corrected
+in the configuration; operator errors are not retried with another precision.
+
+For BSA, change the backend to `RAINFUSION_ATTN` and set
+`diffusion_kv_cache_dtype` to `fp8` or `mxfp4`.
+See [RainFusion attention](../diffusion/attention_backends/rainfusion.md) for
+sparse-path configuration and behavior.
+
+### Requirement and interface test
+
+Until these APIs are available in a MindIE-SD release, use revision
+[`8637b5333b0225381b215390fd09a8732e671cc4`](https://gitcode.com/zqxu/MindIE-SD/commit/8637b5333b0225381b215390fd09a8732e671cc4).
+Build the Python package, PyTorch plugin, and custom operators from that same
+checkout. With CANN sourced, run the NPU interface tests from the Omni checkout:
+
+```bash
+python -m pytest tests/platforms/npu/quant/test_kv_quant_npu.py \
+ -k real_npu -vv -s -o addopts=''
+```
+
+A skipped NPU test is not a pass. Validate generated video quality against a
+floating-point run with the same prompt, seed, resolution, and step count.
diff --git a/examples/model_display_names.yml b/examples/model_display_names.yml
index 7a04ff8fa..be4ba2f80 100644
--- a/examples/model_display_names.yml
+++ b/examples/model_display_names.yml
@@ -2,7 +2,6 @@
# The docs generator combines these names with the serving mode for each page H1.
aura_omni: AURA Omni
covo_audio: Covo-Audio-Chat
-dynin_omni: Dynin-Omni
lance: Lance
mimo_audio: MiMo-Audio
ming_flash_omni: Ming-flash-omni 2.0
diff --git a/examples/offline_inference/diffusion/ar_diffusion_streaming_decode.py b/examples/offline_inference/diffusion/ar_diffusion_streaming_decode.py
new file mode 100644
index 000000000..6c87d4e63
--- /dev/null
+++ b/examples/offline_inference/diffusion/ar_diffusion_streaming_decode.py
@@ -0,0 +1,339 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+"""Stream LingBot-World 2.0 pixels from one stepwise request.
+
+One ``AsyncOmni.generate()`` drives the whole rollout: each AR block leaves
+``post_decode()`` as one video chunk, decoded through that session's own
+temporal VAE cache rather than as an isolated clip. That is what this script
+shows end to end -- chunk *N + 1* continues chunk *N*, so the streamed timeline
+carries the same frame count as an offline whole-clip decode of the same
+latents instead of losing each block's opening frame to a restarted decoder.
+Only the session's first latent frame expands to a single raw frame; on the
+default checkpoint, whose block is three latent frames at a temporal factor of
+four, that is 9 frames for the opening chunk and 12 for every later one.
+
+The decode lives inside the pipeline, keyed by ``request_id``, so nothing here
+owns a VAE: this file is a client of the served path, and the same chunks reach
+a WebSocket client of ``/v1/realtime/video`` unchanged.
+
+Examples::
+
+ python examples/offline_inference/diffusion/ar_diffusion_streaming_decode.py \
+ --image scene.png --prompt "a lit hallway" \
+ --action-script actions.json --output-dir /tmp/lingbot-stream
+
+``--action-script`` is a JSON list with one camera action list per AR block,
+for example ``[[["w"], ["w"], ["w"]], [["a"], [], []]]`` for a checkpoint that
+generates three latent frames per block. The frames per block and temporal
+compression are read off the checkpoint rather than assumed, so the script
+can be repointed with ``--model``.
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import json
+import math
+import os
+import time
+from collections.abc import Sequence
+from pathlib import Path
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ # Only for type hints -- the real imports stay deferred inside run() so
+ # --help and the offline validation helpers don't require a CUDA-enabled
+ # vLLM install.
+ import numpy as np
+
+_MODEL = "robbyant/lingbot-world-v2-14b-causal-fast-diffusers"
+
+
+def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Stream decoded LingBot-World 2.0 video chunks from a single stepwise request.",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog=__doc__,
+ )
+ parser.add_argument("--model", default=_MODEL, help="Hugging Face model ID or local checkpoint path.")
+ parser.add_argument("--image", required=True, help="Initial RGB image.")
+ parser.add_argument("--prompt", required=True, help="Scene prompt for the rollout.")
+ parser.add_argument(
+ "--action-script",
+ required=True,
+ help=(
+ "JSON file holding one camera action list per AR block. "
+ "The per-block frame count is read off the checkpoint, not assumed."
+ ),
+ )
+ parser.add_argument("--output-dir", required=True, help="Directory for decoded frames and metadata.")
+ parser.add_argument("--request-id", default="lingbot-world-stream", help="Request id; also the session id.")
+ parser.add_argument("--height", type=int, default=480)
+ parser.add_argument("--width", type=int, default=832)
+ parser.add_argument("--seed", type=int, default=42)
+ parser.add_argument("--gpu-memory-fraction", type=float, default=0.6)
+ parser.add_argument("--tensor-parallel-size", type=int, default=1)
+ parser.add_argument("--enforce-eager", action="store_true")
+ return parser.parse_args(argv)
+
+
+def _load_action_script(path: Path) -> list[list[list[str]]]:
+ """Parse the per-block camera action script, without assuming its geometry.
+
+ The shape is the one ``sampling_params.extra_args["camera_action_script"]``
+ takes: one entry per AR block, each entry one per-frame action list. How
+ many frames a block holds is a property of the checkpoint, so it is checked
+ in :func:`_validate_against_checkpoint` once the configs have been read.
+ """
+ try:
+ value = json.loads(path.read_text())
+ except json.JSONDecodeError as exc:
+ raise ValueError(f"--action-script is not valid JSON: {exc.msg}.") from None
+ if not isinstance(value, list) or not value:
+ raise ValueError("--action-script must be a non-empty JSON list of per-block action lists.")
+ script: list[list[list[str]]] = []
+ for block_index, block in enumerate(value):
+ if not isinstance(block, list) or not block:
+ raise ValueError(f"--action-script block {block_index} must be a non-empty list of frame action lists.")
+ frames: list[list[str]] = []
+ for frame in block:
+ if not isinstance(frame, list) or any(not isinstance(action, str) for action in frame):
+ raise ValueError(f"--action-script block {block_index} frames must contain only action strings.")
+ frames.append(list(frame))
+ script.append(frames)
+ return script
+
+
+def _model_config_json(model: str, relative_path: str) -> dict[str, Any]:
+ """Read one config file out of a local checkpoint or a Hub repo."""
+ if os.path.isdir(model):
+ return json.loads((Path(model) / relative_path).read_text())
+ from huggingface_hub import hf_hub_download
+
+ return json.loads(Path(hf_hub_download(repo_id=model, filename=relative_path)).read_text())
+
+
+def _checkpoint_geometry(model: str) -> tuple[int, int]:
+ """Read ``(frames_per_block, temporal_compression)`` off the checkpoint.
+
+ The pipeline takes both from the model --
+ ``transformer.config.num_frames_per_block`` and
+ ``vae.config.scale_factor_temporal`` -- so hardcoding 3 and 4 here would
+ silently build a wrong action script and an inconsistent ``num_frames`` the
+ moment ``--model`` points at a checkpoint with different geometry.
+ """
+ transformer_config = _model_config_json(model, "transformer/config.json")
+ vae_config = _model_config_json(model, "vae/config.json")
+ if "num_frames_per_block" not in transformer_config:
+ raise ValueError(
+ f"{model}'s transformer/config.json declares no num_frames_per_block, "
+ "so this checkpoint does not generate AR blocks and cannot be streamed this way."
+ )
+ frames_per_block = int(transformer_config["num_frames_per_block"])
+ temporal_compression = int(vae_config.get("scale_factor_temporal", 4))
+ if frames_per_block <= 0 or temporal_compression <= 0:
+ raise ValueError(
+ "the checkpoint reports a non-positive block geometry "
+ f"(num_frames_per_block={frames_per_block}, scale_factor_temporal={temporal_compression})."
+ )
+ return frames_per_block, temporal_compression
+
+
+def _validate_against_checkpoint(
+ script: list[list[list[str]]], *, frames_per_block: int, temporal_compression: int
+) -> int:
+ """Check the script against the checkpoint and return the frame count to request."""
+ for block_index, block in enumerate(script):
+ if len(block) != frames_per_block:
+ raise ValueError(
+ f"--action-script block {block_index} holds {len(block)} frame action lists, but this "
+ f"checkpoint generates {frames_per_block} latent frames per AR block."
+ )
+ return (len(script) * frames_per_block - 1) * temporal_compression + 1
+
+
+def _validate_args(args: argparse.Namespace) -> tuple[Path, Path, Path]:
+ image = Path(args.image).expanduser().resolve()
+ action_script = Path(args.action_script).expanduser().resolve()
+ output_dir = Path(args.output_dir).expanduser().resolve()
+ if not image.is_file():
+ raise ValueError("--image must point to an existing file.")
+ if not action_script.is_file():
+ raise ValueError("--action-script must point to an existing JSON file.")
+ if not args.prompt.strip():
+ raise ValueError("--prompt must contain non-whitespace text.")
+ if args.height <= 0 or args.width <= 0 or args.height % 16 or args.width % 16:
+ raise ValueError("--height and --width must be positive multiples of 16.")
+ if args.tensor_parallel_size <= 0:
+ raise ValueError("--tensor-parallel-size must be positive.")
+ if not math.isfinite(args.gpu_memory_fraction) or not 0 < args.gpu_memory_fraction <= 1:
+ raise ValueError("--gpu-memory-fraction must be in (0, 1].")
+ if not args.request_id.strip():
+ raise ValueError("--request-id must contain non-whitespace text.")
+ return image, action_script, output_dir
+
+
+def _as_frames(value: Any) -> np.ndarray:
+ """Normalize one streamed chunk to ``[T, H, W, 3]`` uint8.
+
+ ``output_type="np"`` reaches the client as a float array in ``[0, 1]``,
+ with or without the leading batch axis depending on how the formatter
+ unwrapped it; a nested list is the ``"pil"`` shape. Anything else means
+ the chunk is not pixels -- most likely the request still asked for
+ latents -- and is worth failing on rather than saving.
+ """
+ import numpy as np
+ import PIL.Image
+ import torch
+
+ if isinstance(value, list):
+ if len(value) == 1 and isinstance(value[0], list):
+ value = value[0]
+ if value and all(isinstance(frame, PIL.Image.Image) for frame in value):
+ return np.stack([np.asarray(frame.convert("RGB")) for frame in value])
+ if isinstance(value, torch.Tensor):
+ value = value.detach().float().cpu().numpy()
+ if not isinstance(value, np.ndarray):
+ raise RuntimeError(f"Expected decoded pixels from each AR block, got {type(value).__name__}.")
+ if value.ndim == 5 and value.shape[0] == 1:
+ value = value[0]
+ if value.ndim != 4 or value.shape[-1] != 3:
+ raise RuntimeError(f"Expected one [T, H, W, 3] pixel chunk per AR block, got shape {tuple(value.shape)}.")
+ if value.dtype != np.uint8:
+ value = (np.clip(value, 0.0, 1.0) * 255.0).round().astype(np.uint8)
+ return value
+
+
+def _chunk_metadata(output: Any) -> dict[str, Any]:
+ multimodal = getattr(output, "multimodal_output", None) or {}
+ metadata = multimodal.get("metadata") if isinstance(multimodal, dict) else None
+ ar_diffusion = metadata.get("ar_diffusion") if isinstance(metadata, dict) else None
+ if not isinstance(ar_diffusion, dict):
+ raise RuntimeError("Streamed chunk is missing its ar_diffusion metadata envelope.")
+ return dict(ar_diffusion)
+
+
+async def run(argv: Sequence[str] | None = None) -> Path:
+ args = parse_args(argv)
+ image, action_script_path, output_dir = _validate_args(args)
+ camera_action_script = _load_action_script(action_script_path)
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ # Imports stay below pure input validation so --help and helper tests do not
+ # require a CUDA-enabled vLLM installation.
+ import numpy as np
+
+ from vllm_omni.entrypoints.async_omni import AsyncOmni
+ from vllm_omni.inputs.data import OmniDiffusionSamplingParams
+
+ frames_per_block, temporal_compression = _checkpoint_geometry(args.model)
+ num_chunks = len(camera_action_script)
+ num_frames = _validate_against_checkpoint(
+ camera_action_script,
+ frames_per_block=frames_per_block,
+ temporal_compression=temporal_compression,
+ )
+
+ engine = AsyncOmni(
+ model=args.model,
+ engine_backend="vllm_omni.experimental.ar_diffusion.engine.ARDiffusionEngine",
+ enforce_eager=args.enforce_eager,
+ tensor_parallel_size=args.tensor_parallel_size,
+ max_num_seqs=1,
+ # One request, many chunks: the stepwise contract from #6844. Streaming
+ # output is what turns each post_decode() into a delivered chunk.
+ step_execution=True,
+ diffusion_streaming_output=True,
+ model_config={
+ "ar_diffusion_height": args.height,
+ "ar_diffusion_width": args.width,
+ "ar_diffusion_kv_config": {
+ "gpu_memory_fraction": args.gpu_memory_fraction,
+ "warmup_cudagraph": True,
+ },
+ },
+ )
+ sampling = OmniDiffusionSamplingParams(
+ height=args.height,
+ width=args.width,
+ num_frames=num_frames,
+ num_inference_steps=4,
+ max_sequence_length=512,
+ seed=args.seed,
+ # Pixels, not latents: this is what routes the chunk through the
+ # session's streaming VAE decode instead of handing back a tensor.
+ output_type="np",
+ extra_args={"flow_shift": 5.0, "camera_action_script": camera_action_script},
+ )
+ prompt = {"prompt": args.prompt, "multi_modal_data": {"image": str(image)}}
+
+ measurements: list[dict[str, Any]] = []
+ t_start = time.perf_counter()
+ first_frame_at: float | None = None
+ chunk_started = t_start
+ try:
+ async for output in engine.generate(prompt, sampling, request_id=args.request_id):
+ images = getattr(output, "images", None)
+ if not images:
+ continue
+ if len(images) != 1:
+ raise RuntimeError("Expected exactly one decoded chunk per AR block.")
+ frames = _as_frames(images[0])
+ now = time.perf_counter()
+ if first_frame_at is None:
+ first_frame_at = now - t_start
+ chunk_index = len(measurements)
+ metadata = _chunk_metadata(output)
+
+ np.save(output_dir / f"chunk_{chunk_index:03d}.npy", frames)
+ (output_dir / f"chunk_{chunk_index:03d}.json").write_text(
+ json.dumps(metadata, indent=2, sort_keys=True) + "\n"
+ )
+ measurements.append(
+ {
+ "chunk_index": chunk_index,
+ "chunk_latency_seconds": now - chunk_started,
+ "frames": int(frames.shape[0]),
+ "frame_shape": list(frames.shape),
+ "metadata": metadata,
+ }
+ )
+ chunk_started = now
+ print(json.dumps(measurements[-1], sort_keys=True), flush=True)
+ finally:
+ engine.shutdown()
+
+ delivered_frames = sum(int(chunk["frames"]) for chunk in measurements)
+ summary_path = output_dir / "summary.json"
+ summary_path.write_text(
+ json.dumps(
+ {
+ "request_id": args.request_id,
+ "chunks_delivered": len(measurements),
+ "chunks_expected": num_chunks,
+ "frames_delivered": delivered_frames,
+ "frames_expected": num_frames,
+ # The point of session-owned decode: a streamed rollout carries
+ # the same frame timeline as an offline decode of the same
+ # latents. Per-chunk decode would deliver fewer, because every
+ # block would re-expand its own opening frame.
+ "matches_offline_frame_timeline": delivered_frames == num_frames,
+ "time_to_first_frame_seconds": first_frame_at,
+ "total_seconds": time.perf_counter() - t_start,
+ "chunks": measurements,
+ },
+ indent=2,
+ sort_keys=True,
+ )
+ + "\n"
+ )
+ return summary_path
+
+
+def main(argv: Sequence[str] | None = None) -> Path:
+ return asyncio.run(run(argv))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/offline_inference/diffusion/lingbot_world_v2.py b/examples/offline_inference/diffusion/lingbot_world_v2.py
index 590442c4d..0eb2027b1 100644
--- a/examples/offline_inference/diffusion/lingbot_world_v2.py
+++ b/examples/offline_inference/diffusion/lingbot_world_v2.py
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
-# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Generate a LingBot-World v2 video from an image and camera trajectory.
The official checkpoint is licensed separately under CC BY-NC-SA and is
@@ -32,7 +32,6 @@ import numpy as np
_MODEL = "robbyant/lingbot-world-v2-14b-causal-fast-diffusers"
_MAX_SEQUENCE_LENGTH = 512
_MAX_PIXEL_AREA = 480 * 832
-_MAX_RAW_FRAMES = 117
_MAX_SOURCE_CAMERA_FRAMES = 4096
_TEMPORAL_COMPRESSION = 4
_LATENT_FRAMES_PER_BLOCK = 3
@@ -99,6 +98,7 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
default=1,
help="Number of GPUs used for tensor parallelism inside the DiT.",
)
+ parser.add_argument("--ulysses-degree", type=int, default=1, help="Pure Ulysses sequence parallel degree.")
parser.add_argument("--flow-shift", type=float, default=5.0, help="Positive FlowUniPC scheduler shift.")
parser.add_argument("--fps", type=int, default=16, help="Frames per second in the exported MP4.")
parser.add_argument("--output", default="lingbot_world_v2.mp4", help="Output MP4 path.")
@@ -158,6 +158,8 @@ def build_omni_kwargs(
if args.tensor_parallel_size <= 0:
raise ValueError("--tensor-parallel-size must be a positive integer.")
+ if args.ulysses_degree <= 0:
+ raise ValueError("--ulysses-degree must be a positive integer.")
flow_shift = _positive_finite(args.flow_shift, "--flow-shift")
model_path = Path(args.model).expanduser()
model = str(model_path.resolve()) if model_path.exists() else args.model
@@ -165,6 +167,7 @@ def build_omni_kwargs(
"model": model,
"flow_shift": flow_shift,
"tensor_parallel_size": args.tensor_parallel_size,
+ "ulysses_degree": args.ulysses_degree,
"enforce_eager": args.enforce_eager,
"model_config": {"lingbot_action_root": str(paths.action_root)},
}
@@ -187,8 +190,6 @@ def build_request(
raise ValueError("--height * --width must not exceed 480 * 832 pixels.")
if args.num_frames <= 0:
raise ValueError("--num-frames must be a positive integer.")
- if args.num_frames > _MAX_RAW_FRAMES:
- raise ValueError("--num-frames must not exceed 117 raw frames.")
if args.num_frames > paths.camera_frames:
raise ValueError("Camera arrays must contain at least --num-frames frames.")
if (args.num_frames - 1) % _TEMPORAL_COMPRESSION:
diff --git a/examples/offline_inference/diffusion/lingbot_world_v2_realtime.py b/examples/offline_inference/diffusion/lingbot_world_v2_realtime.py
index d98fe06f9..f0f1043ab 100644
--- a/examples/offline_inference/diffusion/lingbot_world_v2_realtime.py
+++ b/examples/offline_inference/diffusion/lingbot_world_v2_realtime.py
@@ -1,13 +1,12 @@
# SPDX-License-Identifier: Apache-2.0
-# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Run LingBot-World 2.0 as an in-process realtime AR-Diffusion session.
This example exercises the internal session API. It is not a public HTTP or
WebSocket protocol. Each JSONL event advances the world by one three-latent
-frame AR block and writes the latent plus its identity metadata. The current
-scoped implementation supports at most ten ticks per generation epoch because
-its image condition is bounded to 117 pixel frames; reset or create a session
-to start a new world.
+frame AR block and writes the latent plus its identity metadata. Session-owned
+causal VAE encoder state produces each condition block without retaining or
+repeating a full image-condition horizon.
"""
from __future__ import annotations
@@ -24,8 +23,6 @@ from typing import Any
_MODEL = "robbyant/lingbot-world-v2-14b-causal-fast-diffusers"
_CAMERA_ACTION_SCHEMA = "lingbot.camera_actions.v1"
_FRAMES_PER_BLOCK = 3
-# ((117 pixel frames - 1) / VAE temporal factor 4 + 1) / 3 latent frames.
-_MAX_REALTIME_TICKS = 10
def _camera_event_data(frames: list[list[str]]) -> dict[str, Any]:
@@ -41,7 +38,7 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser.add_argument(
"--events",
required=True,
- help="JSONL file with one prompt/action event per AR block; at most 10 events.",
+ help="JSONL file with one prompt/action event per AR block.",
)
parser.add_argument("--output-dir", required=True, help="Directory for chunk latents and metadata.")
parser.add_argument("--session-id", default="lingbot-world", help="Persistent world session identifier.")
@@ -84,12 +81,6 @@ def _load_events(path: Path) -> list[dict[str, Any]]:
events.append({"event_id": event_id, "prompt": prompt, "frames": frames})
if not events:
raise ValueError("events file must contain at least one event.")
- if len(events) > _MAX_REALTIME_TICKS:
- raise ValueError(
- "events file must contain at most "
- f"{_MAX_REALTIME_TICKS} events because the current LingBot realtime "
- "image-condition horizon is 117 pixel frames."
- )
event_ids = [event["event_id"] for event in events]
if event_ids != sorted(set(event_ids)):
raise ValueError("event_id values must be unique and strictly increasing.")
diff --git a/examples/offline_inference/dynin_omni/README.md b/examples/offline_inference/dynin_omni/README.md
deleted file mode 100644
index 6cf834142..000000000
--- a/examples/offline_inference/dynin_omni/README.md
+++ /dev/null
@@ -1,110 +0,0 @@
-# Dynin-Omni: Offline inference
-
-This folder contains a unified offline inference entrypoint:
-
-- `end2end.py`
-
-## 1. Environment Setup
-
-Run from repository root:
-
-```bash
-cd
-```
-
-If needed, install this repo in editable mode:
-
-```bash
-pip install -e .
-```
-
-## 2. Extra Dependencies (EMOVA)
-
-Install the following packages for EMOVA-related components:
-
-```bash
-pip install \
- "phonemizer==3.3.0" \
- "Unidecode==1.4.0" \
- "hydra-core==1.3.2" \
- "pytorch-lightning==1.1.0" \
- "wget==3.2" \
- "wrapt==2.1.1" \
- "onnx==1.20.1" \
- "frozendict==2.4.7" \
- "inflect==7.5.0" \
- "braceexpand==0.1.7" \
- "webdataset==1.0.2" \
- "torch-stft==0.1.4" \
- "editdistance==0.8.1"
-```
-
-## 3. Hardware and VRAM Requirements
-
-This example uses a 3-stage pipeline on one GPU by default
-([`dynin_omni.yaml`](../../../vllm_omni/deploy/dynin_omni.yaml)):
-
-- Stage-0 (`token2text`): `gpu_memory_utilization: 0.5`
-- Stage-1 (`token2image`): `gpu_memory_utilization: 0.1`
-- Stage-2 (`token2audio`): `gpu_memory_utilization: 0.1`
-
-### Requested GPU Memory Budget from `gpu_memory_utilization`
-
-| Stage | Utilization | A100 80GB | H200 141GB |
-| :-- | :-- | :-- | :-- |
-| Stage-0 (token2text) | 0.5 | ~40.0 GB | ~70.5 GB |
-| Stage-1 (token2image) | 0.1 | ~8.0 GB | ~14.1 GB |
-| Stage-2 (token2audio) | 0.1 | ~8.0 GB | ~14.1 GB |
-| Total requested budget | 0.7 | ~56.0 GB | ~98.7 GB |
-
-### Observed Runtime Signal (from your log)
-
-- Stage-0 reported: `Model loading took 15.12 GiB memory` (weights footprint signal).
-- Stages 1/2 can still add runtime memory depending on task path and backend allocations.
-- Keep extra headroom for CUDA/PyTorch overhead and temporary allocations.
-
-### GPU Compatibility
-
-- Confirmed target GPUs for this setup: **NVIDIA H200**, **NVIDIA A100**.
-- CI/e2e coverage in this repo also includes CUDA **L4** markers for Dynin tests.
-
-## 4. End2End Run Examples
-
-```bash
-# t2t
-python /examples/offline_inference/dynin_omni/end2end.py \
- --task t2t --model snu-aidas/Dynin-Omni --text
-
-# i2t
-python /examples/offline_inference/dynin_omni/end2end.py \
- --task i2t --model snu-aidas/Dynin-Omni --image --text "Please describe this image in detail."
-
-# s2t
-python /examples/offline_inference/dynin_omni/end2end.py \
- --task s2t --model snu-aidas/Dynin-Omni --audio --text "Transcribe the given audio."
-
-# t2i
-python /examples/offline_inference/dynin_omni/end2end.py \
- --task t2i --model snu-aidas/Dynin-Omni --text
-
-# v2t
-python /examples/offline_inference/dynin_omni/end2end.py \
- --task v2t --model snu-aidas/Dynin-Omni --video --text "Describe this video in detail."
-
-# i2i
-python /examples/offline_inference/dynin_omni/end2end.py \
- --task i2i --model snu-aidas/Dynin-Omni --image --text
-
-# t2s
-python /examples/offline_inference/dynin_omni/end2end.py \
- --task t2s --model snu-aidas/Dynin-Omni --text
-```
-
-## 5. Notes
-
-- Outputs are saved under task-specific directories in `/tmp` by default.
-- You can override output path with `--output-dir`.
-- If you want to force local config resolution, pass `--dynin-config-path `.
-- If you see the warning
- `max_num_batched_tokens (32768) exceeds max_num_seqs * max_model_len (4096)`,
- reduce `max_num_batched_tokens` in the deploy config (for example, `4096` in the CI config).
diff --git a/examples/offline_inference/dynin_omni/end2end.py b/examples/offline_inference/dynin_omni/end2end.py
deleted file mode 100644
index 74d33fe01..000000000
--- a/examples/offline_inference/dynin_omni/end2end.py
+++ /dev/null
@@ -1,1448 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: Apache-2.0
-
-from __future__ import annotations
-
-import argparse
-import json
-import os
-import re
-import sys
-import time
-import types
-from importlib.machinery import ModuleSpec
-from pathlib import Path
-from typing import Any
-
-import numpy as np
-import torch
-from PIL import Image
-
-TASK_CHOICES = ("t2t", "t2i", "t2s", "i2i", "i2t", "s2t", "v2t")
-
-TASK_DEFAULT_RUNTIME = {
- "t2t": ("mmu", "mmu", 0, "text"),
- "t2i": ("t2i", "t2i_gen", 2, "image"),
- "t2s": ("t2s_mmu_like", "t2s_gen", 1, "audio"),
- "i2i": ("i2i", "i2i", 2, "image"),
- "i2t": ("mmu", "mmu", 0, "text"),
- "s2t": ("s2t", "s2t", 0, "text"),
- "v2t": ("v2t", "v2t", 0, "text"),
-}
-
-TASK_RUNTIME_FALLBACKS: dict[str, dict[str, Any]] = {
- "t2t": {
- "output_dir": "/tmp/dynin_end2end_outputs",
- "prompt_max_text_len": 1024,
- "max_new_tokens": 1024,
- "steps": 1024,
- "block_length": 16,
- "temperature": 0.0,
- "cfg_scale": 0.0,
- },
- "t2i": {
- "output_dir": "/tmp/dynin_t2i_outputs",
- "prompt_max_text_len": 128,
- "image_token_count": 1024,
- "mask_token_id": 126336,
- "codebook_size": 8192,
- "timesteps": 20,
- "guidance_scale": 3.5,
- "temperature": 1.0,
- },
- "i2i": {
- "output_dir": "/tmp/dynin_i2i_outputs",
- "prompt_max_text_len": 128,
- "mask_token_id": 126336,
- "codebook_size": 8192,
- "timesteps": 64,
- "guidance_scale": 3.5,
- "temperature": 1.0,
- "image_resolution": 336,
- "use_train_i2i_prompt": True,
- },
- "i2t": {
- "output_dir": "/tmp/dynin_i2t_outputs",
- "prompt_max_text_len": 128,
- "max_new_tokens": 128,
- "steps": 128,
- "block_length": 2,
- "temperature": 0.0,
- "cfg_scale": 0.0,
- "mask_token_id": 126336,
- "codebook_size": 8192,
- "image_resolution": 480,
- "remasking": "low_confidence",
- },
- "s2t": {
- "output_dir": "/tmp/dynin_s2t_outputs",
- "prompt_max_text_len": 1024,
- "max_new_tokens": 128,
- "steps": 128,
- "block_length": 2,
- "temperature": 0.0,
- "cfg_scale": 0.0,
- "mask_token_id": 126336,
- "codebook_size": 8192,
- "remasking": "low_confidence",
- },
- "t2s": {
- "output_dir": "/tmp/dynin_t2s_outputs",
- "runtime_task": "t2s_mmu_like",
- "prompting_task": "t2s_gen",
- "prompt_max_text_len": 1024,
- "t2s_token_length": 512,
- "mask_token_id": 126336,
- "codebook_size": 8192,
- "audio_codebook_size": 4096,
- "steps": 512,
- "block_length": 128,
- "temperature": 1.0,
- "cfg_scale": 2.5,
- "t2s_condition": "gender-female_emotion-neutral_speed-normal_pitch-normal",
- },
- "v2t": {
- "output_dir": "/tmp/dynin_v2t_outputs",
- "prompt_max_text_len": 1024,
- "max_new_tokens": 128,
- "steps": 128,
- "block_length": 2,
- "temperature": 0.0,
- "cfg_scale": 0.0,
- "mask_token_id": 126336,
- "codebook_size": 8192,
- "image_resolution": 224,
- "num_frames": 5,
- "remasking": "low_confidence",
- },
-}
-
-DEFAULT_I2T_QUESTION = "Please describe this image in detail."
-DEFAULT_S2T_INSTRUCTION = "Transcribe the given audio."
-DEFAULT_V2T_QUESTION = "Please provide a detailed description of the video."
-DEFAULT_T2T_PROMPT = "Explain multimodal LLM inference in 3 sentences."
-DEFAULT_T2S_INSTRUCTION = "Convert the given text into spoken audio."
-DEFAULT_T2S_PROMPT = "Hello. This is a default text-to-speech sample."
-
-DYNIN_SPECIAL_TOKENS = (
- "<|soi|>",
- "<|eoi|>",
- "<|sov|>",
- "<|eov|>",
- "<|t2i|>",
- "<|mmu|>",
- "<|t2v|>",
- "<|v2v|>",
- "<|lvg|>",
- "<|i2i|>",
- "<|ti2ti|>",
- "<|v2t|>",
- "<|v2s|>",
- "<|s2t|>",
- "<|t2s|>",
- "<|s2s|>",
- "<|soa|>",
- "<|eoa|>",
-)
-
-
-def bootstrap_repo_path() -> Path:
- repo_root = Path(__file__).resolve().parents[3]
- repo_root_str = str(repo_root)
- if repo_root_str not in sys.path:
- sys.path.insert(0, repo_root_str)
- return repo_root
-
-
-def ensure_safe_import_for_vllm() -> None:
- os.environ.setdefault("TRANSFORMERS_NO_TORCHVISION", "1")
- try:
- import torchvision # noqa: F401
-
- return
- except Exception:
- pass
-
- import enum
-
- class _InterpolationMode(enum.Enum):
- NEAREST = 0
- BILINEAR = 2
- BICUBIC = 3
- LANCZOS = 1
- HAMMING = 4
- BOX = 5
-
- tv_mod = types.ModuleType("torchvision")
- tv_mod.__dict__["__version__"] = "0.0-stub"
- tv_mod.__spec__ = ModuleSpec(name="torchvision", loader=None)
- transforms_mod = types.ModuleType("torchvision.transforms")
- transforms_mod.__spec__ = ModuleSpec(name="torchvision.transforms", loader=None)
- transforms_mod.InterpolationMode = _InterpolationMode
- tv_mod.transforms = transforms_mod
- sys.modules["torchvision"] = tv_mod
- sys.modules["torchvision.transforms"] = transforms_mod
-
-
-def sanitize_repo_id(repo_id: str) -> str:
- return re.sub(r"[^a-zA-Z0-9._-]+", "_", repo_id)
-
-
-def is_hf_repo_id(value: str) -> bool:
- return isinstance(value, str) and value.count("/") == 1 and all(value.split("/", 1))
-
-
-def ensure_local_model_dir(model: str, cache_dir: Path, localize: bool) -> Path:
- model_path = Path(model).expanduser()
- if model_path.is_dir():
- return model_path.resolve()
- if not localize:
- return Path(model)
-
- from huggingface_hub import snapshot_download
-
- cache_dir.mkdir(parents=True, exist_ok=True)
- os.environ.setdefault("HF_HOME", str(cache_dir / ".hf_home"))
- local_dir = cache_dir / sanitize_repo_id(model)
- if not local_dir.exists():
- print(f"[end2end] Downloading model into local cache: {local_dir}")
- snapshot_download(
- repo_id=model,
- local_dir=str(local_dir),
- local_dir_use_symlinks=True,
- resume_download=True,
- )
- return local_dir.resolve()
-
-
-def resolve_local_only(
- override: bool | None,
- source: str,
- default: bool,
-) -> bool:
- if override is not None:
- return bool(override)
- return default or Path(source).expanduser().is_dir()
-
-
-def load_text_tokenizer(tokenizer_source: str, local_files_only: bool):
- from transformers import AutoTokenizer
-
- kwargs = {
- "trust_remote_code": True,
- "padding_side": "left",
- "local_files_only": bool(local_files_only),
- }
- try:
- tokenizer = AutoTokenizer.from_pretrained(tokenizer_source, **kwargs)
- except TypeError:
- kwargs.pop("local_files_only", None)
- tokenizer = AutoTokenizer.from_pretrained(tokenizer_source, **kwargs)
- return tokenizer
-
-
-def preprocess_image(image: Image.Image, resolution: int) -> torch.Tensor:
- w, h = image.size
- short_side = min(w, h)
- scale = resolution / short_side
- new_w, new_h = round(w * scale), round(h * scale)
- image = image.resize((new_w, new_h), Image.BICUBIC)
- left = (new_w - resolution) // 2
- top = (new_h - resolution) // 2
- image = image.crop((left, top, left + resolution, top + resolution))
- arr = np.array(image, dtype=np.float32) / 255.0
- tensor = torch.from_numpy(arr).permute(2, 0, 1)
- return (tensor - 0.5) / 0.5
-
-
-def load_vq_image_encoder(source: str, local_files_only: bool, device: torch.device) -> Any:
- from vllm_omni.model_executor.models.dynin_omni.dynin_omni_common import get_dynin_magvit_attr
-
- MAGVITv2 = get_dynin_magvit_attr("MAGVITv2", source=source, local_files_only=local_files_only)
- vq_model = MAGVITv2.from_pretrained(source, local_files_only=local_files_only).to(device)
- vq_model.requires_grad_(False)
- vq_model.eval()
- return vq_model
-
-
-def encode_image_tokens(
- image_path: Path,
- vq_model: Any,
- device: torch.device,
- resolution: int,
-) -> torch.Tensor:
- image = Image.open(image_path).convert("RGB")
- image_tensor = preprocess_image(image, resolution=resolution).unsqueeze(0).to(device)
- with torch.no_grad():
- token_ids = vq_model.get_code(image_tensor)
- token_ids = torch.as_tensor(token_ids, dtype=torch.long).detach().cpu()
- if token_ids.ndim == 2 and token_ids.shape[0] == 1:
- token_ids = token_ids[0]
- return token_ids.contiguous()
-
-
-def encode_video_tokens(
- video_path: Path,
- vq_model: Any,
- device: torch.device,
- resolution: int,
- num_frames: int,
-) -> torch.Tensor:
- import cv2
-
- cap = cv2.VideoCapture(str(video_path))
- frames: list[np.ndarray] = []
- while True:
- ok, frame = cap.read()
- if not ok:
- break
- frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
- frames.append(frame)
- cap.release()
- if not frames:
- raise ValueError(f"Video has no readable frames: {video_path}")
- if len(frames) < num_frames:
- raise ValueError(f"Video has {len(frames)} frames, requires >= {num_frames}: {video_path}")
-
- indices = np.linspace(0, len(frames) - 1, num_frames).astype(int)
- token_list: list[torch.Tensor] = []
- for idx in indices:
- pil = Image.fromarray(frames[int(idx)])
- frame_tensor = preprocess_image(pil, resolution=resolution).unsqueeze(0).to(device)
- with torch.no_grad():
- token_list.append(torch.as_tensor(vq_model.get_code(frame_tensor), dtype=torch.long))
- merged = torch.cat(token_list, dim=1).detach().cpu()
- if merged.ndim == 2 and merged.shape[0] == 1:
- merged = merged[0]
- return merged.contiguous()
-
-
-def load_vq_audio_encoder(source: str, local_files_only: bool, device: torch.device) -> Any:
- from transformers import AutoModel
-
- kwargs = {
- "trust_remote_code": True,
- "local_files_only": bool(local_files_only),
- "low_cpu_mem_usage": False,
- }
- try:
- model = AutoModel.from_pretrained(source, **kwargs)
- except TypeError:
- kwargs.pop("low_cpu_mem_usage", None)
- try:
- model = AutoModel.from_pretrained(source, **kwargs)
- except TypeError:
- kwargs.pop("local_files_only", None)
- model = AutoModel.from_pretrained(source, **kwargs)
- model.requires_grad_(False)
- model.eval()
- if hasattr(model, "to"):
- model = model.to(device)
- return model
-
-
-def encode_audio_tokens(audio_path: Path, vq_audio_model: Any) -> torch.Tensor:
- encoded = vq_audio_model.encode(str(audio_path))
- if isinstance(encoded, dict):
- for key in ("input_ids", "token_ids", "codes", "tokens"):
- if key in encoded:
- encoded = encoded[key]
- break
- encoded = torch.as_tensor(encoded, dtype=torch.long).detach().cpu()
- if encoded.ndim == 1:
- encoded = encoded.unsqueeze(0)
- elif encoded.ndim > 2:
- encoded = encoded.view(encoded.shape[0], -1)
- return encoded.contiguous()
-
-
-def build_chat_prompt(content: str) -> str:
- return (
- f"<|start_header_id|>user<|end_header_id|>\n{content}<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n"
- )
-
-
-def resolve_task_text(
- *,
- task_name: str,
- text: str,
- instruction: str = "",
- raw_prompt: bool = False,
-) -> str:
- text = str(text or "").strip()
-
- if task_name == "t2t" and not text:
- return DEFAULT_T2T_PROMPT
- if task_name == "i2t" and not text:
- return DEFAULT_I2T_QUESTION
- if task_name == "s2t" and not text:
- return DEFAULT_S2T_INSTRUCTION
- if task_name == "v2t" and not text:
- return DEFAULT_V2T_QUESTION
- if task_name in {"t2i", "i2i"} and not text:
- return "A high quality detailed image."
-
- if task_name != "t2s":
- return text
-
- if not text:
- text = DEFAULT_T2S_PROMPT
-
- if raw_prompt:
- return text
-
- instruction = str(instruction or "").strip() or DEFAULT_T2S_INSTRUCTION
- return build_chat_prompt(f"{instruction}\n{text}")
-
-
-def load_universal_prompting(
- *,
- tokenizer: Any,
- tokenizer_source: str,
- max_text_len: int,
- cond_dropout_prob: float,
- local_files_only: bool,
- max_audio_len: int = 512,
- max_audio_len_short: int = 256,
-) -> Any:
- from vllm_omni.model_executor.models.dynin_omni.dynin_omni_common import (
- DYNIN_REMOTE_SETTINGS,
- resolve_remote_attr,
- )
-
- UniversalPrompting = resolve_remote_attr(
- "UniversalPrompting",
- module_name="prompting_utils",
- settings=DYNIN_REMOTE_SETTINGS,
- source=tokenizer_source,
- local_files_only=bool(local_files_only),
- fallback_module_names=("modeling_dynin_omni",),
- )
- init_kwargs: dict[str, Any] = {
- "max_text_len": int(max_text_len),
- "special_tokens": DYNIN_SPECIAL_TOKENS,
- "ignore_id": -100,
- "cond_dropout_prob": float(cond_dropout_prob),
- "use_reserved_token": True,
- "max_audio_len": int(max_audio_len),
- "max_audio_len_short": int(max_audio_len_short),
- }
- try:
- return UniversalPrompting(tokenizer, **init_kwargs)
- except TypeError:
- init_kwargs.pop("max_audio_len", None)
- init_kwargs.pop("max_audio_len_short", None)
- return UniversalPrompting(tokenizer, **init_kwargs)
-
-
-def _runtime_fallback(task: str, key: str, value: Any) -> Any:
- if isinstance(value, str):
- if value.strip() != "":
- return value
- elif value is not None:
- return value
- return TASK_RUNTIME_FALLBACKS.get(task, {}).get(key)
-
-
-def _validate_generation_args(*, task: str, max_new_tokens: int, steps: int, block_length: int) -> None:
- # Keep i2t/v2t generation constraints aligned with i2t.py/v2t.py.
- if task not in {"i2t", "v2t"}:
- return
- if max_new_tokens <= 0:
- raise ValueError(f"{task} requires max_new_tokens > 0.")
- if block_length <= 0:
- raise ValueError(f"{task} requires block_length > 0.")
- if steps <= 0:
- raise ValueError(f"{task} requires steps > 0.")
- if max_new_tokens % block_length != 0:
- raise ValueError(f"{task} requires max_new_tokens % block_length == 0, got {max_new_tokens} % {block_length}")
- num_blocks = max_new_tokens // block_length
- if num_blocks <= 0:
- raise ValueError(f"{task} has invalid num_blocks.")
- if steps % num_blocks != 0:
- raise ValueError(
- f"{task} requires steps % (max_new_tokens // block_length) == 0, "
- f"got steps={steps}, max_new_tokens={max_new_tokens}, block_length={block_length}"
- )
-
-
-def make_prompt_payload(
- *,
- task: str,
- text: str,
- image_tokens: torch.Tensor | None,
- audio_tokens: torch.Tensor | None,
- video_tokens: torch.Tensor | None,
- image_placeholder_tokens: int,
- audio_placeholder_tokens: int,
- image_token_offset: int,
- speech_token_offset: int,
- mask_token_id: int,
- use_train_i2i_prompt: bool,
-) -> tuple[Any, str]:
- runtime_task, prompting_task, _, _ = TASK_DEFAULT_RUNTIME[task]
- del runtime_task
-
- if task == "t2t":
- payload = ([[]], [build_chat_prompt(text)])
- return payload, prompting_task
-
- if task == "i2t":
- if image_tokens is None:
- raise ValueError("i2t requires image tokens")
- img = image_tokens.view(-1).long() + int(image_token_offset)
- payload = ([[img]], [build_chat_prompt(text)])
- return payload, prompting_task
-
- if task == "s2t":
- if audio_tokens is None:
- raise ValueError("s2t requires audio tokens")
- aud = audio_tokens.long() + int(speech_token_offset)
- if aud.ndim == 1:
- aud = aud.unsqueeze(0)
- payload = ([aud], [build_chat_prompt(text)])
- return payload, prompting_task
-
- if task == "v2t":
- if video_tokens is None:
- raise ValueError("v2t requires video tokens")
- vid = video_tokens.view(-1).long() + int(image_token_offset)
- payload = (vid.unsqueeze(0), [build_chat_prompt(text)])
- return payload, prompting_task
-
- if task == "t2i":
- image_placeholder = torch.full(
- (1, int(image_placeholder_tokens)),
- fill_value=int(mask_token_id),
- dtype=torch.long,
- )
- payload = ([text], image_placeholder)
- return payload, prompting_task
-
- if task == "i2i":
- if image_tokens is None:
- raise ValueError("i2i requires image tokens")
- src = image_tokens.view(1, -1).long() + int(image_token_offset)
- target_len = int(image_placeholder_tokens) if image_placeholder_tokens > 0 else int(src.shape[1])
- image_placeholder = torch.full(
- (1, target_len),
- fill_value=int(mask_token_id),
- dtype=torch.long,
- )
- if use_train_i2i_prompt:
- labels_placeholder = torch.full(
- (1, target_len),
- fill_value=-100,
- dtype=torch.long,
- )
- payload = ([text], src, image_placeholder, labels_placeholder)
- return payload, "i2i"
- payload = ([text], src, image_placeholder)
- return payload, "i2i_gen"
-
- if task == "t2s":
- audio_placeholder = torch.full(
- (1, int(audio_placeholder_tokens)),
- fill_value=int(mask_token_id),
- dtype=torch.long,
- )
- payload = ([text], audio_placeholder)
- return payload, prompting_task
-
- raise ValueError(f"Unsupported task: {task}")
-
-
-def _to_1d_int_list(value: Any) -> list[int]:
- if value is None:
- return []
- if isinstance(value, torch.Tensor):
- tensor = value.detach().to(device="cpu", dtype=torch.long)
- else:
- tensor = torch.as_tensor(value, dtype=torch.long)
- if tensor.ndim == 0:
- tensor = tensor.view(1)
- elif tensor.ndim >= 2:
- tensor = tensor.view(tensor.shape[0], -1)[0]
- return [int(v) for v in tensor.tolist()]
-
-
-def _run_uni_prompting(uni_prompting: Any, payload: Any, prompting_task: str) -> tuple[list[int], list[int]]:
- prepared = uni_prompting(payload, prompting_task)
- if isinstance(prepared, tuple):
- prepared_input_ids = prepared[0] if len(prepared) > 0 else None
- prepared_attention_mask = prepared[1] if len(prepared) > 1 else None
- else:
- prepared_input_ids = prepared
- prepared_attention_mask = None
-
- input_ids = _to_1d_int_list(prepared_input_ids)
- attention_mask = _to_1d_int_list(prepared_attention_mask)
- if not input_ids:
- raise RuntimeError(f"UniversalPrompting returned empty input_ids for task={prompting_task}")
- return input_ids, attention_mask
-
-
-def _get_special_token_id(uni_prompting: Any, token: str) -> int:
- sptids = getattr(uni_prompting, "sptids_dict", None) or {}
- if token not in sptids:
- raise KeyError(f"Special token not found in UniversalPrompting.sptids_dict: {token}")
- token_ids = _to_1d_int_list(sptids[token])
- if not token_ids:
- raise ValueError(f"Special token id is empty for token: {token}")
- return int(token_ids[0])
-
-
-def _tokenize_chat_query(tokenizer: Any, text: str) -> list[int]:
- encoded = tokenizer(build_chat_prompt(text), return_tensors="pt").input_ids[0]
- token_ids = _to_1d_int_list(encoded)
- if not token_ids:
- raise RuntimeError("Failed to tokenize chat query text.")
- return token_ids
-
-
-def _flatten_media_token_ids_with_offset(token_ids: Any, token_offset: int) -> list[int]:
- media_ids = token_ids
- if isinstance(media_ids, torch.Tensor):
- media_ids = media_ids.detach().cpu().reshape(-1).tolist()
- else:
- media_ids = np.asarray(media_ids).reshape(-1).tolist()
- return [int(x) + int(token_offset) for x in media_ids]
-
-
-def _scalar_token_id(value: Any) -> int:
- if isinstance(value, torch.Tensor):
- if value.numel() == 0:
- raise ValueError("Empty special-token tensor.")
- return int(value.view(-1)[0].item())
- if isinstance(value, (list, tuple)):
- if not value:
- raise ValueError("Empty special-token list.")
- return int(value[0])
- return int(value)
-
-
-def build_v2t_input_ids(
- *,
- video_token_ids: Any,
- tokenizer: Any,
- uni_prompting: Any,
- question: str,
- image_token_offset: int,
-) -> tuple[list[int], str]:
- media_ids = video_token_ids
- if isinstance(media_ids, torch.Tensor):
- media_ids = media_ids.detach().cpu().reshape(-1).tolist()
- else:
- media_ids = np.asarray(media_ids).reshape(-1).tolist()
- media_ids = [int(x) + int(image_token_offset) for x in media_ids]
-
- sptids = uni_prompting.sptids_dict
- task_id = _scalar_token_id(sptids["<|v2t|>"])
- soi_id = _scalar_token_id(sptids["<|soi|>"])
- eoi_id = _scalar_token_id(sptids["<|eoi|>"])
- sot_id = _scalar_token_id(sptids["<|sot|>"])
-
- prompt_text = build_v2t_chat_prompt(question)
- query_ids = tokenizer(prompt_text, return_tensors="pt").input_ids[0].detach().cpu().tolist()
- input_ids = [task_id, soi_id] + media_ids + [eoi_id, sot_id] + [int(v) for v in query_ids]
- return input_ids, prompt_text
-
-
-def build_i2t_input_ids(
- *,
- image_token_ids: Any,
- tokenizer: Any,
- uni_prompting: Any,
- question: str,
- image_token_offset: int,
-) -> tuple[list[int], str]:
- image_ids = image_token_ids
- if isinstance(image_ids, torch.Tensor):
- image_ids = image_ids.detach().cpu().reshape(-1).tolist()
- else:
- image_ids = np.asarray(image_ids).reshape(-1).tolist()
- image_ids = [int(x) + int(image_token_offset) for x in image_ids]
-
- sptids = uni_prompting.sptids_dict
- task_id = _scalar_token_id(sptids["<|mmu|>"])
- soi_id = _scalar_token_id(sptids["<|soi|>"])
- eoi_id = _scalar_token_id(sptids["<|eoi|>"])
- sot_id = _scalar_token_id(sptids["<|sot|>"])
-
- prompt_text = build_i2t_chat_prompt(question)
- query_ids = tokenizer(prompt_text, return_tensors="pt").input_ids[0].detach().cpu().tolist()
- input_ids = [task_id, soi_id] + image_ids + [eoi_id, sot_id] + [int(v) for v in query_ids]
- return input_ids, prompt_text
-
-
-def build_v2t_chat_prompt(question: str) -> str:
- return (
- f"<|start_header_id|>user<|end_header_id|>\n{question}<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n"
- )
-
-
-def build_i2t_chat_prompt(question: str) -> str:
- return (
- f"<|start_header_id|>user<|end_header_id|>\n{question}<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n"
- )
-
-
-def make_mmu_prompt(
- *,
- task: str,
- text: str,
- tokenizer: Any,
- uni_prompting: Any,
- image_tokens: torch.Tensor | None,
- audio_tokens: torch.Tensor | None,
- video_tokens: torch.Tensor | None,
- image_token_offset: int,
- speech_token_offset: int,
-) -> tuple[list[int], list[int]]:
- query_ids = _tokenize_chat_query(tokenizer, text)
-
- if task == "i2t":
- token_ids, _ = build_i2t_input_ids(
- image_token_ids=image_tokens,
- tokenizer=tokenizer,
- uni_prompting=uni_prompting,
- question=text,
- image_token_offset=int(image_token_offset),
- )
- token_ids = [int(v) for v in token_ids]
- return token_ids, [1] * len(token_ids)
-
- if task == "v2t":
- token_ids, _ = build_v2t_input_ids(
- video_token_ids=video_tokens,
- tokenizer=tokenizer,
- uni_prompting=uni_prompting,
- question=text,
- image_token_offset=int(image_token_offset),
- )
- token_ids = [int(v) for v in token_ids]
- return token_ids, [1] * len(token_ids)
-
- if task == "s2t":
- if audio_tokens is None:
- raise ValueError("s2t requires audio tokens")
- audio_ids = _to_1d_int_list(audio_tokens.long() + int(speech_token_offset))
- token_ids = [
- _get_special_token_id(uni_prompting, "<|s2t|>"),
- _get_special_token_id(uni_prompting, "<|soa|>"),
- *audio_ids,
- _get_special_token_id(uni_prompting, "<|eoa|>"),
- *query_ids,
- ]
- return token_ids, [1] * len(token_ids)
-
- raise ValueError(f"Unsupported task for validation-style MMU prompt: {task}")
-
-
-def iter_mm_outputs(outputs: list[Any]):
- for omni_out in outputs:
- req_out = omni_out
- req_list = req_out if isinstance(req_out, list) else [req_out]
- for item in req_list:
- if item is None:
- continue
- mm_out = getattr(item, "multimodal_output", None) or {}
- if mm_out:
- yield mm_out
- completions = getattr(item, "outputs", None) or []
- for completion in completions:
- c_mm_out = getattr(completion, "multimodal_output", None) or {}
- if c_mm_out:
- yield c_mm_out
- omni_mm = getattr(omni_out, "multimodal_output", None) or {}
- if omni_mm:
- yield omni_mm
-
-
-def _to_token_list(value: Any) -> list[int]:
- if value is None:
- return []
- if hasattr(value, "detach"):
- value = value.detach()
- if hasattr(value, "cpu"):
- value = value.cpu()
- if hasattr(value, "flatten"):
- value = value.flatten().tolist()
- if isinstance(value, tuple):
- value = list(value)
- if not isinstance(value, list):
- return []
- out: list[int] = []
- for token in value:
- if isinstance(token, bool):
- continue
- try:
- out.append(int(token))
- except Exception:
- continue
- return out
-
-
-def extract_text_output(outputs: list[Any], tokenizer: Any) -> str:
- for mm_out in iter_mm_outputs(outputs):
- text = mm_out.get("text")
- if isinstance(text, list) and text:
- text = text[-1]
- if isinstance(text, str) and text.strip():
- return text.strip()
- for key in ("text_tokens", "token_ids"):
- token_ids = _to_token_list(mm_out.get(key))
- if not token_ids:
- continue
- decoded = tokenizer.decode(token_ids, skip_special_tokens=True)
- if isinstance(decoded, str) and decoded.strip():
- return decoded.strip()
- return ""
-
-
-def extract_image_output(outputs: list[Any]) -> torch.Tensor | None:
- for mm_out in iter_mm_outputs(outputs):
- image = mm_out.get("image")
- if isinstance(image, list) and image:
- image = image[-1]
- if isinstance(image, torch.Tensor):
- return image
- return None
-
-
-def tensor_to_pil_image(image: torch.Tensor) -> Image.Image:
- arr = image.detach().cpu().numpy()
- if arr.ndim == 4:
- arr = arr[0]
- if arr.ndim == 3 and arr.shape[0] in (1, 3, 4):
- arr = np.transpose(arr, (1, 2, 0))
- if arr.dtype != np.uint8:
- arr = arr.astype(np.float32)
- if arr.max() <= 1.0:
- arr = arr * 255.0
- arr = np.clip(arr, 0.0, 255.0).astype(np.uint8)
- if arr.ndim == 3 and arr.shape[-1] == 1:
- arr = arr[..., 0]
- return Image.fromarray(arr)
-
-
-def extract_audio_output(outputs: list[Any]) -> tuple[np.ndarray, int] | None:
- for mm_out in iter_mm_outputs(outputs):
- audio = mm_out.get("audio")
- if audio is None:
- audio = mm_out.get("speech")
- if audio is None:
- continue
-
- def _to_wav_array(value: Any) -> np.ndarray:
- if isinstance(value, torch.Tensor):
- return value.detach().cpu().numpy().reshape(-1).astype(np.float32)
- return np.asarray(value).reshape(-1).astype(np.float32)
-
- if isinstance(audio, list):
- chunks = [_to_wav_array(chunk) for chunk in audio]
- wav = np.concatenate(chunks, axis=0) if chunks else np.zeros((0,), dtype=np.float32)
- else:
- wav = _to_wav_array(audio)
- sr = mm_out.get("sr", 24000)
- if hasattr(sr, "item"):
- try:
- sr = int(sr.item())
- except Exception:
- sr = 24000
- elif isinstance(sr, list):
- sr = int(sr[0]) if sr else 24000
- else:
- sr = int(sr)
- return wav, sr
- return None
-
-
-def save_audio_wav(path: Path, wav: np.ndarray, sr: int) -> None:
- try:
- import soundfile as sf
-
- sf.write(str(path), wav, int(sr), format="WAV")
- except Exception:
- from scipy.io import wavfile
-
- wav_i16 = np.clip(wav, -1.0, 1.0)
- wav_i16 = (wav_i16 * 32767.0).astype(np.int16)
- wavfile.write(str(path), int(sr), wav_i16)
-
-
-def parse_args(repo_root: Path) -> argparse.Namespace:
- parser = argparse.ArgumentParser(description="Dynin-Omni unified offline end2end example.")
- parser.add_argument("--task", type=str, required=True, choices=TASK_CHOICES)
- parser.add_argument("--model", type=str, required=True, help="HF repo id or local model directory.")
- parser.add_argument(
- "--deploy-config",
- type=str,
- default=str(repo_root / "vllm_omni/deploy/dynin_omni.yaml"),
- help="Path to deploy config yaml.",
- )
- parser.add_argument(
- "--dynin-config-path",
- type=str,
- default="",
- help="Path to DYNIN config yaml (passed through additional_information).",
- )
- parser.add_argument(
- "--model-cache-dir",
- type=str,
- default="/tmp/dynin_localized_models",
- help="Cache directory used when --model is HF repo id.",
- )
- parser.add_argument(
- "--localize-model",
- action=argparse.BooleanOptionalAction,
- default=True,
- help="If true and --model is HF repo id, snapshot it under --model-cache-dir.",
- )
- parser.add_argument("--text", type=str, default="", help="Prompt/edit/question text.")
- parser.add_argument("--instruction", type=str, default="", help="Optional extra instruction.")
- parser.add_argument("--raw-prompt", action=argparse.BooleanOptionalAction, default=False)
- parser.add_argument("--image", type=str, default="", help="Input image path for i2i/i2t.")
- parser.add_argument("--audio", type=str, default="", help="Input audio path for s2t.")
- parser.add_argument("--video", type=str, default="", help="Input video path for v2t.")
- parser.add_argument("--image-resolution", type=int, default=None)
- parser.add_argument("--num-frames", type=int, default=None)
- parser.add_argument(
- "--output-dir",
- type=str,
- default="",
- help="Directory for generated outputs.",
- )
- parser.add_argument("--output-prefix", type=str, default="")
- parser.add_argument("--seed", type=int, default=0)
- parser.add_argument("--dtype", type=str, default="auto")
- parser.add_argument("--max-tokens-per-stage", type=int, default=1)
-
- parser.add_argument("--runtime-task", type=str, default="", help="Override runtime task key.")
- parser.add_argument("--prompting-task", type=str, default="", help="Override prompting task key.")
- parser.add_argument("--detok-id", type=int, default=None, help="Override detok id.")
-
- parser.add_argument("--prompt-max-text-len", type=int, default=None)
- parser.add_argument("--cond-dropout-prob", type=float, default=0.0)
- parser.add_argument("--max-new-tokens", type=int, default=None)
- parser.add_argument("--steps", type=int, default=None)
- parser.add_argument("--block-length", type=int, default=None)
- parser.add_argument("--temperature", type=float, default=None)
- parser.add_argument("--cfg-scale", type=float, default=None)
- parser.add_argument("--remasking", type=str, default="low_confidence")
-
- parser.add_argument("--timesteps", type=int, default=None)
- parser.add_argument("--guidance-scale", type=float, default=None)
- parser.add_argument("--noise-type", type=str, default="mask")
- parser.add_argument("--noise-schedule-name", type=str, default="cosine")
- parser.add_argument("--noise-schedule-params", type=str, default="{}")
-
- parser.add_argument("--mask-token-id", type=int, default=None)
- parser.add_argument("--codebook-size", type=int, default=None)
- parser.add_argument("--audio-codebook-size", type=int, default=None)
- parser.add_argument("--image-token-count", type=int, default=None)
- parser.add_argument("--t2s-token-length", type=int, default=None)
- parser.add_argument(
- "--t2s-condition",
- type=str,
- default="",
- )
- parser.add_argument(
- "--use-train-i2i-prompt",
- action="store_true",
- help="Use i2i training prompt template (default behavior of i2i.py).",
- )
- parser.add_argument(
- "--no-use-train-i2i-prompt",
- dest="use_train_i2i_prompt",
- action="store_false",
- help="Use i2i_gen prompt template.",
- )
- parser.set_defaults(use_train_i2i_prompt=None)
-
- parser.add_argument("--tokenizer-path", type=str, default="")
- parser.add_argument("--model-local-files-only", action=argparse.BooleanOptionalAction, default=None)
- parser.add_argument("--tokenizer-local-files-only", action=argparse.BooleanOptionalAction, default=None)
-
- parser.add_argument("--vq-model-image-path", type=str, default="")
- parser.add_argument("--vq-model-image-local-files-only", action=argparse.BooleanOptionalAction, default=None)
- parser.add_argument("--vq-model-audio-path", type=str, default="")
- parser.add_argument("--vq-model-audio-local-files-only", action=argparse.BooleanOptionalAction, default=None)
-
- parser.add_argument("--disable-hf-xet", action=argparse.BooleanOptionalAction, default=True)
- return parser.parse_args()
-
-
-def main() -> None:
- repo_root = bootstrap_repo_path()
- ensure_safe_import_for_vllm()
- from vllm_omni.model_executor.models.dynin_omni.dynin_omni_common import (
- DYNIN_PROMPT_SOURCE_KEY,
- DYNIN_PROMPT_SOURCE_OFFLINE_PREBUILT,
- )
-
- args = parse_args(repo_root)
-
- if args.disable_hf_xet:
- os.environ.setdefault("HF_HUB_DISABLE_XET", "1")
-
- np.random.seed(args.seed)
- torch.manual_seed(args.seed)
-
- model_dir = ensure_local_model_dir(
- model=args.model,
- cache_dir=Path(args.model_cache_dir).expanduser(),
- localize=bool(args.localize_model),
- )
- model_source = str(model_dir)
-
- task_name = str(args.task)
- dynin_config_path = str(Path(args.dynin_config_path).expanduser())
- os.environ["DYNIN_CONFIG_PATH"] = dynin_config_path
- default_runtime_task, default_prompting_task, default_detok_id, final_modality = TASK_DEFAULT_RUNTIME[task_name]
- runtime_task = args.runtime_task.strip() or str(
- _runtime_fallback(task_name, "runtime_task", None) or default_runtime_task
- )
- prompting_task = args.prompting_task.strip() or str(
- _runtime_fallback(task_name, "prompting_task", None) or default_prompting_task
- )
- detok_id_default = _runtime_fallback(task_name, "detok_id", None)
- if detok_id_default is None:
- detok_id_default = default_detok_id
- detok_id = int(detok_id_default if args.detok_id is None else args.detok_id)
-
- output_dir_default = _runtime_fallback(task_name, "output_dir", args.output_dir)
- resolved_output_dir = str(output_dir_default or "/tmp/dynin_end2end_outputs")
-
- image_resolution_value = _runtime_fallback(
- task_name,
- "image_resolution",
- args.image_resolution,
- )
- if image_resolution_value is None:
- image_resolution_value = 336
- image_resolution = int(image_resolution_value)
-
- num_frames_value = _runtime_fallback(
- task_name,
- "num_frames",
- args.num_frames,
- )
- if num_frames_value is None:
- num_frames_value = 8
- num_frames = int(num_frames_value)
-
- prompt_max_text_len_value = _runtime_fallback(
- task_name,
- "prompt_max_text_len",
- args.prompt_max_text_len,
- )
- if prompt_max_text_len_value is None:
- prompt_max_text_len_value = 1024
- prompt_max_text_len = int(prompt_max_text_len_value)
-
- max_new_tokens_value = _runtime_fallback(
- task_name,
- "max_new_tokens",
- args.max_new_tokens,
- )
- if max_new_tokens_value is None:
- max_new_tokens_value = 256
- max_new_tokens = int(max_new_tokens_value)
-
- steps_value = _runtime_fallback(
- task_name,
- "steps",
- args.steps,
- )
- if steps_value is None:
- steps_value = 256
- steps = int(steps_value)
-
- block_length_value = _runtime_fallback(
- task_name,
- "block_length",
- args.block_length,
- )
- if block_length_value is None:
- block_length_value = 2
- block_length = int(block_length_value)
-
- temperature_value = _runtime_fallback(
- task_name,
- "temperature",
- args.temperature,
- )
- if temperature_value is None:
- temperature_value = 0.0
- temperature = float(temperature_value)
-
- cfg_scale_value = _runtime_fallback(
- task_name,
- "cfg_scale",
- args.cfg_scale,
- )
- if cfg_scale_value is None:
- cfg_scale_value = 0.0
- cfg_scale = float(cfg_scale_value)
-
- remasking = str(_runtime_fallback(task_name, "remasking", args.remasking) or "low_confidence")
-
- timesteps_value = _runtime_fallback(
- task_name,
- "timesteps",
- args.timesteps,
- )
- if timesteps_value is None:
- timesteps_value = 20
- timesteps = int(timesteps_value)
-
- guidance_scale_value = _runtime_fallback(
- task_name,
- "guidance_scale",
- args.guidance_scale,
- )
- if guidance_scale_value is None:
- guidance_scale_value = 0.0
- guidance_scale = float(guidance_scale_value)
-
- mask_token_id_value = _runtime_fallback(
- task_name,
- "mask_token_id",
- args.mask_token_id,
- )
- if mask_token_id_value is None:
- mask_token_id_value = 126336
- mask_token_id = int(mask_token_id_value)
-
- codebook_size_value = _runtime_fallback(
- task_name,
- "codebook_size",
- args.codebook_size,
- )
- if codebook_size_value is None:
- codebook_size_value = 8192
- codebook_size = int(codebook_size_value)
-
- audio_codebook_size_value = _runtime_fallback(
- task_name,
- "audio_codebook_size",
- args.audio_codebook_size,
- )
- if audio_codebook_size_value is None:
- audio_codebook_size_value = 4096
- audio_codebook_size = int(audio_codebook_size_value)
-
- image_token_count_value = _runtime_fallback(
- task_name,
- "image_token_count",
- args.image_token_count,
- )
- image_token_count = int(image_token_count_value) if image_token_count_value is not None else 0
-
- t2s_token_length_value = _runtime_fallback(
- task_name,
- "t2s_token_length",
- args.t2s_token_length,
- )
- if t2s_token_length_value is None:
- t2s_token_length_value = 383
- t2s_token_length = int(t2s_token_length_value)
-
- t2s_condition = str(
- _runtime_fallback(task_name, "t2s_condition", args.t2s_condition)
- or "gender-female_emotion-neutral_speed-normal_pitch-normal"
- )
-
- _validate_generation_args(
- task=task_name,
- max_new_tokens=max_new_tokens,
- steps=steps,
- block_length=block_length,
- )
-
- use_train_i2i_prompt = _runtime_fallback(task_name, "use_train_i2i_prompt", args.use_train_i2i_prompt)
- if use_train_i2i_prompt is None:
- use_train_i2i_prompt = bool(task_name == "i2i")
- use_train_i2i_prompt = bool(use_train_i2i_prompt)
-
- if task_name in {"i2i", "i2t"} and not args.image:
- raise ValueError(f"--task {task_name} requires --image")
- if task_name == "s2t" and not args.audio:
- raise ValueError("--task s2t requires --audio")
- if task_name == "v2t" and not args.video:
- raise ValueError("--task v2t requires --video")
-
- text = resolve_task_text(
- task_name=task_name,
- text=args.text,
- instruction=args.instruction,
- raw_prompt=bool(args.raw_prompt),
- )
-
- tokenizer_source = args.tokenizer_path.strip() or model_source
- model_local_only = resolve_local_only(
- args.model_local_files_only, model_source, default=Path(model_source).is_dir()
- )
- tokenizer_local_only = resolve_local_only(
- args.tokenizer_local_files_only,
- tokenizer_source,
- default=model_local_only,
- )
- tokenizer = load_text_tokenizer(tokenizer_source, local_files_only=tokenizer_local_only)
- text_vocab_size = int(len(tokenizer))
-
- image_tokens: torch.Tensor | None = None
- audio_tokens: torch.Tensor | None = None
- video_tokens: torch.Tensor | None = None
-
- device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
-
- vq_image_source = args.vq_model_image_path.strip() or "snu-aidas/magvitv2"
- vq_audio_source = args.vq_model_audio_path.strip() or "snu-aidas/emova_speech_tokenizer_vllm"
- vq_image_local_only = resolve_local_only(args.vq_model_image_local_files_only, vq_image_source, default=False)
- vq_audio_local_only = resolve_local_only(args.vq_model_audio_local_files_only, vq_audio_source, default=False)
-
- if task_name in {"i2i", "i2t", "v2t"}:
- vq_image = load_vq_image_encoder(vq_image_source, vq_image_local_only, device)
- if task_name in {"i2i", "i2t"}:
- image_tokens = encode_image_tokens(
- Path(args.image).expanduser().resolve(),
- vq_model=vq_image,
- device=device,
- resolution=int(image_resolution),
- )
- if task_name == "v2t":
- video_tokens = encode_video_tokens(
- Path(args.video).expanduser().resolve(),
- vq_model=vq_image,
- device=device,
- resolution=int(image_resolution),
- num_frames=int(num_frames),
- )
- if hasattr(vq_image, "cpu"):
- vq_image = vq_image.cpu()
-
- if task_name == "s2t":
- vq_audio = load_vq_audio_encoder(vq_audio_source, vq_audio_local_only, device)
- audio_tokens = encode_audio_tokens(Path(args.audio).expanduser().resolve(), vq_audio)
- if hasattr(vq_audio, "cpu"):
- vq_audio = vq_audio.cpu()
-
- noise_schedule_params: dict[str, Any] = {}
- try:
- parsed = json.loads(args.noise_schedule_params)
- if isinstance(parsed, dict):
- noise_schedule_params = {str(k): v for k, v in parsed.items()}
- except Exception:
- noise_schedule_params = {}
-
- image_token_count = int(image_token_count)
- if image_token_count <= 0:
- if image_tokens is not None:
- image_token_count = int(image_tokens.numel())
- else:
- base_res = int(image_resolution)
- image_token_count = max(1, (base_res // 16) ** 2)
-
- uncond_input_ids: list[int] | None = None
- uncond_attention_mask: list[int] | None = None
- if task_name == "t2t":
- messages = [{"role": "user", "content": text}]
- if getattr(tokenizer, "chat_template", None):
- prompt_text = tokenizer.apply_chat_template(messages, add_generation_prompt=True, tokenize=False)
- encoded = tokenizer(prompt_text, return_tensors="pt", add_special_tokens=False)
- else:
- encoded = tokenizer(text, return_tensors="pt", add_special_tokens=True)
- prompt_token_ids = _to_1d_int_list(encoded["input_ids"])
- prompt_attention_mask = _to_1d_int_list(encoded.get("attention_mask"))
- if not prompt_attention_mask:
- prompt_attention_mask = [1] * len(prompt_token_ids)
- else:
- max_audio_len_for_prompt = int(max(t2s_token_length, 512))
- if audio_tokens is not None:
- max_audio_len_for_prompt = max(max_audio_len_for_prompt, int(audio_tokens.numel()))
- max_audio_len_short_for_prompt = max(256, max_audio_len_for_prompt // 2)
-
- uni_prompting = load_universal_prompting(
- tokenizer=tokenizer,
- tokenizer_source=tokenizer_source,
- max_text_len=int(prompt_max_text_len),
- cond_dropout_prob=float(args.cond_dropout_prob),
- local_files_only=bool(tokenizer_local_only),
- max_audio_len=int(max_audio_len_for_prompt),
- max_audio_len_short=int(max_audio_len_short_for_prompt),
- )
- prompting_text_vocab_size = int(len(uni_prompting.text_tokenizer))
-
- is_mmu_task = task_name in {"i2t", "s2t", "v2t"} and not args.prompting_task.strip()
- if is_mmu_task:
- prompt_token_ids, prompt_attention_mask = make_mmu_prompt(
- task=task_name,
- text=text,
- tokenizer=uni_prompting.text_tokenizer,
- uni_prompting=uni_prompting,
- image_tokens=image_tokens,
- audio_tokens=audio_tokens,
- video_tokens=video_tokens,
- image_token_offset=prompting_text_vocab_size,
- speech_token_offset=prompting_text_vocab_size + int(codebook_size),
- )
- else:
- prompt_payload, prompting_task = make_prompt_payload(
- task=task_name,
- text=text,
- image_tokens=image_tokens,
- audio_tokens=audio_tokens,
- video_tokens=video_tokens,
- image_placeholder_tokens=image_token_count,
- audio_placeholder_tokens=int(t2s_token_length),
- image_token_offset=text_vocab_size,
- speech_token_offset=text_vocab_size + int(codebook_size),
- mask_token_id=int(mask_token_id),
- use_train_i2i_prompt=use_train_i2i_prompt,
- )
- if args.prompting_task.strip():
- prompting_task = args.prompting_task.strip()
-
- prompt_token_ids, prompt_attention_mask = _run_uni_prompting(
- uni_prompting,
- prompt_payload,
- prompting_task,
- )
-
- if task_name in {"i2t", "s2t", "v2t"}:
- prompt_attention_mask = [1] * len(prompt_token_ids)
- if not prompt_attention_mask:
- prompt_attention_mask = [1] * len(prompt_token_ids)
-
- if task_name in {"t2i", "i2i"} and guidance_scale > 0:
- uncond_payload, uncond_prompting_task = make_prompt_payload(
- task=task_name,
- text="",
- image_tokens=image_tokens,
- audio_tokens=audio_tokens,
- video_tokens=video_tokens,
- image_placeholder_tokens=image_token_count,
- audio_placeholder_tokens=int(t2s_token_length),
- image_token_offset=text_vocab_size,
- speech_token_offset=text_vocab_size + int(codebook_size),
- mask_token_id=int(mask_token_id),
- use_train_i2i_prompt=use_train_i2i_prompt,
- )
- uncond_input_ids, uncond_attention_mask = _run_uni_prompting(
- uni_prompting,
- uncond_payload,
- args.prompting_task.strip() or uncond_prompting_task,
- )
- if not uncond_attention_mask:
- uncond_attention_mask = [1] * len(uncond_input_ids)
-
- runtime_info: dict[str, Any] = {
- "task": [runtime_task],
- "detok_id": [int(detok_id)],
- DYNIN_PROMPT_SOURCE_KEY: [DYNIN_PROMPT_SOURCE_OFFLINE_PREBUILT],
- "dynin_config_path": [str(dynin_config_path)],
- "attention_mask": [prompt_attention_mask],
- "prompt_max_text_len": [int(prompt_max_text_len)],
- "prompting_max_text_len": [int(prompt_max_text_len)],
- "cond_dropout_prob": [float(args.cond_dropout_prob)],
- "prompting_cond_dropout_prob": [float(args.cond_dropout_prob)],
- "tokenizer_path": [str(tokenizer_source)],
- "text_vocab_size": [int(text_vocab_size)],
- "model_local_files_only": [bool(model_local_only)],
- "max_new_tokens": [int(max_new_tokens)],
- "steps": [int(steps)],
- "block_length": [int(block_length)],
- "temperature": [float(temperature)],
- "cfg_scale": [float(cfg_scale)],
- "remasking": [str(remasking)],
- "mask_id": [int(mask_token_id)],
- "mask_token_id": [int(mask_token_id)],
- "codebook_size": [int(codebook_size)],
- "audio_codebook_size": [int(audio_codebook_size)],
- "timesteps": [int(timesteps)],
- "guidance_scale": [float(guidance_scale)],
- "noise_type": [str(args.noise_type)],
- "noise_schedule_name": [str(args.noise_schedule_name)],
- "noise_schedule_params": [noise_schedule_params],
- "seq_len": [int(image_token_count)],
- "condition": [str(t2s_condition)],
- "vq_model_image_path": [str(vq_image_source)],
- "vq_model_image_local_files_only": [bool(vq_image_local_only)],
- "vq_model_audio_path": [str(vq_audio_source)],
- "vq_model_audio_local_files_only": [bool(vq_audio_local_only)],
- }
-
- if task_name in {"t2t", "i2t", "s2t", "v2t"}:
- runtime_info["prompt_length"] = [int(len(prompt_token_ids))]
- if uncond_input_ids is not None:
- runtime_info["uncond_input_ids"] = [uncond_input_ids]
- if uncond_attention_mask is not None:
- runtime_info["uncond_attention_mask"] = [uncond_attention_mask]
-
- if task_name == "t2s":
- runtime_info["max_new_tokens"] = [int(t2s_token_length)]
-
- prompt = {
- "prompt_token_ids": [int(v) for v in prompt_token_ids],
- "additional_information": runtime_info,
- "modalities": [final_modality],
- }
-
- from vllm import SamplingParams
-
- from vllm_omni.entrypoints.omni import Omni
-
- deploy_config_path = str(Path(args.deploy_config).expanduser())
- omni = Omni(model=model_source, deploy_config=deploy_config_path, dtype=args.dtype)
- sampling_params_list = [
- SamplingParams(max_tokens=int(args.max_tokens_per_stage), temperature=0.0, top_p=1.0, detokenize=False)
- for _ in range(omni.num_stages)
- ]
-
- try:
- outputs = list(omni.generate(prompt, sampling_params_list))
- finally:
- omni.close()
-
- out_dir = Path(resolved_output_dir).expanduser()
- out_dir.mkdir(parents=True, exist_ok=True)
- stamp = time.strftime("%Y%m%d_%H%M%S")
- prefix = args.output_prefix.strip() or f"{task_name}_{stamp}"
-
- if final_modality == "text":
- text_out = extract_text_output(outputs, tokenizer=tokenizer)
- if not text_out:
- raise RuntimeError("No text output found.")
- out_path = out_dir / f"{prefix}.txt"
- out_path.write_text(text_out + "\n", encoding="utf-8")
- print(f"[end2end] text saved: {out_path}")
- print(text_out)
- return
-
- if final_modality == "image":
- image_out = extract_image_output(outputs)
- if image_out is None:
- raise RuntimeError("No image output found.")
- pil = tensor_to_pil_image(image_out)
- out_path = out_dir / f"{prefix}.png"
- pil.save(out_path)
- print(f"[end2end] image saved: {out_path}")
- return
-
- if final_modality == "audio":
- audio_out = extract_audio_output(outputs)
- if audio_out is None:
- raise RuntimeError("No audio output found.")
- wav, sr = audio_out
- out_path = out_dir / f"{prefix}.wav"
- save_audio_wav(out_path, wav, sr)
- print(f"[end2end] audio saved: {out_path} (sr={sr}, samples={wav.shape[0]})")
- return
-
- raise RuntimeError(f"Unsupported final modality: {final_modality}")
-
-
-if __name__ == "__main__":
- main()
diff --git a/examples/offline_inference/hunyuan_image3/README.md b/examples/offline_inference/hunyuan_image3/README.md
deleted file mode 100644
index b4d5af2c7..000000000
--- a/examples/offline_inference/hunyuan_image3/README.md
+++ /dev/null
@@ -1,154 +0,0 @@
-# HunyuanImage-3.0-Instruct
-
-This example runs HunyuanImage-3.0-Instruct offline with the unified deploy
-YAMLs under `vllm_omni/deploy/`.
-
-## Deploy Configs
-
-| File | Topology | Default use |
-| :--- | :--- | :--- |
-| `vllm_omni/deploy/hunyuan_image_3_moe.yaml` | AR + DiT | Default for `text2img` and `img2img`. |
-| `vllm_omni/deploy/hunyuan_image3_ar.yaml` | AR only | Default for `img2text` and `text2text`. |
-| `vllm_omni/deploy/hunyuan_image3_dit.yaml` | DiT only | Standalone diffusion stage. Pass it explicitly with `--deploy-config`. |
-
-The example chooses a deploy config automatically when `--deploy-config` is
-omitted:
-
-| `--modality` | `mode` passed to Omni | Default deploy |
-| :--- | :--- | :--- |
-| `text2img` | `text-to-image` | `hunyuan_image_3_moe.yaml` |
-| `img2img` | `image-editing` | `hunyuan_image_3_moe.yaml` |
-| `img2text` | `image-to-text` | `hunyuan_image3_ar.yaml` |
-| `text2text` | `text-to-text` | `hunyuan_image3_ar.yaml` |
-
-`--modality` is an offline example convenience flag. It maps to the internal
-`mode` argument passed to `Omni(...)` by this script. HunyuanImage3 uses
-separate deploy YAMLs for AR + DiT, AR-only, and DiT-only topologies, so the
-stage topology is selected by the deploy file rather than by YAML mode
-overrides.
-
-Online serving does not expose a `--modality` flag or accept `mode` as an API
-request field. Choose the deploy topology when starting the server with
-`--deploy-config`, then use the OpenAI-compatible endpoint and request shape for
-the scenario. The `modalities` request field is used by the chat completions
-path; the image endpoints infer the image task from the endpoint and payload.
-
-| Online scenario | Server deploy | Request |
-| :--- | :--- | :--- |
-| Text to image | `--deploy-config vllm_omni/deploy/hunyuan_image_3_moe.yaml` | `POST /v1/images/generations`, or `POST /v1/chat/completions` with `"modalities": ["image"]`. |
-| Image editing | `--deploy-config vllm_omni/deploy/hunyuan_image_3_moe.yaml` | `POST /v1/images/edits`. |
-| Image/text to text | `--deploy-config vllm_omni/deploy/hunyuan_image3_ar.yaml` | `POST /v1/chat/completions` for text output, for example with `"modalities": ["text"]`. |
-| DiT-only image generation | `--deploy-config vllm_omni/deploy/hunyuan_image3_dit.yaml` | `POST /v1/images/generations`. |
-
-## Run Examples
-
-Text to image, using the default AR + DiT deploy:
-
-```bash
-python examples/offline_inference/hunyuan_image3/end2end.py \
- --model tencent/HunyuanImage-3.0-Instruct \
- --modality text2img \
- --prompts "A cute cat sitting on a windowsill watching the sunset"
-```
-
-Image editing, using the default AR + DiT deploy:
-
-```bash
-python examples/offline_inference/hunyuan_image3/end2end.py \
- --model tencent/HunyuanImage-3.0-Instruct \
- --modality img2img \
- --image-path /path/to/image.png \
- --prompts "Make the petals neon pink"
-```
-
-Image to text, using the AR-only deploy:
-
-```bash
-python examples/offline_inference/hunyuan_image3/end2end.py \
- --model tencent/HunyuanImage-3.0-Instruct \
- --modality img2text \
- --image-path /path/to/image.jpg \
- --prompts "Describe the content of the picture."
-```
-
-Text to text, using the AR-only deploy:
-
-```bash
-python examples/offline_inference/hunyuan_image3/end2end.py \
- --model tencent/HunyuanImage-3.0-Instruct \
- --modality text2text \
- --prompts "What is the capital of France?"
-```
-
-Standalone DiT, using the DiT-only deploy explicitly:
-
-```bash
-python examples/offline_inference/hunyuan_image3/end2end.py \
- --model tencent/HunyuanImage-3.0-Instruct \
- --modality text2img \
- --deploy-config vllm_omni/deploy/hunyuan_image3_dit.yaml \
- --prompts "A cinematic portrait of an astronaut in a greenhouse"
-```
-
-Override the default full AR + DiT deploy explicitly:
-
-```bash
-python examples/offline_inference/hunyuan_image3/end2end.py \
- --model tencent/HunyuanImage-3.0-Instruct \
- --modality text2img \
- --deploy-config vllm_omni/deploy/hunyuan_image_3_moe.yaml \
- --prompts "A cute cat"
-```
-
-## Additional Config
-
-You can pass diffusion worker `additional_config` from the offline example as a JSON object.
-This maps to the upstream vLLM `VllmConfig.additional_config` platform extension field:
-https://docs.vllm.ai/en/stable/api/vllm/config/#vllm.config.VllmConfig.additional_config
-
-```bash
-python end2end.py --modality text2img \
- --prompts "A cute cat" \
- --additional-config '{"torchair_graph_config":{"enabled":true}}'
-```
-
-
-## Key Arguments
-
-| Argument | Description |
-| :--- | :--- |
-| `--deploy-config` | Optional path for unified deploy YAML overrides. |
-| `--additional-config` | JSON object forwarded to diffusion worker `additional_config`. |
-| `--modality` | Offline-only convenience flag. One of `text2img`, `img2img`, `img2text`, `text2text`. It selects prompt formatting, internal `mode`, and default deploy config for this script. Online serving uses `--deploy-config` plus the endpoint and, for chat completions, request `modalities` instead. |
-| `--steps` | Number of diffusion inference steps for image generation. |
-| `--guidance-scale` | Classifier-free guidance scale for image generation. |
-| `--height`, `--width` | Output image size for `text2img`. |
-| `--bot-task` | Override prompt mode. `none`, `think`, `recaption`, `think_recaption`, or `vanilla`. |
-| `--sys-type` | Override the system prompt type, for example `en_unified` or `en_vanilla`. |
-| `--vae-use-tiling` | Enable VAE tiling for memory reduction. |
-
-## Notes
-
-- `hunyuan_image3_ar.yaml` is a 4-card AR-only text/comprehension deploy.
-- `hunyuan_image3_dit.yaml` is a single-stage DiT deploy with `stage_id: 0`.
-- Platform-specific overrides are included in the unified deploy YAMLs.
-
-## Prompt Format
-
-HunyuanImage-3.0-Instruct uses an instruct chat template:
-
-```text
-<|startoftext|>{system_prompt}
-
-User: { ?}{user_prompt}
-
-Assistant: {trigger_tag?}
-```
-
-- ` `: Placeholder for each input image (single token; expanded by the multimodal pipeline).
-- Trigger tags: `` for CoT and `` for recaptioning, placed after `Assistant: `.
-- System prompt: Auto-selected from `task` and `bot_task`.
-- `bot_task='vanilla'` with `task='t2i'` uses the bare pretrain template.
-
-The shared `vllm_omni.diffusion.models.hunyuan_image3.prompt_utils.build_prompt_tokens()`
-helper handles segment-by-segment tokenization and matches HF `apply_chat_template`.
diff --git a/examples/offline_inference/hunyuan_image3/end2end.py b/examples/offline_inference/hunyuan_image3/end2end.py
deleted file mode 100644
index fbe4289be..000000000
--- a/examples/offline_inference/hunyuan_image3/end2end.py
+++ /dev/null
@@ -1,321 +0,0 @@
-"""
-HunyuanImage-3.0-Instruct unified end-to-end inference script.
-"""
-
-import argparse
-import json
-import os
-from pathlib import Path
-
-from vllm_omni.diffusion.models.hunyuan_image3.prompt_utils import (
- MAX_IMAGES_PER_REQUEST,
- build_prompt_tokens,
- resolve_stop_token_ids,
- resolve_sys_type,
-)
-from vllm_omni.entrypoints.omni import Omni
-from vllm_omni.inputs.data import OmniPromptType
-
-_REPO_ROOT = Path(__file__).resolve().parents[3]
-_DEFAULT_DEPLOY_CONFIG = str(_REPO_ROOT / "vllm_omni" / "deploy" / "hunyuan_image_3_moe.yaml")
-_DEFAULT_AR_DEPLOY_CONFIG = str(_REPO_ROOT / "vllm_omni" / "deploy" / "hunyuan_image3_ar.yaml")
-
-_MODALITY_TASK_MAP: dict[str, tuple[str, str | None]] = {
- "text2img": ("t2i", "think"),
- "img2img": ("it2i", "think"),
- "img2text": ("i2t", None),
- "text2text": ("t2t", None),
-}
-
-_MODALITY_DEFAULT_DEPLOY_CONFIG = {
- "text2img": _DEFAULT_DEPLOY_CONFIG,
- "img2img": _DEFAULT_DEPLOY_CONFIG,
- "img2text": _DEFAULT_AR_DEPLOY_CONFIG,
- "text2text": _DEFAULT_AR_DEPLOY_CONFIG,
-}
-
-_MODALITY_MODE = {
- "text2img": "text-to-image",
- "img2img": "image-editing",
- "img2text": "image-to-text",
- "text2text": "text-to-text",
-}
-
-
-def parse_args():
- parser = argparse.ArgumentParser(description="HunyuanImage-3.0-Instruct end-to-end inference.")
- parser.add_argument("--model", default="tencent/HunyuanImage-3.0-Instruct", help="Model name or local path.")
- parser.add_argument(
- "--modality",
- default="text2img",
- choices=list(_MODALITY_TASK_MAP),
- )
- parser.add_argument("--prompts", nargs="+", default=None, help="Input text prompts.")
- parser.add_argument(
- "--image-path",
- type=str,
- default=None,
- help="Input image path(s) for img2img/img2text. Comma-separated for multi-image (up to 3).",
- )
- parser.add_argument("--output", type=str, default=".", help="Output directory to save results.")
- parser.add_argument("--steps", type=int, default=50, help="Number of inference steps.")
- parser.add_argument("--guidance-scale", type=float, default=5.0, help="Classifier-free guidance scale.")
- parser.add_argument("--seed", type=int, default=42, help="Random seed.")
- parser.add_argument("--height", type=int, default=None, help="Output image height.")
- parser.add_argument("--width", type=int, default=None, help="Output image width.")
- parser.add_argument("--vae-use-tiling", action="store_true", help="Enable VAE tiling.")
- parser.add_argument(
- "--bot-task",
- type=str,
- default=None,
- choices=["none", "think", "recaption", "think_recaption", "vanilla"],
- help="Override prompt mode. Default: auto from --modality.",
- )
- parser.add_argument("--sys-type", type=str, default=None, help="Override system prompt type.")
- parser.add_argument("--deploy-config", type=str, default=None, help="Custom deploy YAML path.")
- parser.add_argument(
- "--stream",
- action="store_true",
- default=False,
- help="Enable streaming CoT display; print AR text token-by-token in real time.",
- )
- parser.add_argument("--log-stats", action="store_true", default=False)
- parser.add_argument("--init-timeout", type=int, default=300, help="Initialization timeout in seconds.")
- parser.add_argument("--enforce-eager", action="store_true", help="Disable torch.compile.")
- parser.add_argument(
- "--diffusion-kv-cache-dtype",
- type=str,
- default=None,
- help="Diffusion attention KV cache dtype, for example 'fp8'. Separate from vLLM --kv-cache-dtype.",
- )
- parser.add_argument(
- "--diffusion-kv-cache-skip-steps",
- type=str,
- default=None,
- help="Denoising step selector to keep diffusion KV cache in native dtype, for example '0,1,4-6'.",
- )
- parser.add_argument(
- "--diffusion-kv-cache-skip-layers",
- type=str,
- default=None,
- help="Transformer layer selector to keep diffusion KV cache in native dtype, for example '0-2,10'.",
- )
- parser.add_argument(
- "--additional-config",
- type=str,
- default=None,
- help=(
- "JSON object forwarded to Omni/additional_config, for example "
- '\'{"torchair_graph_config":{"enabled":true}}\'. Different platforms may support different '
- "configs. Make sure the configs are valid for the platform you are using. "
- "Contents must be hashable."
- ),
- )
-
- return parser.parse_args()
-
-
-def parse_additional_config(raw_value: str | None) -> dict | None:
- """Parse a JSON string into an additional_config mapping."""
- if raw_value is None:
- return None
-
- try:
- additional_config = json.loads(raw_value)
- except json.JSONDecodeError as exc:
- raise ValueError(f"Invalid --additional-config JSON: {exc}") from exc
-
- if additional_config is None:
- return None
- if not isinstance(additional_config, dict):
- raise ValueError(f"--additional-config must decode to a JSON object, got {type(additional_config).__name__}")
- return additional_config
-
-
-def main():
- args = parse_args()
- os.makedirs(args.output, exist_ok=True)
- additional_config = parse_additional_config(args.additional_config)
-
- task, default_bot_task = _MODALITY_TASK_MAP[args.modality]
- if args.bot_task is None:
- bot_task: str | None = default_bot_task
- elif args.bot_task == "none":
- bot_task = None
- else:
- bot_task = args.bot_task
-
- deploy_config = args.deploy_config or _MODALITY_DEFAULT_DEPLOY_CONFIG[args.modality]
-
- omni_kwargs = {
- "model": args.model,
- "vae_use_tiling": args.vae_use_tiling,
- "log_stats": args.log_stats,
- "init_timeout": args.init_timeout,
- "enforce_eager": args.enforce_eager,
- "mode": _MODALITY_MODE[args.modality],
- "diffusion_kv_cache_dtype": args.diffusion_kv_cache_dtype,
- "diffusion_kv_cache_skip_steps": args.diffusion_kv_cache_skip_steps,
- "diffusion_kv_cache_skip_layers": args.diffusion_kv_cache_skip_layers,
- }
-
- if additional_config is not None:
- omni_kwargs["additional_config"] = additional_config
- omni_kwargs["deploy_config"] = deploy_config
-
- omni = Omni(**omni_kwargs)
-
- prompts = args.prompts or ["A cute cat"]
- input_images: list = []
- if args.modality in ("img2img", "img2text"):
- if not args.image_path:
- raise ValueError(f"--image-path required for {args.modality}, got: {args.image_path}")
- from PIL import Image
-
- image_paths = [p.strip() for p in args.image_path.split(",") if p.strip()]
- if len(image_paths) > MAX_IMAGES_PER_REQUEST:
- raise ValueError(
- f"--image-path accepts at most {MAX_IMAGES_PER_REQUEST} images for "
- f"HunyuanImage-3.0 IT2I, got {len(image_paths)}: {args.image_path}"
- )
- for image_path in image_paths:
- if not os.path.exists(image_path):
- raise ValueError(f"Image path does not exist: {image_path}")
- input_images.append(Image.open(image_path).convert("RGB"))
- if not input_images:
- raise ValueError(f"--image-path produced no usable paths: {args.image_path!r}")
-
- from transformers import AutoTokenizer
-
- tokenizer = AutoTokenizer.from_pretrained(args.model, trust_remote_code=True)
- mm_image_payload = (input_images[0] if len(input_images) == 1 else input_images) if input_images else None
-
- formatted_prompts: list[OmniPromptType] = []
- for prompt in prompts:
- build_kwargs: dict = {"task": task, "bot_task": bot_task, "sys_type": args.sys_type}
- if input_images:
- build_kwargs["num_images"] = len(input_images)
- result = build_prompt_tokens(prompt, tokenizer, **build_kwargs)
- token_ids = result.token_ids
- effective_sys_type = args.sys_type or resolve_sys_type(bot_task)
-
- prompt_dict: dict = {
- "prompt_token_ids": token_ids,
- "prompt": prompt,
- "use_system_prompt": effective_sys_type,
- }
- if args.modality == "text2img":
- prompt_dict["modalities"] = ["image"]
- elif args.modality == "img2img":
- prompt_dict["modalities"] = ["image"]
- prompt_dict["multi_modal_data"] = {"image": mm_image_payload}
- prompt_dict["height"] = input_images[0].height
- prompt_dict["width"] = input_images[0].width
- elif args.modality == "img2text":
- prompt_dict["modalities"] = ["text"]
- prompt_dict["multi_modal_data"] = {"image": mm_image_payload}
- else:
- prompt_dict["modalities"] = ["text"]
- formatted_prompts.append(prompt_dict)
-
- from vllm.sampling_params import RequestOutputKind
-
- params_list = list(omni.default_sampling_params_list)
-
- from vllm_omni.inputs.data import OmniDiffusionSamplingParams
-
- if (args.height is None) != (args.width is None):
- raise ValueError("--height and --width must both be specified or both omitted.")
- user_specified_size = args.height is not None and args.width is not None
- if args.modality in ("img2text", "text2text"):
- ar_image_size = "auto"
- elif user_specified_size:
- ar_image_size = f"{args.width}x{args.height}"
- else:
- ar_image_size = None
- ar_stop_token_ids = resolve_stop_token_ids(
- task=task, bot_task=bot_task, tokenizer=tokenizer, image_size=ar_image_size
- )
- print(
- f"[AR Config] task={task}, bot_task={bot_task}, image_size={ar_image_size}, stop_token_ids={ar_stop_token_ids}"
- )
- for sp in params_list:
- if isinstance(sp, OmniDiffusionSamplingParams):
- sp.num_inference_steps = args.steps
- sp.guidance_scale = args.guidance_scale
- sp.guidance_scale_provided = True
- if args.seed is not None:
- sp.seed = args.seed
- if args.modality == "text2img":
- sp.height = args.height
- sp.width = args.width
- elif hasattr(sp, "stop_token_ids"):
- sp.stop_token_ids = ar_stop_token_ids
- # When --stream is set, request DELTA output from the AR stage
- # so we can display CoT text token-by-token in real time.
- if args.stream and hasattr(sp, "output_kind"):
- sp.output_kind = RequestOutputKind.DELTA
-
- print(f"\n{'=' * 60}")
- print("HunyuanImage-3.0 Generation Configuration:")
- print(f" Model: {args.model}")
- print(f" Modality: {args.modality}")
- print(f" Prompt task: {task}")
- print(f" Bot task: {bot_task}")
- print(f" Deploy config: {deploy_config}")
- print(f" Num stages: {omni.num_stages}")
- if args.modality in ("text2img", "img2img"):
- print(f" Inference steps: {args.steps}")
- print(f" Guidance scale: {args.guidance_scale}")
- print(f" Seed: {args.seed}")
- print(f" diffusion_kv_cache_dtype: {args.diffusion_kv_cache_dtype}")
- print(f" diffusion_kv_cache_skip_steps: {args.diffusion_kv_cache_skip_steps}")
- print(f" diffusion_kv_cache_skip_layers: {args.diffusion_kv_cache_skip_layers}")
- if args.modality == "text2img":
- print(f" Output size: {args.width}x{args.height}")
- if args.image_path:
- print(f" Input image: {args.image_path}")
- if additional_config is not None:
- print(f" Additional config: {additional_config}")
- print(f" Prompts: {prompts}")
- print(f"{'=' * 60}\n")
-
- # When --stream is set, print AR CoT text token-by-token in real time.
- # Otherwise, collect and print the full AR text once when stage 0 finishes.
- omni_outputs = omni.generate(
- prompts=formatted_prompts,
- sampling_params_list=params_list,
- py_generator=True,
- use_tqdm=False,
- )
- img_idx = 0
- for req_output in omni_outputs:
- ro = req_output
- stage_id = getattr(req_output, "stage_id", None)
-
- # AR stage text — each CompletionOutput.text is already a delta when
- # output_kind=DELTA, so we can print it directly (matching the pattern
- # in serving_chat.py).
- if stage_id == 0 and ro and getattr(ro, "outputs", None):
- for o in ro.outputs:
- text = getattr(o, "text", "") or ""
- if text:
- print(text, end="", flush=True)
- # Non-streaming: one shot with full text — emit a trailing newline.
- if not args.stream:
- print(flush=True)
-
- # Collect images from diffusion stage
- images = getattr(req_output, "images", None)
- if not images and ro and hasattr(ro, "images"):
- images = ro.images
- if images:
- for j, img in enumerate(images):
- save_path = os.path.join(args.output, f"output_{img_idx}_{j}.png")
- img.save(save_path)
- print(f"\n[Output] Saved image to {save_path}")
- img_idx += 1
-
-
-if __name__ == "__main__":
- main()
diff --git a/examples/offline_inference/image_to_image/image_edit.py b/examples/offline_inference/image_to_image/image_edit.py
index 91a5ce658..4985117f6 100644
--- a/examples/offline_inference/image_to_image/image_edit.py
+++ b/examples/offline_inference/image_to_image/image_edit.py
@@ -96,6 +96,7 @@ from typing import Any
import torch
from PIL import Image
+from vllm_omni.diffusion.data import logger
from vllm_omni.diffusion.utils.image_output import extract_images_from_outputs
from vllm_omni.diffusion.utils.param_utils import apply_declared_extra_args
from vllm_omni.entrypoints.omni import Omni
@@ -103,6 +104,8 @@ from vllm_omni.entrypoints.openai.stage_params import clone_sampling_params
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.model_extras import (
build_image_to_image_prompt,
+ get_ar_input_builder,
+ get_ar_tokenizer_validator,
get_extra_body_params,
get_model_class_name,
should_init_extra_args_for_non_diffusion_stages,
@@ -110,6 +113,91 @@ from vllm_omni.model_extras import (
from vllm_omni.platforms import current_omni_platform
+def _apply_ar_stage_inputs(
+ ar_input_builder: Any,
+ *,
+ model: str,
+ prompt_text: str,
+ extra_body: dict[str, Any],
+ num_images: int,
+ height: int | None,
+ width: int | None,
+ prompt_dict: dict[str, Any],
+ sampling_params_list: list[Any],
+ text_output: bool = False,
+ trust_remote_code: bool = False,
+ validate_tokenizer: Any | None = None,
+ allow_tokenizer_fallback: bool = False,
+) -> None:
+ """Apply a model's declared AR-stage inputs to the request in place.
+
+ Mirrors the helper in ``text_to_image.py``: loads the tokenizer for
+ byte-for-byte HF-parity AR tokenization, asks the model's
+ ``ar_input_builder`` for the AR prefill + stop tokens, and writes them
+ onto ``prompt_dict`` and the non-diffusion (AR) stage params.
+ Model-agnostic -- only the declared-hook contract is assumed.
+
+ ``trust_remote_code`` must be threaded in from the caller's own resolved
+ ``--trust-remote-code`` flag -- this is a separate tokenizer load outside
+ the main engine, so it needs the same explicit user opt-in rather than
+ defaulting to trusting whatever ``--model`` was passed.
+
+ ``validate_tokenizer``, when the model declares one via
+ ``get_ar_tokenizer_validator``, is called on a successfully-loaded real
+ tokenizer -- outside the load's try/except, so a validation failure (a
+ model/tokenizer revision drifting from hardcoded special-token ids)
+ raises instead of being swallowed into the string-prompt fallback.
+
+ ``allow_tokenizer_fallback`` (default ``False``, i.e. fail fast): a
+ tokenizer load failure normally raises, since silently degrading to the
+ string-prompt form can produce a request that completes with subtly
+ wrong stop tokens instead of surfacing the real problem (missing
+ ``--trust-remote-code``, network/cache issue, etc.). Set ``True`` only
+ for explicit unit tests or a deliberate offline-compat run where the
+ caller has already decided a degraded prompt is acceptable.
+ """
+ try:
+ from transformers import AutoTokenizer
+
+ ar_tokenizer: Any | None = AutoTokenizer.from_pretrained(model, trust_remote_code=trust_remote_code)
+ except Exception as exc: # noqa: BLE001 - re-raised unless the caller opts into the fallback
+ if not allow_tokenizer_fallback:
+ raise
+ logger.warning(f"AR tokenizer load failed ({exc}); falling back to string prompt (no BPE parity).")
+ ar_tokenizer = None
+
+ if ar_tokenizer is not None and validate_tokenizer is not None:
+ validate_tokenizer(ar_tokenizer)
+
+ ar_inputs = ar_input_builder(
+ prompt=prompt_text,
+ tokenizer=ar_tokenizer,
+ extra_body=extra_body,
+ num_images=num_images,
+ height=height,
+ width=width,
+ text_output=text_output,
+ )
+
+ if ar_inputs.prompt_token_ids is not None:
+ prompt_dict["prompt_token_ids"] = ar_inputs.prompt_token_ids
+ elif ar_inputs.prompt is not None:
+ prompt_dict["prompt"] = ar_inputs.prompt
+ if ar_inputs.use_system_prompt:
+ prompt_dict["use_system_prompt"] = ar_inputs.use_system_prompt
+ prompt_dict["modalities"] = ar_inputs.modalities
+
+ # Apply stop_token_ids to exactly the stage(s) the builder names -- not
+ # "whichever stage isn't a diffusion stage," which would misfire on any
+ # future topology with more than one non-diffusion stage.
+ for stage_index in ar_inputs.stage_indices:
+ if stage_index >= len(sampling_params_list):
+ continue
+ stage_params = sampling_params_list[stage_index]
+ if not isinstance(stage_params, OmniDiffusionSamplingParams) and hasattr(stage_params, "stop_token_ids"):
+ stage_params.stop_token_ids = ar_inputs.stop_token_ids
+
+
def parse_profiler_config(value: str) -> dict[str, Any]:
try:
config = json.loads(value)
@@ -419,6 +507,22 @@ def parse_args() -> argparse.Namespace:
default=None,
help='JSON profiler config for torch/cuda profiling, e.g. \'{"profiler":"torch","torch_profiler_dir":"./perf"}\'.',
)
+ parser.add_argument(
+ "--trust-remote-code",
+ action="store_true",
+ help="Trust and execute custom modeling code from the model repo (required by e.g. HunyuanImage-3.0).",
+ )
+ parser.add_argument(
+ "--allow-tokenizer-fallback",
+ action="store_true",
+ help=(
+ "For models with a declared ar_input_builder (e.g. HunyuanImage-3.0): if loading "
+ "the AR tokenizer fails, degrade to the string-prompt form (no BPE parity) instead "
+ "of failing the run. Off by default -- a tokenizer load failure usually means a "
+ "missing --trust-remote-code or a network/cache issue that's worth surfacing, not "
+ "silently masking with a possibly-wrong prompt."
+ ),
+ )
return parser.parse_args()
@@ -492,6 +596,8 @@ def main():
)
if args.enforce_eager is not None:
omni_kwargs["enforce_eager"] = args.enforce_eager
+ if args.trust_remote_code:
+ omni_kwargs["trust_remote_code"] = True
if args.deploy_config:
omni_kwargs["deploy_config"] = args.deploy_config
omni = Omni(**omni_kwargs)
@@ -585,6 +691,26 @@ def main():
if not diffusion_replaced and len(sampling_params_list) == 1:
sampling_params_list = [diffusion_params]
+ # Models with an AR text stage (e.g. HunyuanImage3 image-editing) declare an
+ # ar_input_builder; build the AR prefill + stop tokens declaratively from the
+ # plain prompt + reference image count + extra_args. Others are untouched.
+ ar_input_builder = get_ar_input_builder(model_class_name)
+ if ar_input_builder is not None:
+ _apply_ar_stage_inputs(
+ ar_input_builder,
+ model=args.model,
+ prompt_text=args.prompt,
+ extra_body=extra_args_from_cli,
+ num_images=len(input_images),
+ height=args.height,
+ width=args.width,
+ prompt_dict=prompt_dict,
+ sampling_params_list=sampling_params_list,
+ trust_remote_code=args.trust_remote_code,
+ validate_tokenizer=get_ar_tokenizer_validator(model_class_name),
+ allow_tokenizer_fallback=args.allow_tokenizer_fallback,
+ )
+
outputs = omni.generate(prompt_dict, sampling_params_list=sampling_params_list)
generation_end = time.perf_counter()
generation_time = generation_end - generation_start
diff --git a/examples/offline_inference/text_to_image/README.md b/examples/offline_inference/text_to_image/README.md
index cfe05255d..7664bb32d 100644
--- a/examples/offline_inference/text_to_image/README.md
+++ b/examples/offline_inference/text_to_image/README.md
@@ -19,23 +19,23 @@ This folder provides several entrypoints for experimenting with text-to-image di
### Supported Models
-| Model | Image Shape | Peak VRAM (GiB) * | Model Weights (GiB) |
+| Model | Image Shape | Peak VRAM (GiB) * | Model Weights (GiB) |
| ----- | ----------- | ----------- | ----------------- |
| `Qwen/Qwen-Image` | 1024 x 1024 | 60.0 | 53.7 |
-| `Qwen/Qwen-Image-2512` |1024 x 1024 | 60.0 | 53.7 |
+| `Qwen/Qwen-Image-2512` | 1024 x 1024 | 60.0 | 53.7 |
| `Tongyi-MAI/Z-Image-Turbo` | 1024 x 1024 | 24.8 | 19.2 |
| `stepfun-ai/NextStep-1.1` | 512 x 512 | 71.8 | 28.1 |
| `meituan-longcat/LongCat-Image` | 1024 x 1024 | 71.2 | 27.3 |
| `AIDC-AI/Ovis-Image-7B` | 1024 x 1024 | 71.8 | 17.1 |
-| `OmniGen2/OmniGen2` | 1024 x 1024 | 20.1 | 14.7 |
+| `OmniGen2/OmniGen2` | 1024 x 1024 | 20.1 | 14.7 |
| `stabilityai/stable-diffusion-3.5-medium` | 1024 x 1024 | 20.1 | 15.6 |
| `black-forest-labs/FLUX.1-dev` | 1024 x 1024 | 33.9 | 31.4 |
| `black-forest-labs/FLUX.1-schnell` | 1024 x 1024 | 33.9 | 31.4 |
| `black-forest-labs/FLUX.2-klein-4B` | 1024 x 1024 | 72.7 | 14.9 |
| `black-forest-labs/FLUX.2-klein-9B` | 1024 x 1024 | 37.1 | 32.3 |
| `black-forest-labs/FLUX.2-dev` | 1024 x 1024 | 65.7 | >80 (CPU offload required) |
-| `HunyuanImage-3.0` | 1024 x 1024 | 80.0 (TP≥3) | 160 |
-| `HiDream-I1-Full` | 1024 x 1024 | 63.7 | 57.7 |
+| `HunyuanImage-3.0` | 1024 x 1024 | 80.0 (TP≥3) | 160 |
+| `HiDream-I1-Full` | 1024 x 1024 | 63.7 | 57.7 |
| `krea/Krea-2-Raw`, `krea/Krea-2-Turbo` | 1024 (Raw) / 2048 (Turbo) | — | ~30 |
!!! info
@@ -112,9 +112,9 @@ python text_to_image.py \
| `--enable-cpu-offload` | flag | off | Enable CPU offloading for diffusion models |
| `--lora-path` | str | — | Path to PEFT LoRA adapter folder or checkpoint file |
| `--lora-scale` | float | `1.0` | Scale factor for LoRA weights |
-| `--lora-backend` | str |`"peft"`| LoRA backend for loading LoRA adapters. Default: peft. Available options: peft, distill |
-| `--use-system-prompt` | str | `None` | System prompt preset: `en_unified`, `en_vanilla`, `en_recaption`, `en_think_recaption`, `dynamic`, `None`, or custom text. Recommended: `en_unified`. Only for HunyuanImage-3.0.|
-| `--system-prompt` | str | `None` | Custom system prompt text. Only used when `--use-system-prompt` is set to `custom`. Only for HunyuanImage-3.0.|
+| `--lora-backend` | str | `"peft"` | LoRA backend for loading LoRA adapters. Default: peft. Available options: peft, distill |
+| `--use-system-prompt` | str | `None` | System prompt preset: `en_unified`, `en_vanilla`, `en_recaption`, `en_think_recaption`, `dynamic`, `None`, or custom text. Recommended: `en_unified`. Only for HunyuanImage-3.0. |
+| `--system-prompt` | str | `None` | Custom system prompt text. Only used when `--use-system-prompt` is set to `custom`. Only for HunyuanImage-3.0. |
| `--auxiliary-text-encoder` | str | `None` | Supplementary auxiliary text encoder parameters model name or path (especially for Hidream-l1-full). |
**NextStep-1.1 specific arguments:**
@@ -127,7 +127,7 @@ python text_to_image.py \
| `--use-norm` | flag | off | Apply layer normalization to sampled tokens |
> If you encounter OOM errors, try using `--vae-use-slicing` and `--vae-use-tiling` to reduce memory usage.
-
+>
> Qwen-Image currently publishes best-effort presets at `1328x1328`, `1664x928`, `928x1664`, `1472x1140`, `1140x1472`, `1584x1056`, and `1056x1584`. Adjust `--height/--width` accordingly for the most reliable outcomes.
## More CLI Examples
@@ -216,6 +216,26 @@ python examples/offline_inference/text_to_image/text_to_image.py \
--output /output.png
```
+### Anima Single-File Checkpoints
+
+Anima uses the official model dimensions by default. A checkpoint may provide a JSON file with the same stem (for example, `anima.json` beside `anima.safetensors`) containing `transformer` and `text_conditioner` configuration objects. These override the default dimensions and must match the checkpoint weights. The shared tiny-model tests use this to load small random-weight models. Components in `text_encoder/`, `vae/`, `tokenizer/`, `t5_tokenizer/`, and `scheduler/` beside the checkpoint are discovered automatically.
+
+To load Anima, point `--model` to the single-file checkpoint path, pass the native pipeline class name using `--model-class-name`, and supply the converted components directory using `--custom-pipeline-args`:
+
+```bash
+python examples/offline_inference/text_to_image/text_to_image.py \
+ --model /path/to/models/anima-official/split_files/diffusion_models/anima-base-v1.0.safetensors \
+ --model-class-name AnimaPipeline \
+ --custom-pipeline-args '{"components_path": "/path/to/models/anima-components"}' \
+ --prompt "A cinematic close-up of a glass teapot on a wooden table." \
+ --seed 42 \
+ --guidance-scale 4.0 \
+ --num-inference-steps 50 \
+ --height 1024 \
+ --width 1024 \
+ --output anima_output.png
+```
+
### Krea 2
Krea 2 is published as diffusers-format checkpoints: `krea/Krea-2-Turbo` (few-step distilled) and `krea/Krea-2-Raw`.
@@ -336,7 +356,7 @@ python text_to_image.py \
LoRA adapters must be in PEFT format. A typical adapter directory structure:
-```
+```text
lora_adapter/
├── adapter_config.json
└── adapter_model.safetensors
diff --git a/examples/offline_inference/text_to_image/text_to_image.py b/examples/offline_inference/text_to_image/text_to_image.py
index bc7ccbf31..57f2371b6 100644
--- a/examples/offline_inference/text_to_image/text_to_image.py
+++ b/examples/offline_inference/text_to_image/text_to_image.py
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
-# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import argparse
import functools
@@ -24,6 +24,8 @@ from vllm_omni.model_extras import (
build_text_to_image_prompt as build_model_text_to_image_prompt,
)
from vllm_omni.model_extras import (
+ get_ar_input_builder,
+ get_ar_tokenizer_validator,
get_extra_body_params,
get_model_class_name,
should_init_extra_args_for_non_diffusion_stages,
@@ -56,6 +58,7 @@ def parse_json_object(value: str, flag_name: str = "argument") -> dict[str, Any]
parse_profiler_config = functools.partial(parse_json_object, flag_name="--profiler-config")
+parse_custom_pipeline_args = functools.partial(parse_json_object, flag_name="--custom-pipeline-args")
def build_text_to_image_prompt(prompt: str, negative_prompt: str | None) -> dict[str, Any]:
@@ -378,10 +381,123 @@ def parse_args() -> argparse.Namespace:
default=None,
help="Supplementary auxiliary text encoder parameters model name or path (especially for Hidream-l1-full).",
)
+ parser.add_argument(
+ "--model-class-name",
+ type=str,
+ default=None,
+ help="Override the diffusion pipeline class name (e.g. AnimaPipeline).",
+ )
+ parser.add_argument(
+ "--custom-pipeline-args",
+ type=parse_custom_pipeline_args,
+ default=None,
+ help='JSON object passed to native/custom pipelines (e.g. \'{"components_path": "/path"}\').',
+ )
+ parser.add_argument(
+ "--trust-remote-code",
+ action="store_true",
+ help="Trust and execute custom modeling code from the model repo (required by e.g. HunyuanImage-3.0).",
+ )
+ parser.add_argument(
+ "--allow-tokenizer-fallback",
+ action="store_true",
+ help=(
+ "For models with a declared ar_input_builder (e.g. HunyuanImage-3.0): if loading "
+ "the AR tokenizer fails, degrade to the string-prompt form (no BPE parity) instead "
+ "of failing the run. Off by default -- a tokenizer load failure usually means a "
+ "missing --trust-remote-code or a network/cache issue that's worth surfacing, not "
+ "silently masking with a possibly-wrong prompt."
+ ),
+ )
current_omni_platform.pre_register_and_update(parser)
return parser.parse_args()
+def _apply_ar_stage_inputs(
+ ar_input_builder: Any,
+ *,
+ model: str,
+ prompt_text: str,
+ extra_body: dict[str, Any],
+ num_images: int,
+ height: int | None,
+ width: int | None,
+ prompt_dict: dict[str, Any],
+ sampling_params_list: list[Any],
+ text_output: bool = False,
+ trust_remote_code: bool = False,
+ validate_tokenizer: Any | None = None,
+ allow_tokenizer_fallback: bool = False,
+) -> None:
+ """Apply a model's declared AR-stage inputs to the request in place.
+
+ Loads the model tokenizer (for byte-for-byte HF-parity segment
+ tokenization) and asks the model's ``ar_input_builder`` for the AR
+ prefill + stop tokens, then writes them onto ``prompt_dict`` and the
+ non-diffusion (AR) stage sampling params. Kept model-agnostic: the
+ example only knows the declared-hook contract.
+
+ ``trust_remote_code`` must be threaded in from the caller's own resolved
+ ``--trust-remote-code`` flag -- this is a separate tokenizer load outside
+ the main engine, so it needs the same explicit user opt-in rather than
+ defaulting to trusting whatever ``--model`` was passed.
+
+ ``validate_tokenizer``, when the model declares one via
+ ``get_ar_tokenizer_validator``, is called on a successfully-loaded real
+ tokenizer -- outside the load's try/except, so a validation failure (a
+ model/tokenizer revision drifting from hardcoded special-token ids)
+ raises instead of being swallowed into the string-prompt fallback.
+
+ ``allow_tokenizer_fallback`` (default ``False``, i.e. fail fast): a
+ tokenizer load failure normally raises, since silently degrading to the
+ string-prompt form can produce a request that completes with subtly
+ wrong stop tokens instead of surfacing the real problem (missing
+ ``--trust-remote-code``, network/cache issue, etc.). Set ``True`` only
+ for explicit unit tests or a deliberate offline-compat run where the
+ caller has already decided a degraded prompt is acceptable.
+ """
+ try:
+ from transformers import AutoTokenizer
+
+ ar_tokenizer: Any | None = AutoTokenizer.from_pretrained(model, trust_remote_code=trust_remote_code)
+ except Exception as exc: # noqa: BLE001 - re-raised unless the caller opts into the fallback
+ if not allow_tokenizer_fallback:
+ raise
+ logger.warning(f"AR tokenizer load failed ({exc}); falling back to string prompt (no BPE parity).")
+ ar_tokenizer = None
+
+ if ar_tokenizer is not None and validate_tokenizer is not None:
+ validate_tokenizer(ar_tokenizer)
+
+ ar_inputs = ar_input_builder(
+ prompt=prompt_text,
+ tokenizer=ar_tokenizer,
+ extra_body=extra_body,
+ num_images=num_images,
+ height=height,
+ width=width,
+ text_output=text_output,
+ )
+
+ if ar_inputs.prompt_token_ids is not None:
+ prompt_dict["prompt_token_ids"] = ar_inputs.prompt_token_ids
+ elif ar_inputs.prompt is not None:
+ prompt_dict["prompt"] = ar_inputs.prompt
+ if ar_inputs.use_system_prompt:
+ prompt_dict["use_system_prompt"] = ar_inputs.use_system_prompt
+ prompt_dict["modalities"] = ar_inputs.modalities
+
+ # Apply stop_token_ids to exactly the stage(s) the builder names -- not
+ # "whichever stage isn't a diffusion stage," which would misfire on any
+ # future topology with more than one non-diffusion stage.
+ for stage_index in ar_inputs.stage_indices:
+ if stage_index >= len(sampling_params_list):
+ continue
+ stage_params = sampling_params_list[stage_index]
+ if not isinstance(stage_params, OmniDiffusionSamplingParams) and hasattr(stage_params, "stop_token_ids"):
+ stage_params.stop_token_ids = ar_inputs.stop_token_ids
+
+
def main():
args = parse_args()
generator = torch.Generator(device=current_omni_platform.device_type).manual_seed(args.seed)
@@ -456,7 +572,6 @@ def main():
"vae_patch_parallel_size": args.vae_patch_parallel_size,
"enable_expert_parallel": args.enable_expert_parallel,
"enable_cpu_offload": args.enable_cpu_offload,
- "mode": "text-to-image",
"log_stats": args.log_stats,
"enable_diffusion_pipeline_profiler": args.enable_diffusion_pipeline_profiler,
"profiler_config": args.profiler_config,
@@ -470,11 +585,17 @@ def main():
omni_kwargs["tensor_parallel_size"] = args.tensor_parallel_size
if args.enforce_eager is not None:
omni_kwargs["enforce_eager"] = args.enforce_eager
+ if args.trust_remote_code:
+ omni_kwargs["trust_remote_code"] = True
if args.deploy_config:
omni_kwargs["deploy_config"] = args.deploy_config
- if use_nextstep:
+ if args.model_class_name:
+ omni_kwargs["model_class_name"] = args.model_class_name
+ elif use_nextstep:
# NextStep-1.1 requires explicit pipeline class
omni_kwargs["model_class_name"] = "NextStep11Pipeline"
+ if args.custom_pipeline_args is not None:
+ omni_kwargs["custom_pipeline_args"] = args.custom_pipeline_args
# Cosmos3 loads its (gated) guardrail models at build time, so the guardrails
# gate is an engine-level config (offline analog of the server's --no-guardrails).
if args.extra_body and "guardrails" in args.extra_body:
@@ -510,6 +631,10 @@ def main():
print(f" LoRA: scale={args.lora_scale}")
if args.deploy_config:
print(f" deploy-config: {args.deploy_config}")
+ if args.model_class_name:
+ print(f" Model class name: {args.model_class_name}")
+ if args.custom_pipeline_args is not None:
+ print(f" Custom pipeline args: {args.custom_pipeline_args}")
print(f"{'=' * 60}\n")
# Build LoRA request when --lora-path is set
@@ -614,6 +739,30 @@ def main():
if not diffusion_replaced and len(sampling_params_list) == 1:
sampling_params_list = [diffusion_params]
+ # Models with an AR text stage (e.g. HunyuanImage3) declare an
+ # ar_input_builder. When present, build the AR prefill token-ids and AR
+ # stop-token-ids declaratively from the plain prompt + extra_body, so this
+ # example stays model-agnostic. Models without one are untouched.
+ ar_input_builder = get_ar_input_builder(model_class_name)
+ # A model can also be deployed with only its diffusion stage. Keep those
+ # requests on the string-prompt path, as in the single-stage images API.
+ has_ar_stage = any(not isinstance(params, OmniDiffusionSamplingParams) for params in sampling_params_list)
+ if ar_input_builder is not None and has_ar_stage:
+ _apply_ar_stage_inputs(
+ ar_input_builder,
+ model=args.model,
+ prompt_text=args.prompt,
+ extra_body=user_extra,
+ num_images=0,
+ height=args.height,
+ width=args.width,
+ prompt_dict=prompt_dict,
+ sampling_params_list=sampling_params_list,
+ trust_remote_code=args.trust_remote_code,
+ validate_tokenizer=get_ar_tokenizer_validator(model_class_name),
+ allow_tokenizer_fallback=args.allow_tokenizer_fallback,
+ )
+
outputs = omni.generate(prompt_dict, sampling_params_list=sampling_params_list)
generation_end = time.perf_counter()
diff --git a/examples/offline_inference/text_to_speech/README.md b/examples/offline_inference/text_to_speech/README.md
index a8a273026..36d1b44ee 100644
--- a/examples/offline_inference/text_to_speech/README.md
+++ b/examples/offline_inference/text_to_speech/README.md
@@ -13,11 +13,13 @@ list of supported architectures across all modalities, see
## Supported Models
| Model | HuggingFace repo | Stages | Voice cloning | Streaming | Special modes | Sample rate |
-|---|---|---|---|---|---|---|
+| --- | --- | --- | --- | --- | --- | --- |
+| AuK | `tencent/AuK`, `tencent/AuK-Flash` (assembled with `tools/prepare_auk_checkpoint.py`) | 2 (thinker encoder + DiT) | ✓ | — | instruction-driven editing, enhancement, separation; no example script (use `vllm_omni.model_extras.auk`) | 24 kHz |
| Audio8 TTS Preview | `Audio8/Audio8-TTS-Preview-0.6b` | dual-AR | ✓ | ✓ | 11 languages | 44.1 kHz |
+| Breeze-TTS-2 | `BreezeBlue/Breeze-TTS-2` | 2 (talker + codec) | ✓ | ✓ (async chunk) | voice design / voice direction (`--instruction`) | 24 kHz |
| CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | 2 (talker + code2wav) | ✓ | ✓ | — | 24 kHz |
| Fish Speech S2 Pro | `fishaudio/s2-pro` | dual-AR | ✓ | ✓ | — | 44.1 kHz |
-| Gepard-1.0 | `nineninesix/gepard-1.0` | single (native AR + NanoCodec) | — (zero-shot; cloning WIP) | — (serving WIP) | zero-shot | 22.05 kHz |
+| Gepard-1.0 | `nineninesix/gepard-1.0` | single (native AR + NanoCodec) | — (zero-shot; cloning later) | ✓ (online) | zero-shot | 22.05 kHz |
| GLM-TTS | `zai-org/GLM-TTS` | 2 (AR + DiT) | ✓ (required) | ✓ | — | 24 kHz |
| Ming-omni-tts | `inclusionAI/Ming-omni-tts-0.5B` | 2 (AR + audio VAE) | ✓ | ✓ | style / IP / dialect / TTA / podcast | 44.1 kHz |
| Ming-flash-omni-TTS | `Jonathan1909/Ming-flash-omni-2.0` | single (talker only) | — (caption-controlled) | — | style / IP / basic captions | 44.1 kHz |
@@ -25,7 +27,6 @@ list of supported architectures across all modalities, see
| OmniVoice | `k2-fsa/OmniVoice` | 2 (gen + dec) | ✓ | — | voice design, language hint | 24 kHz |
| Qwen3-TTS | `Qwen/Qwen3-TTS-12Hz-1.7B-{CustomVoice,VoiceDesign,Base}` | 2 (talker + code2wav) | ✓ (Base) | ✓ | 3 task variants | 24 kHz |
| VoxCPM2 | `openbmb/VoxCPM2` | single (native AR) | ✓ | ✓ (online) | continuation | 48 kHz |
-| dots.tts | `dots-studio/dots.tts-soar` | single (native AR) | — (text-only) | ✓ (online) | no-reference synthesis | 48 kHz |
| IndexTTS-2 | `IndexTeam/IndexTTS-2` | 2 (AR talker + S2Mel DiT + BigVGAN) | ✓ (required) | — | emotion control (`--emo-audio`, `--emo-text`, `--emo-vector`) | 22.05 kHz |
| IndexTTS-2.5 | native `checkpoints/` bundle | 2 (AR talker + EnhancedCodec + S2Mel DiT + BigVGAN) | ✓ (required) | — | multilingual (`--lang`) + emotion control | 22.05 kHz |
| Voxtral TTS | `mistralai/Voxtral-4B-TTS-2603` | varies | ✓ | ✓ | voice presets | 24 kHz |
@@ -52,32 +53,38 @@ codec: a slow AR transformer predicts one semantic token per codec frame, and a
4-layer fast AR predicts that frame's remaining 9 codebooks.
### Prerequisites
+
No extra packages: the codec architecture ships in tree and its weights
(`codec.pth`) come with the checkpoint.
### Quick start
+
```bash
python examples/offline_inference/text_to_speech/audio8_tts/end2end.py \
--text "Welcome to Audio8 TTS, a compact multilingual text to speech model."
```
### Voice cloning
+
```bash
python examples/offline_inference/text_to_speech/audio8_tts/end2end.py \
--text "Welcome to Audio8 TTS." \
--ref-audio /path/to/reference.wav \
--ref-text "The exact transcript of the reference recording."
```
+
The reference transcript must match what is actually spoken in the clip;
a mismatch degrades both stability and speaker similarity.
### Streaming
+
```bash
python examples/offline_inference/text_to_speech/audio8_tts/end2end.py \
--text "Welcome to Audio8 TTS." --streaming
```
### Notes
+
- Output: 44.1 kHz mono WAV; the codec emits ~21.5 frames/s (2048 samples per frame).
- Context is 2048 packed text+audio positions, so `max_model_len` is 2048.
- Greedy decoding (`temperature: 0`) is degenerate for this checkpoint — it
@@ -90,6 +97,7 @@ python examples/offline_inference/text_to_speech/audio8_tts/end2end.py \
- `--num-prompts 4` smoke-tests concurrency (stage 0 runs `max_num_seqs: 4`).
### Measured behaviour (1x H20, shared GPU)
+
`--streaming` on a 58-character English sentence: TTFA ~0.74 s (4-frame first
chunk), 7 chunks, 1.55 s wall for 3.16 s of audio (RTF ~0.49). Treat these as
smoke-test magnitudes, not a benchmark.
@@ -101,12 +109,14 @@ smoke-test magnitudes, not a benchmark.
2-stage TTS pipeline (`talker` + `code2wav`) at 24 kHz.
### Prerequisites
+
```bash
uv pip install -e .
# Includes soundfile, onnxruntime, x-transformers, einops via requirements.
```
Download the model snapshot:
+
```python
from huggingface_hub import snapshot_download
snapshot_download('FunAudioLLM/Fun-CosyVoice3-0.5B-2512',
@@ -114,15 +124,18 @@ snapshot_download('FunAudioLLM/Fun-CosyVoice3-0.5B-2512',
```
If your downloaded checkpoint lacks `config.json`, add it:
+
```json
{
"model_type": "cosyvoice3",
"architectures": ["CosyVoice3Model"]
}
```
+
This is required because `AutoConfig.register("cosyvoice3", CosyVoice3Config)` only registers the class mapping; the loader still reads `model_type` from `config.json` to select the class.
### Quick start
+
```bash
python examples/offline_inference/text_to_speech/cosyvoice3/end2end.py \
--model pretrained_models/Fun-CosyVoice3-0.5B \
@@ -130,9 +143,11 @@ python examples/offline_inference/text_to_speech/cosyvoice3/end2end.py \
```
### Voice cloning
+
If `--ref-audio` is omitted, the script downloads the upstream
[`zero_shot_prompt.wav`](https://github.com/FunAudioLLM/CosyVoice/blob/main/asset/zero_shot_prompt.wav) from the CosyVoice repo into the current directory.
To use your own clip, pass `--ref-audio /path/to/reference.wav`, and modify `--prompt-text` correspondingly.
+
```bash
python examples/offline_inference/text_to_speech/cosyvoice3/end2end.py \
--model pretrained_models/Fun-CosyVoice3-0.5B \
@@ -142,9 +157,11 @@ python examples/offline_inference/text_to_speech/cosyvoice3/end2end.py \
```
### Streaming
+
Streaming is enabled by default via `async_chunk: true` in `vllm_omni/deploy/cosyvoice3.yaml`. Pass `--no-async-chunk` on `vllm serve` to switch to the legacy synchronous path.
### Notes
+
- Stage 0 (`talker`) emits speech tokens; stage 1 (`code2wav`) runs flow matching + HiFiGAN to synthesize waveform.
- Deploy config auto-loads from `vllm_omni/deploy/cosyvoice3.yaml` based on HF `model_type`. Pass `--deploy-config ` to override.
@@ -155,6 +172,7 @@ Streaming is enabled by default via `async_chunk: true` in `vllm_omni/deploy/cos
2-stage TTS pipeline (AR + DiT flow-matching) at 24 kHz. Every request requires reference audio and its transcript for zero-shot voice cloning.
### Quick start
+
```bash
python examples/offline_inference/text_to_speech/glm_tts/end2end.py \
--model zai-org/GLM-TTS \
@@ -165,12 +183,14 @@ python examples/offline_inference/text_to_speech/glm_tts/end2end.py \
```
### Architecture
-```
+
+```text
Text → [Stage 0: AR] → Speech Tokens → [Stage 1: DiT + HiFT] → Audio (24 kHz)
(Llama-based) (32k vocab) (Flow Matching)
```
### Notes
+
- `--ref-audio` and `--ref-text` are **required** together; GLM-TTS does not support text-only synthesis.
- Reference audio should be 3-10 seconds.
- First run may be slow due to lazy loading of WhisperVQ tokenizer and CampPlus ONNX speaker embedder.
@@ -185,12 +205,14 @@ Text → [Stage 0: AR] → Speech Tokens → [Stage 1: DiT + HiFT] → Audio (24
No extra packages are required; the DAC codec is vendored in vLLM-Omni.
### Quick start
+
```bash
python examples/offline_inference/text_to_speech/fish_speech/end2end.py \
--text "Hello, this is a test of the Fish Speech text to speech system."
```
### Voice cloning
+
```bash
python examples/offline_inference/text_to_speech/fish_speech/end2end.py \
--text "Hello, this is a cloned voice." \
@@ -199,14 +221,17 @@ python examples/offline_inference/text_to_speech/fish_speech/end2end.py \
```
### Streaming
+
```bash
python examples/offline_inference/text_to_speech/fish_speech/end2end.py \
--text "Hello, this is a streaming test." \
--streaming
```
+
Streaming requires `async_chunk: true` in the deploy config.
### Notes
+
- Output: 44.1 kHz mono WAV.
- DAC codec weights (`codec.pth`) are loaded lazily from the model directory.
@@ -217,6 +242,7 @@ Streaming requires `async_chunk: true` in the deploy config.
Dense 0.5B two-stage TTS pipeline (`AR + flow` + audio VAE) at 44.1 kHz. The example covers style, IP voice, music-only generation, text-to-audio events, emotion, dialect, zero-shot cloning, podcast, speech+BGM, and speech+environment-sound cases.
### Quick start
+
```bash
python examples/offline_inference/text_to_speech/ming_tts/end2end.py \
--case style \
@@ -225,6 +251,7 @@ python examples/offline_inference/text_to_speech/ming_tts/end2end.py \
```
### Voice cloning
+
```bash
python examples/offline_inference/text_to_speech/ming_tts/end2end.py \
--case zero_shot \
@@ -235,6 +262,7 @@ python examples/offline_inference/text_to_speech/ming_tts/end2end.py \
```
### Streaming
+
```bash
python examples/offline_inference/text_to_speech/ming_tts/end2end.py \
--case basic \
@@ -245,6 +273,7 @@ python examples/offline_inference/text_to_speech/ming_tts/end2end.py \
```
### Notes
+
- `style`, `ip`, `bgm`, and `tta` do not require reference audio.
- Reference-audio cases use `--ref-audio`; `zero_shot` also requires `--ref-text`.
- `podcast` uses multiple references via `--ref-audio-paths`.
@@ -257,14 +286,17 @@ python examples/offline_inference/text_to_speech/ming_tts/end2end.py \
Standalone talker-only deployment of Ming-flash-omni-2.0 at 44.1 kHz. Voice is controlled through caption fields (`风格` / `IP` / `语速`/`基频`/`音量`) rather than reference audio.
### Prerequisites
+
The example calls into `vllm_omni.model_executor.models.ming_flash_omni.prompt_utils` for the default prompt and instruction builder; no extra pip install on top of the base vLLM-Omni install.
### Quick start
+
```bash
python examples/offline_inference/text_to_speech/ming_flash_omni_tts/end2end.py --case style
```
### Cases
+
```bash
# ASMR-style whisper (caption-driven)
python examples/offline_inference/text_to_speech/ming_flash_omni_tts/end2end.py --case style
@@ -279,6 +311,7 @@ python examples/offline_inference/text_to_speech/ming_flash_omni_tts/end2end.py
Override the default text per case with `--text`, write to a custom path with `--output`.
### Notes
+
- Talker-only deployment — for the multimodal Ming-flash-omni example, see [`examples/offline_inference/ming_flash_omni/`](../../ming_flash_omni/).
- Deploy config: `vllm_omni/deploy/ming_flash_omni_tts.yaml` (single GPU, `enforce_eager`, `max_num_seqs: 1`).
- Decode defaults from the Ming cookbook: `max_decode_steps=200`, `cfg=2.0`, `sigma=0.25`, `temperature=0.0`, `use_zero_spk_emb=True`.
@@ -292,6 +325,7 @@ Single-stage 0.1B AR LM + MOSS-Audio-Tokenizer-Nano codec at 48 kHz mono (mixed
> **No built-in speaker presets.** `--ref-audio` is required on every call. Default `--mode voice_clone` matches upstream's recommended workflow; `--mode continuation` is exposed for completeness but upstream's continuation-with-prompt path emits very short / near-silent output, so it is rarely useful in practice. Sample reference clips ship in the upstream repo under [`assets/audio/`](https://github.com/OpenMOSS/MOSS-TTS-Nano/tree/main/assets/audio) (e.g. `zh_1.wav`, `en_2.wav`, `jp_2.wav`).
### Quick start
+
```bash
# Fetch a sample reference clip (one-off, user-scoped cache).
REF_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/moss-tts-nano"
@@ -303,9 +337,11 @@ python examples/offline_inference/text_to_speech/moss_tts_nano/end2end.py \
--text "你好,这是MOSS-TTS-Nano的语音合成演示。" \
--ref-audio "$REF_DIR/zh_1.wav"
```
+
The first run downloads `OpenMOSS-Team/MOSS-TTS-Nano` and `OpenMOSS-Team/MOSS-Audio-Tokenizer-Nano` from Hugging Face.
### Reproducible runs
+
```bash
python examples/offline_inference/text_to_speech/moss_tts_nano/end2end.py \
--text "Deterministic test." \
@@ -314,6 +350,7 @@ python examples/offline_inference/text_to_speech/moss_tts_nano/end2end.py \
```
### Notes
+
- Output: 48 kHz mono WAV (the tokenizer is internally stereo at 48 kHz; the wrapper averages to mono before reaching the engine).
- Deploy config: `vllm_omni/deploy/moss_tts_nano.yaml` (auto-loaded; override with `--deploy-config`).
- Default `--max-new-frames 375` ≈ 14 s of audio; raise for longer outputs.
@@ -327,12 +364,15 @@ python examples/offline_inference/text_to_speech/moss_tts_nano/end2end.py \
Zero-shot multilingual TTS supporting 600+ languages, with three modes (auto / clone / design).
### Prerequisites
+
```bash
huggingface-cli download k2-fsa/OmniVoice
```
+
Voice cloning requires `transformers>=5.3.0`. Auto and design modes work with `transformers>=4.57.0`.
### Quick start (auto voice)
+
```bash
python examples/offline_inference/text_to_speech/omnivoice/end2end.py \
--model k2-fsa/OmniVoice \
@@ -340,6 +380,7 @@ python examples/offline_inference/text_to_speech/omnivoice/end2end.py \
```
### Voice cloning
+
```bash
python examples/offline_inference/text_to_speech/omnivoice/end2end.py \
--model k2-fsa/OmniVoice \
@@ -349,6 +390,7 @@ python examples/offline_inference/text_to_speech/omnivoice/end2end.py \
```
### Voice design
+
```bash
python examples/offline_inference/text_to_speech/omnivoice/end2end.py \
--model k2-fsa/OmniVoice \
@@ -357,6 +399,7 @@ python examples/offline_inference/text_to_speech/omnivoice/end2end.py \
```
### Language hint
+
```bash
python examples/offline_inference/text_to_speech/omnivoice/end2end.py \
--model k2-fsa/OmniVoice \
@@ -365,6 +408,7 @@ python examples/offline_inference/text_to_speech/omnivoice/end2end.py \
```
### Seed for Reproducibility
+
```bash
python examples/offline_inference/text_to_speech/omnivoice/end2end.py \
--model k2-fsa/OmniVoice \
@@ -373,6 +417,7 @@ python examples/offline_inference/text_to_speech/omnivoice/end2end.py \
```
### Notes
+
- Stage 0 (Generator): Qwen3-0.6B with 32-step iterative unmasking.
- Stage 1 (Decoder): HiggsAudioV2 RVQ + DAC at 24 kHz.
@@ -383,13 +428,16 @@ python examples/offline_inference/text_to_speech/omnivoice/end2end.py \
3-task-variant TTS with 24 kHz output. Has its own argparse surface (this script does not follow the common `--text` / `--ref-audio` shape).
### Prerequisites
+
For ROCm builds, replace `onnxruntime` with `onnxruntime-rocm`:
+
```bash
pip uninstall onnxruntime
pip install onnxruntime-rocm
```
### Task variants
+
- `CustomVoice`: predefined speaker (speaker ID) with optional style instruction.
- `VoiceDesign`: text + descriptive instruction designs a new voice.
- `Base`: voice cloning from reference audio + transcript.
@@ -413,22 +461,27 @@ python examples/offline_inference/text_to_speech/qwen3_tts/end2end.py --query-ty
```
### Streaming
+
```bash
python examples/offline_inference/text_to_speech/qwen3_tts/end2end.py \
--query-type CustomVoice \
--streaming \
--output-dir /tmp/out_stream
```
+
Streaming requires `async_chunk: true` in the deploy config.
### Word Timestamps
+
Word-level timestamps are currently a serving-path feature: launch
`vllm-omni serve` with `--forced-aligner` and request `word_timestamps`
(see `examples/online_serving/text_to_speech/README.md`). An offline
example is not provided in this release.
### Batched decoding
+
The Code2Wav stage supports batched decoding through the SpeechTokenizer. Pass multiple prompts via `--txt-prompts` and set `--batch-size` accordingly. To raise `max_num_seqs` on either stage, create an overlay based on `vllm_omni/deploy/qwen3_tts.yaml` and pass it with `--deploy-config`:
+
```bash
python examples/offline_inference/text_to_speech/qwen3_tts/end2end.py \
--query-type CustomVoice \
@@ -436,9 +489,11 @@ python examples/offline_inference/text_to_speech/qwen3_tts/end2end.py \
--batch-size 4 \
--deploy-config /path/to/qwen3_tts_batched.yaml
```
+
`--batch-size` must match a CUDA-graph capture size (1, 2, 4, 8, 16…).
### Notes
+
- Run `--help` for the full argument surface.
- See `qwen3_tts/end2end.py` for the prompt-length-estimation logic the Talker uses.
@@ -449,6 +504,7 @@ python examples/offline_inference/text_to_speech/qwen3_tts/end2end.py \
Single-stage native AR TTS at 22.05 kHz. Pipeline: `Qwen3.5 backbone → 32 FSQ codebook heads (one frame/step) → NeMo NanoCodec`. Backbone runs under vLLM paged attention; the 32-head sampling + learned embedding feedback ride the native-AR runner hooks. `enforce_eager` (CUDA graph is a perf follow-up).
### Prerequisites
+
The NanoCodec decoder needs NeMo (NVIDIA Open Model License), installed separately:
```bash
@@ -478,19 +534,22 @@ failure rather than an environment one. The PyTorch sampler it falls back to cos
here.
### Quick start (zero-shot, default voice)
+
```bash
python examples/offline_inference/text_to_speech/gepard/end2end.py \
--text "Hello, this is a Gepard demo."
```
### Voice cloning
+
Not yet — PR1 is zero-shot only (the learned `null_prefix` default voice). Reference-audio cloning (`ref_compressor` speaker prefix) is a follow-up PR.
### Notes
+
- Output: 22.05 kHz mono WAV.
- First run downloads two checkpoints, not one: the model itself, and the NanoCodec decoder it names in `codec_id` (`nvidia/nemo-nano-codec-22khz-1.89kbps-21.5fps` by default). Both are fetched automatically; expect the first startup to be correspondingly slower.
-- Offline only for now; the `/v1/audio/speech` adapter is a follow-up PR.
-- Deploy config: `vllm_omni/deploy/gepard.yaml` (the example's default; copy it and pass `--deploy-config` to change it).
+- Offline and online: `examples/offline_inference/text_to_speech/gepard/end2end.py` and `/v1/audio/speech` (see the [online Gepard section](../../online_serving/text_to_speech/README.md#gepard-10)).
+- Deploy config: `vllm_omni/deploy/gepard.yaml` (the example's default; copy it and pass `--deploy-config` to change it). Online serving **must** pass `--deploy-config vllm_omni/deploy/gepard.yaml` because the checkpoint self-identifies as `qwen3_5_text`.
- Generation length and reproducibility are stage settings, not script flags. One output token is one audio frame, so `max_tokens` in the YAML is the frame budget; `seed` makes the in-model 32-head sampling reproducible. The script deliberately passes no `SamplingParams`: one supplied by a caller replaces the stage defaults wholesale rather than merging, which would drop the pipeline's stop token and run every request to `max_tokens`.
- Text length: short texts are repeated internally to match the training layout (the checkpoint's `text_repetition` block); the upper bound is the stage's `max_model_len`, enforced by the engine. Empty text is rejected rather than voiced.
- `VLLM_GEPARD_GREEDY=1` swaps the 32-head Gumbel-max sampling for argmax, for reproducible comparisons that do not depend on a seed.
@@ -500,6 +559,7 @@ Not yet — PR1 is zero-shot only (the learned `null_prefix` default voice). Ref
Single-stage native AR TTS at 48 kHz. Pipeline: `feat_encoder → MiniCPM4 → FSQ → residual_lm → LocDiT → AudioVAE`.
### Prerequisites
+
```bash
pip install voxcpm
# or, for a local source checkout:
@@ -507,6 +567,7 @@ export VLLM_OMNI_VOXCPM_CODE_PATH=/path/to/voxcpm
```
### Quick start
+
```bash
python examples/offline_inference/text_to_speech/voxcpm2/end2end.py \
--model openbmb/VoxCPM2 \
@@ -514,7 +575,9 @@ python examples/offline_inference/text_to_speech/voxcpm2/end2end.py \
```
### Voice cloning
+
Pass a reference audio for isolated cloning, or both `--ref-audio` + `--ref-text` for prompt continuation:
+
```bash
python examples/offline_inference/text_to_speech/voxcpm2/end2end.py \
--text "Hello, this is a voice clone demo." \
@@ -523,39 +586,16 @@ python examples/offline_inference/text_to_speech/voxcpm2/end2end.py \
```
### Streaming
+
Streaming is exposed through the online OpenAI Speech API (`stream=true`). See [`examples/online_serving/text_to_speech/voxcpm2/gradio_demo.py`](../../online_serving/text_to_speech/voxcpm2/gradio_demo.py) for an AudioWorklet-based gapless streaming player; the offline `end2end.py` script does not expose a streaming path.
### Notes
+
- Output: 48 kHz mono WAV.
- Deploy config: `vllm_omni/deploy/voxcpm2.yaml` (auto-loaded by HF `model_type`).
---
-## dots.tts
-
-Single-stage native AR TTS at 48 kHz (rednote-hilab). Pipeline: `Qwen2.5-1.5B base LM → DiT (10-step Euler flow matching) → patch_encoder AR loopback → AudioVAE (streaming decode)`. Same "vLLM-native base LM + side-path computation" pattern as VoxCPM2, with a plain Qwen2 backbone instead of MiniCPM4 and no FSQ / residual LM stage.
-
-### Quick start
-```bash
-python examples/offline_inference/text_to_speech/dots_tts/end2end.py \
- --model dots-studio/dots.tts-soar \
- --text "Hello, this is a test of dots TTS running on vLLM Omni."
-```
-
-### Voice cloning
-Not wired in this release. Generation is text-only and does not consume reference audio, a named voice, or a precomputed speaker embedding. The CAM++ x-vector speaker encoder weights load, but `end2end.py` has no `--ref-audio`/`--ref-text` flags yet.
-
-### Streaming
-The AudioVAE decoder emits incremental audio through its internal streaming path (`init_stream_state` / `stream_step` / `stream_flush`). Online streaming is exposed through the OpenAI-compatible `/v1/audio/speech` endpoint with `stream=true`; see the [online serving guide](../../online_serving/text_to_speech/README.md#dotstts). The offline `end2end.py` script returns the consolidated waveform.
-
-### Notes
-- Output: 48 kHz mono WAV.
-- Deploy config: `vllm_omni/deploy/dots_tts.yaml` (auto-loaded by HF `model_type`).
-- Checkpoints: `dots-studio/dots.tts-soar` is the validated default. `dots.tts-base` shares the same architecture but is unvalidated in this repo. `dots.tts-mf` (MeanFlow, 2-4 step) is not supported yet.
-- Known limitation: no CUDA graph capture and no batched side-path computation yet, so concurrent requests do not currently scale (each request's DiT Euler steps run serially). See `recipes/rednote-hilab/dots.tts.md` for details and the roadmap.
-
----
-
## IndexTTS-2 and IndexTTS-2.5
Both versions use a 2-stage TTS pipeline and produce 22.05 kHz mono audio. Stage 0 is a GPT AR talker. IndexTTS-2 Stage 1 uses RepCodec semantic embeddings, GPT latent, S2Mel CFM/DiT, and BigVGAN. IndexTTS-2.5 replaces RepCodec with EnhancedCodec and uses the official code-only path (`use_gpt_latent=false`). Every request requires reference audio for zero-shot voice cloning.
@@ -590,6 +630,7 @@ python examples/offline_inference/text_to_speech/indextts2/end2end.py \
```
### Emotion control
+
```bash
# Emotion from reference audio
python examples/offline_inference/text_to_speech/indextts2/end2end.py \
@@ -614,6 +655,7 @@ python examples/offline_inference/text_to_speech/indextts2/end2end.py \
```
### Notes
+
- `--ref-audio` is **required** — neither version provides text-only synthesis.
- IndexTTS-2.5 Stage 0 (AR Talker) generates mel codes from text + reference audio using plain vLLM sampling. It does not reproduce the official default `num_beams=3` beam search. For parity comparisons, run upstream with `num_beams=1`; output quality can differ from the official beam-search result.
- IndexTTS-2.5 accepts language codes such as `zh`, `en`, `zhen` (mixed Chinese/English), `ja`, and `yue`. `Mandarin` is a vLLM-Omni convenience alias for `zh`. Japanese (`ja`) uses `fugashi` tokenization and produces audio, but it does not automatically expand numbers, dates, or percentages; write those inputs as readable Japanese text before calling the model. Use `--no-text-normalization` only when the input is already normalized.
@@ -629,12 +671,15 @@ python examples/offline_inference/text_to_speech/indextts2/end2end.py \
Voxtral-4B-TTS (Mistral). Has its own argparse surface; uses voice presets and the `mistral_common` `SpeechRequest` protocol.
### Prerequisites
+
Latest `mistral_common` with `SpeechRequest` support:
+
```bash
pip install -e /path/to/mistral-common # or upgrade from PyPI when available
```
### Quick start (voice preset)
+
```bash
python examples/offline_inference/text_to_speech/voxtral_tts/end2end.py \
--write-audio --voice cheerful_female \
@@ -643,6 +688,7 @@ python examples/offline_inference/text_to_speech/voxtral_tts/end2end.py \
```
### Voice cloning (capability gated upstream)
+
```bash
python examples/offline_inference/text_to_speech/voxtral_tts/end2end.py \
--write-audio \
@@ -652,15 +698,48 @@ python examples/offline_inference/text_to_speech/voxtral_tts/end2end.py \
```
### Streaming + concurrency
+
```bash
python examples/offline_inference/text_to_speech/voxtral_tts/end2end.py \
--num-prompts 32 --concurrency 8 --streaming --write-audio --voice neutral_female \
--model mistralai/Voxtral-4B-TTS-2603 \
--text "..."
```
+
Available voice presets are listed on the HF model card (`mistralai/Voxtral-4B-TTS-2603`).
### Notes
+
- `--num-prompts N` replicates the prompt for performance measurement.
- `--concurrency M` requires `--streaming` and must evenly divide `--num-prompts`.
- Run `--help` for the full argument surface.
+
+---
+
+## Breeze-TTS-2
+
+Two-stage AR TTS (T5Gemma2 + Qwen3 talker with a depth decoder → bundled Qwen3-TTS codec) at 24 kHz mono. The prompt builder picks one of four templates automatically: plain, voice design (`--instruction`), clone (`--ref-audio` + `--ref-text`), or voice direction (reference + `--instruction`).
+
+### Quick start
+
+```bash
+python examples/offline_inference/text_to_speech/breeze_tts_2/end2end.py \
+ --model BreezeBlue/Breeze-TTS-2 \
+ --text "Hello, this is Breeze TTS 2 running on vLLM Omni."
+```
+
+### Voice direction
+
+```bash
+python examples/offline_inference/text_to_speech/breeze_tts_2/end2end.py \
+ --text "We need to discuss what happened last night." \
+ --ref-audio /path/to/reference.wav \
+ --ref-text "The exact transcript of the reference audio." \
+ --instruction "Speak slowly with a restrained, serious tone."
+```
+
+### Notes
+
+- Output: 24 kHz mono WAV; `--max-new-tokens` caps generated frames (80 ms each).
+- Speaker tags `--voice S0`..`S9` (default `S0`) select the timbre in non-reference modes; with `--ref-audio` the timbre comes from the reference clip.
+- Deploy config: `vllm_omni/deploy/breeze_tts_2.yaml` (auto-loaded by HF `model_type`); see [`recipes/BreezeBlue/Breeze-TTS-2.md`](../../../recipes/BreezeBlue/Breeze-TTS-2.md) for the online-serving recipe.
diff --git a/examples/offline_inference/text_to_speech/breeze_tts_2/end2end.py b/examples/offline_inference/text_to_speech/breeze_tts_2/end2end.py
new file mode 100644
index 000000000..942ead792
--- /dev/null
+++ b/examples/offline_inference/text_to_speech/breeze_tts_2/end2end.py
@@ -0,0 +1,204 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+"""Offline Breeze-TTS-2 inference example (two-stage talker + codec pipeline).
+
+Uses the async-chunk deploy config (vllm_omni/deploy/breeze_tts_2.yaml).
+Reference checkpoint: BreezeBlue/Breeze-TTS-2.
+"""
+
+from __future__ import annotations
+
+import os
+import time
+from pathlib import Path
+
+import soundfile as sf
+import torch
+from transformers import AutoConfig
+
+from vllm_omni import Omni
+from vllm_omni.model_executor.models.breeze_tts_2.prompt_builder import (
+ BreezeTTS2PromptBuilder,
+)
+from vllm_omni.utils.tracking_parser import TrackingArgumentParser
+
+REPO_ROOT = Path(__file__).resolve().parents[4]
+SAMPLE_RATE = 24_000
+
+
+def parse_args():
+ parser = TrackingArgumentParser(description="Offline Breeze-TTS-2 inference")
+ parser.add_argument(
+ "--model",
+ type=str,
+ default="BreezeBlue/Breeze-TTS-2",
+ help="Breeze-TTS-2 model path or HuggingFace repo ID.",
+ )
+ parser.add_argument(
+ "--text",
+ type=str,
+ default="Hello, this is Breeze TTS 2 running on vLLM Omni.",
+ help="Text to synthesize.",
+ )
+ parser.add_argument(
+ "--voice",
+ type=str,
+ default="S0",
+ help="Speaker tag such as S0..S9.",
+ )
+ parser.add_argument(
+ "--instruction",
+ type=str,
+ default=None,
+ help="Optional style instruction (voice design / voice direction).",
+ )
+ parser.add_argument(
+ "--ref-audio",
+ type=str,
+ default=None,
+ help="Optional reference wav path for cloning (requires --ref-text).",
+ )
+ parser.add_argument(
+ "--ref-text",
+ type=str,
+ default=None,
+ help="Exact transcript of the reference audio.",
+ )
+ parser.add_argument(
+ "--max-new-tokens",
+ type=int,
+ default=None,
+ help="Cap on generated codec frames (one frame is 80 ms of audio).",
+ )
+ parser.add_argument(
+ "--output-dir",
+ type=str,
+ default="output_audio",
+ help="Directory for output WAV files.",
+ )
+ parser.add_argument(
+ "--deploy-config",
+ type=str,
+ default=None,
+ help="Override the deploy config path. If unset, auto-loads "
+ "vllm_omni/deploy/breeze_tts_2.yaml based on the HF model_type.",
+ )
+ return parser.parse_args()
+
+
+def resolve_template(args) -> str:
+ if args.ref_audio is not None:
+ return "ref_edit_tata" if args.instruction else "ref_clone_tata"
+ return "tts_instruction" if args.instruction else "tts_plain"
+
+
+def reference_payload(ref_audio_path: str | None) -> dict:
+ """Load the reference clip into the payload keys the prompt builder reads.
+
+ ``BreezeTTS2PromptBuilder`` consumes ``ref_audio`` (a waveform) plus
+ ``ref_audio_sample_rate``; a bare path field is not read.
+ """
+ if ref_audio_path is None:
+ return {}
+ waveform, sample_rate = sf.read(ref_audio_path, dtype="float32", always_2d=True)
+ # Down-mix to the single 1-D clip the reference encoder expects.
+ waveform = waveform.mean(axis=1)
+ return {"ref_audio": waveform, "ref_audio_sample_rate": int(sample_rate)}
+
+
+def build_prompt(args) -> dict:
+ config = AutoConfig.from_pretrained(args.model, trust_remote_code=True)
+ reference_encoder = None
+ if args.ref_audio is not None:
+ from vllm_omni.model_executor.models.breeze_tts_2.audio_tokenizer import (
+ BreezeReferenceAudioTokenizer,
+ )
+
+ reference_encoder = BreezeReferenceAudioTokenizer.from_pretrained(
+ args.model,
+ num_codebooks=int(getattr(config, "num_codebooks", 16)),
+ device_map="cpu",
+ )
+ builder = BreezeTTS2PromptBuilder.from_pretrained(
+ args.model,
+ config,
+ reference_audio_encoder=reference_encoder,
+ )
+ payload = {
+ "text": args.text,
+ "speaker": args.voice,
+ **reference_payload(args.ref_audio),
+ }
+ if args.instruction:
+ payload["instruction"] = args.instruction
+ if args.ref_text:
+ payload["ref_text"] = args.ref_text
+ prompt = builder.build(payload, template=resolve_template(args))
+ if args.max_new_tokens is not None:
+ prompt["additional_information"]["breeze_max_new_frames"] = int(args.max_new_tokens)
+ return prompt
+
+
+def extract_audio(multimodal_output: dict) -> torch.Tensor:
+ """Extract the final complete audio tensor from multimodal output.
+
+ The output processor concatenates per-step delta tensors under
+ ``model_outputs``. Falls back to ``audio`` for backwards compat.
+ """
+ audio = multimodal_output.get("model_outputs")
+ if audio is None:
+ audio = multimodal_output.get("audio")
+ if audio is None:
+ raise ValueError(f"No audio key in multimodal_output: {list(multimodal_output.keys())}")
+
+ if isinstance(audio, list):
+ valid = [torch.as_tensor(a).float().cpu().reshape(-1) for a in audio if a is not None]
+ if not valid:
+ raise ValueError("Audio list is empty or all elements are None.")
+ return torch.cat(valid, dim=0) if len(valid) > 1 else valid[0]
+
+ return torch.as_tensor(audio).float().cpu().reshape(-1)
+
+
+def main():
+ args = parse_args()
+
+ output_dir = Path(args.output_dir)
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ engine = Omni(
+ model=args.model,
+ deploy_config=args.deploy_config,
+ )
+
+ prompt = build_prompt(args)
+
+ print(f"Model : {args.model}")
+ print(f"Text : {args.text}")
+ print(f"Template : {resolve_template(args)}")
+ print(f"Output dir : {output_dir}")
+ print(f"Prompt len : {len(prompt['prompt_token_ids'])} tokens")
+
+ t_start = time.perf_counter()
+ outputs = engine.generate([prompt])
+ elapsed = time.perf_counter() - t_start
+
+ request_output = outputs[0]
+ mm = request_output.outputs[0].multimodal_output
+ audio = extract_audio(mm)
+
+ duration = audio.numel() / SAMPLE_RATE
+ rtf = elapsed / duration if duration > 0 else float("inf")
+
+ output_path = output_dir / "output.wav"
+ sf.write(str(output_path), audio.numpy(), SAMPLE_RATE, format="WAV")
+
+ print(f"Saved : {output_path}")
+ print(f"Duration : {duration:.2f}s")
+ print(f"Inference : {elapsed:.2f}s")
+ print(f"RTF : {rtf:.3f}")
+
+
+if __name__ == "__main__":
+ os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
+ main()
diff --git a/examples/offline_inference/text_to_speech/dots_tts/end2end.py b/examples/offline_inference/text_to_speech/dots_tts/end2end.py
deleted file mode 100644
index aa710db4a..000000000
--- a/examples/offline_inference/text_to_speech/dots_tts/end2end.py
+++ /dev/null
@@ -1,125 +0,0 @@
-"""Offline dots.tts inference example (single-stage native AR pipeline).
-
-Uses the single-stage native AR config (vllm_omni/deploy/dots_tts.yaml).
-Reference checkpoint: dots-studio/dots.tts-soar.
-"""
-
-from __future__ import annotations
-
-import os
-import time
-from pathlib import Path
-
-import soundfile as sf
-import torch
-
-from vllm_omni import Omni
-from vllm_omni.utils.tracking_parser import TrackingArgumentParser
-
-REPO_ROOT = Path(__file__).resolve().parents[4]
-SAMPLE_RATE = 48_000
-
-
-def parse_args():
- parser = TrackingArgumentParser(description="Offline dots.tts native AR inference")
- parser.add_argument(
- "--model",
- type=str,
- default="dots-studio/dots.tts-soar",
- help="dots.tts model path or HuggingFace repo ID.",
- )
- parser.add_argument(
- "--text",
- type=str,
- default="Hello, this is a test of dots TTS running on vLLM Omni.",
- help="Text to synthesize.",
- )
- parser.add_argument(
- "--output-dir",
- type=str,
- default="output_audio",
- help="Directory for output WAV files.",
- )
- parser.add_argument(
- "--deploy-config",
- type=str,
- default=None,
- help="Override the deploy config path. If unset, auto-loads "
- "vllm_omni/deploy/dots_tts.yaml based on the HF model_type.",
- )
- return parser.parse_args()
-
-
-def extract_audio(multimodal_output: dict) -> torch.Tensor:
- """Extract the final complete audio tensor from multimodal output.
-
- The output processor concatenates per-step delta tensors under
- ``model_outputs``. Falls back to ``audio`` for backwards compat.
- """
- audio = multimodal_output.get("model_outputs")
- if audio is None:
- audio = multimodal_output.get("audio")
- if audio is None:
- raise ValueError(f"No audio key in multimodal_output: {list(multimodal_output.keys())}")
-
- if isinstance(audio, list):
- valid = [torch.as_tensor(a).float().cpu().reshape(-1) for a in audio if a is not None]
- if not valid:
- raise ValueError("Audio list is empty or all elements are None.")
- return torch.cat(valid, dim=0) if len(valid) > 1 else valid[0]
-
- return torch.as_tensor(audio).float().cpu().reshape(-1)
-
-
-def main():
- args = parse_args()
-
- output_dir = Path(args.output_dir)
- output_dir.mkdir(parents=True, exist_ok=True)
-
- engine = Omni(
- model=args.model,
- deploy_config=args.deploy_config,
- )
-
- from transformers import AutoTokenizer
-
- from vllm_omni.model_executor.models.dots_tts.dots_tts_prompt import (
- build_dots_tts_prompt,
- )
-
- tokenizer = AutoTokenizer.from_pretrained(args.model, trust_remote_code=True)
-
- prompt = build_dots_tts_prompt(
- tokenizer=tokenizer,
- text=args.text,
- )
-
- print(f"Model : {args.model}")
- print(f"Text : {args.text}")
- print(f"Output dir : {output_dir}")
- print(f"Prompt len : {len(prompt['prompt_token_ids'])} tokens")
-
- t_start = time.perf_counter()
- outputs = engine.generate([prompt])
- elapsed = time.perf_counter() - t_start
-
- request_output = outputs[0]
- mm = request_output.outputs[0].multimodal_output
- audio = extract_audio(mm)
-
- duration = audio.numel() / SAMPLE_RATE
- rtf = elapsed / duration if duration > 0 else float("inf")
-
- output_path = output_dir / "output.wav"
- sf.write(str(output_path), audio.numpy(), SAMPLE_RATE, format="WAV")
-
- print(f"Saved : {output_path}")
- print(f"Duration : {duration:.2f}s")
- print(f"Inference : {elapsed:.2f}s")
- print(f"RTF : {rtf:.3f}")
-
-
-if __name__ == "__main__":
- os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
- main()
diff --git a/examples/offline_inference/text_to_speech/qwen3_tts/end2end.py b/examples/offline_inference/text_to_speech/qwen3_tts/end2end.py
index 75f562af6..54b55a193 100644
--- a/examples/offline_inference/text_to_speech/qwen3_tts/end2end.py
+++ b/examples/offline_inference/text_to_speech/qwen3_tts/end2end.py
@@ -1,3 +1,6 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+
"""Offline inference demo for Qwen3 TTS via vLLM Omni.
Provides single and batch sample inputs for CustomVoice, VoiceDesign, and Base
@@ -166,6 +169,9 @@ def get_custom_voice_query(use_batch_sample: bool = False) -> QueryResult:
"language": [language],
"speaker": [speaker],
"max_new_tokens": [2048],
+ # Optional Code2Wav emit opt-in under deploy.async_chunk.
+ # Independent of prompt-mode non_streaming_mode (#4198 / #6898).
+ "full_utterance_decode": [True],
}
inputs.append(
{
@@ -185,6 +191,7 @@ def get_custom_voice_query(use_batch_sample: bool = False) -> QueryResult:
"speaker": [speaker],
"instruct": [instruct],
"max_new_tokens": [2048],
+ "full_utterance_decode": [True],
}
inputs = {
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
@@ -226,6 +233,7 @@ def get_voice_design_query(use_batch_sample: bool = False) -> QueryResult:
"instruct": [instruct],
"max_new_tokens": [2048],
"non_streaming_mode": [True],
+ "full_utterance_decode": [True],
}
inputs.append(
{
@@ -244,6 +252,7 @@ def get_voice_design_query(use_batch_sample: bool = False) -> QueryResult:
"instruct": [instruct],
"max_new_tokens": [2048],
"non_streaming_mode": [True],
+ "full_utterance_decode": [True],
}
inputs = {
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
@@ -291,6 +300,7 @@ def get_base_query(use_batch_sample: bool = False, mode_tag: str = "icl") -> Que
"language": [language],
"x_vector_only_mode": [x_vector_only_mode],
"max_new_tokens": [2048],
+ "full_utterance_decode": [True],
}
inputs.append(
{
@@ -307,6 +317,7 @@ def get_base_query(use_batch_sample: bool = False, mode_tag: str = "icl") -> Que
"language": [syn_lang_single],
"x_vector_only_mode": [x_vector_only_mode],
"max_new_tokens": [2048],
+ "full_utterance_decode": [True],
}
inputs = {
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
diff --git a/examples/offline_inference/text_to_video/text_to_video.py b/examples/offline_inference/text_to_video/text_to_video.py
index 06179ef9e..7b7bcb890 100644
--- a/examples/offline_inference/text_to_video/text_to_video.py
+++ b/examples/offline_inference/text_to_video/text_to_video.py
@@ -384,13 +384,20 @@ def parse_args() -> argparse.Namespace:
default=None,
help='JSON profiler config for torch/cuda profiling, e.g. \'{"profiler":"torch","torch_profiler_dir":"./perf"}\'.',
)
- parser.add_argument(
+ quantization_group = parser.add_mutually_exclusive_group()
+ quantization_group.add_argument(
"--quantization",
type=str,
default=None,
choices=["fp8", "mxfp8", "mxfp4", "mxfp4_dualscale", "int8"],
help="Quantization method for the transformer. mxfp8: W8A8 MXFP8 (NPU). mxfp4: W4A4 MXFP4 (NPU). mxfp4_dualscale: W4A4 MXFP4 dual-scale + BF16 fallback mixed (NPU). fp8: online FP8 (GPU).",
)
+ quantization_group.add_argument(
+ "--quantization-config",
+ type=json.loads,
+ default=None,
+ help='Quantization JSON, e.g. \'{"method":"mxfp4","w4a8_fallback_steps":[0,2]}\'.',
+ )
# Distributed and parallel execution
parser.add_argument(
@@ -572,6 +579,8 @@ def main():
omni_kwargs["flow_shift"] = args.flow_shift
if args.quantization is not None:
omni_kwargs["quantization"] = args.quantization
+ if args.quantization_config is not None:
+ omni_kwargs["quantization_config"] = args.quantization_config
if args.cache_backend is not None:
omni_kwargs["cache_backend"] = args.cache_backend
omni_kwargs["cache_config"] = cache_config
diff --git a/examples/online_serving/barge_in_client.py b/examples/online_serving/barge_in_client.py
index 1ac627d35..b9d95c790 100644
--- a/examples/online_serving/barge_in_client.py
+++ b/examples/online_serving/barge_in_client.py
@@ -14,7 +14,10 @@ Scenario (see ``barge_in_client_flow.md`` for the diagram)
The demo is model-neutral: it drives any duplex model through the public
:class:`vllm_omni.clients.duplex.DuplexClient` over
-``/v1/realtime?duplex=1``. Model-specific session shape comes from the
+``/v1/realtime?duplex=1``, or — with ``--inline`` — through
+:class:`vllm_omni.clients.inline_duplex.InlineDuplexClient` on an in-process
+:class:`vllm_omni.entrypoints.duplex_omni.DuplexOmni` (no server needed; the
+model loads in this process). Model-specific session shape comes from the
per-model presets under ``vllm_omni.clients.`` selected with
``--preset`` (default ``minicpmo_4_5``, whose model decides listen/speak on
its own — no VAD).
@@ -31,6 +34,15 @@ then:
--interrupt-wav follow_up_16k.wav \
--output-dir ./duplex_out
+or, without a server, in-process (``--deploy-config`` is optional and defaults
+to the model's bundled duplex profile):
+
+ python barge_in_client.py --inline \
+ --model /path/to/MiniCPM-o-4_5 \
+ --ref-audio /path/to/reference_voice.wav \
+ --question-wav question_16k.wav \
+ --interrupt-wav follow_up_16k.wav
+
Input WAVs must be mono 16 kHz PCM16; when a preset's session takes a
different capture format (the PersonaPlex preset streams 24 kHz float32),
the audio is converted before streaming. ``--ref-audio`` is required by the
@@ -55,6 +67,7 @@ if str(REPO_ROOT) not in sys.path:
from vllm_omni.clients.duplex import ( # noqa: E402
AudioFormat,
DuplexClient,
+ DuplexClientBase,
EventCollector,
SessionConfig,
acknowledge_collected_playback,
@@ -65,7 +78,7 @@ from vllm_omni.clients.duplex import ( # noqa: E402
)
_TRANSCRIPT_DELTA_EVENTS = {
- "response.audio_transcript.delta",
+ "response.output_audio_transcript.delta",
"response.output_text.delta",
"response.text.delta",
}
@@ -139,6 +152,39 @@ def _fold_responses(collector: EventCollector) -> dict[str, dict[str, object]]:
return out
+def _make_client(args: argparse.Namespace, config: SessionConfig) -> DuplexClientBase:
+ """Websocket client by default; ``--inline`` drives an in-process DuplexOmni."""
+ if not args.inline:
+ return DuplexClient(
+ args.url,
+ model=args.model,
+ config=config,
+ reconnect=None,
+ heartbeat_interval_s=None,
+ handshake_timeout_s=args.timeout_s,
+ )
+ # Imported lazily: the in-process path loads the model (and its engine
+ # dependencies) in this process, which the websocket path never needs.
+ from vllm_omni.clients.inline_duplex import InlineDuplexClient
+ from vllm_omni.entrypoints.duplex_omni import DuplexOmni
+
+ # Models shipped with custom HF code (MiniCPM-o) need trust_remote_code to
+ # resolve their pipeline; harmless for the others.
+ omni_kwargs: dict[str, object] = {"model": args.model, "trust_remote_code": True}
+ if args.deploy_config:
+ omni_kwargs["deploy_config"] = args.deploy_config
+ omni = DuplexOmni(**omni_kwargs)
+ client = InlineDuplexClient(
+ omni,
+ model=args.model,
+ config=config,
+ handshake_timeout_s=args.timeout_s,
+ )
+ # The client owns no engine; stop the in-process one when the run ends.
+ client.owned_omni = omni # type: ignore[attr-defined]
+ return client
+
+
async def run(args: argparse.Namespace) -> int:
config = _session_config(args.preset, ref_audio_path=args.ref_audio)
question = _convert_to_session_format(read_pcm16_wav(Path(args.question_wav)), config.input_audio)
@@ -148,105 +194,117 @@ async def run(args: argparse.Namespace) -> int:
output_dir.mkdir(parents=True, exist_ok=True)
collector = EventCollector()
- client = DuplexClient(
- args.url,
- model=args.model,
- config=config,
- reconnect=None,
- heartbeat_interval_s=None,
- handshake_timeout_s=args.timeout_s,
- )
+ client = _make_client(args, config)
# GREET: open the voice session (handshake happens on enter).
- async with client:
- consume_task = asyncio.create_task(collector.consume(client))
+ try:
+ async with client:
+ consume_task = asyncio.create_task(collector.consume(client))
- # ASK: stream the question at realtime pace, then commit the turn.
- # The model decides on its own to answer (no VAD).
- print("[1] streaming question ...", file=sys.stderr)
- await client.stream_pcm(question, chunk_ms=chunk_ms)
- await client.commit(create_response=False)
- await wait_for_condition(
- lambda: collector.count("response.created") > 0,
- timeout_s=args.timeout_s,
- label="first response.created",
- )
- print("[1] assistant is answering", file=sys.stderr)
+ # ASK: stream the question at realtime pace, then commit the turn.
+ # The model decides on its own to answer (no VAD).
+ print("[1] streaming question ...", file=sys.stderr)
+ await client.stream_pcm(question, chunk_ms=chunk_ms)
+ await client.commit(create_response=False)
+ await wait_for_condition(
+ lambda: collector.count("response.created") > 0,
+ timeout_s=args.timeout_s,
+ label="first response.created",
+ )
+ print("[1] assistant is answering", file=sys.stderr)
- # Let the answer play for a moment, as a listening user would.
- await asyncio.sleep(args.listen_s)
+ # Let the answer play for a moment, as a listening user would.
+ await asyncio.sleep(args.listen_s)
- # INTERRUPT: talk over the assistant while its audio still streams.
- # The serving overlap policy decides: enough overlapped speech barges
- # in (the first response is cancelled) or a short remark is deferred
- # until the model yields the turn.
- print("[2] interrupting mid-answer ...", file=sys.stderr)
- ids_before_interrupt = set(collector.response_ids)
- await client.stream_pcm(interrupt, chunk_ms=chunk_ms)
- await client.commit(create_response=False)
+ # INTERRUPT: talk over the assistant while its audio still streams.
+ # The serving overlap policy decides: enough overlapped speech barges
+ # in (the first response is cancelled) or a short remark is deferred
+ # until the model yields the turn.
+ print("[2] interrupting mid-answer ...", file=sys.stderr)
+ ids_before_interrupt = set(collector.response_ids)
+ await client.stream_pcm(interrupt, chunk_ms=chunk_ms)
+ await client.commit(create_response=False)
- # ANSWER AGAIN: wait for a response created *after* the interruption
- # to finish. The first answer may still be streaming here; matching on
- # any response.done would let its completion satisfy the wait and cut
- # off the actual follow-up answer at close.
- def follow_up_answer_done() -> bool:
- return any(
- event.get("type") == "response.done"
- and collector.response_id(event) is not None
- and collector.response_id(event) not in ids_before_interrupt
- for event in collector.events
+ # ANSWER AGAIN: wait for a response created *after* the interruption
+ # to finish. The first answer may still be streaming here; matching on
+ # any response.done would let its completion satisfy the wait and cut
+ # off the actual follow-up answer at close.
+ def follow_up_answer_done() -> bool:
+ return any(
+ event.get("type") == "response.done"
+ and collector.response_id(event) is not None
+ and collector.response_id(event) not in ids_before_interrupt
+ for event in collector.events
+ )
+
+ await wait_for_condition(
+ follow_up_answer_done,
+ timeout_s=args.timeout_s,
+ label="response.done for the response answering the interruption",
)
- await wait_for_condition(
- follow_up_answer_done,
- timeout_s=args.timeout_s,
- label="response.done for the response answering the interruption",
- )
+ # HANG UP: report playback, close the session cleanly.
+ await acknowledge_collected_playback(client, collector)
+ await client.close(timeout_s=args.timeout_s)
+ try:
+ await asyncio.wait_for(consume_task, timeout=5.0)
+ except (asyncio.TimeoutError, asyncio.CancelledError):
+ consume_task.cancel()
- # HANG UP: report playback, close the session cleanly.
- await acknowledge_collected_playback(client, collector)
- await client.close(timeout_s=args.timeout_s)
- try:
- await asyncio.wait_for(consume_task, timeout=5.0)
- except (asyncio.TimeoutError, asyncio.CancelledError):
- consume_task.cancel()
-
- # Report: one WAV per response; statuses show what the overlap did.
- responses = _fold_responses(collector)
- summary: list[dict[str, object]] = []
- for index, (response_id, item) in enumerate(responses.items(), start=1):
- audio = item["audio"]
- assert isinstance(audio, bytes)
- sample_rate_hz = int(item["sample_rate_hz"]) # type: ignore[arg-type]
- wav_path = output_dir / f"response_{index}_{item['status']}.wav"
- if audio:
- write_pcm16_wav(wav_path, audio, sample_rate_hz=sample_rate_hz)
- summary.append(
- {
- "response_id": response_id,
- "status": item["status"],
- "audio_s": round(len(audio) / (sample_rate_hz * 2), 2),
- "text": item["text"],
- "wav": wav_path.name if audio else None,
- }
+ # Report: one WAV per response; statuses show what the overlap did.
+ responses = _fold_responses(collector)
+ summary: list[dict[str, object]] = []
+ for index, (response_id, item) in enumerate(responses.items(), start=1):
+ audio = item["audio"]
+ assert isinstance(audio, bytes)
+ sample_rate_hz = int(item["sample_rate_hz"]) # type: ignore[arg-type]
+ wav_path = output_dir / f"response_{index}_{item['status']}.wav"
+ if audio:
+ write_pcm16_wav(wav_path, audio, sample_rate_hz=sample_rate_hz)
+ summary.append(
+ {
+ "response_id": response_id,
+ "status": item["status"],
+ "audio_s": round(len(audio) / (sample_rate_hz * 2), 2),
+ "text": item["text"],
+ "wav": wav_path.name if audio else None,
+ }
+ )
+ (output_dir / "summary.json").write_text(json.dumps(summary, indent=2, ensure_ascii=False))
+ (output_dir / "events.jsonl").write_text(
+ "".join(json.dumps(event, ensure_ascii=False) + "\n" for event in collector.events),
+ encoding="utf-8",
)
- (output_dir / "summary.json").write_text(json.dumps(summary, indent=2, ensure_ascii=False))
- (output_dir / "events.jsonl").write_text(
- "".join(json.dumps(event, ensure_ascii=False) + "\n" for event in collector.events),
- encoding="utf-8",
- )
- print(json.dumps(summary, indent=2, ensure_ascii=False))
- cancelled = sum(1 for item in summary if item["status"] == "cancelled")
- print(
- f"\n{len(summary)} responses ({cancelled} interrupted); outputs in {output_dir}",
- file=sys.stderr,
- )
+ print(json.dumps(summary, indent=2, ensure_ascii=False))
+ cancelled = sum(1 for item in summary if item["status"] == "cancelled")
+ print(
+ f"\n{len(summary)} responses ({cancelled} interrupted); outputs in {output_dir}",
+ file=sys.stderr,
+ )
+ finally:
+ owned_omni = getattr(client, "owned_omni", None)
+ if owned_omni is not None:
+ owned_omni.shutdown()
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--url", default="ws://127.0.0.1:8099/v1/realtime")
- parser.add_argument("--model", default="openbmb/MiniCPM-o-4_5")
+ parser.add_argument(
+ "--model",
+ default="openbmb/MiniCPM-o-4_5",
+ help="served model name, or the model path with --inline",
+ )
+ parser.add_argument(
+ "--inline",
+ action="store_true",
+ help="run the model in this process through DuplexOmni + InlineDuplexClient instead of connecting to --url",
+ )
+ parser.add_argument(
+ "--deploy-config",
+ default=None,
+ help="deploy YAML for --inline (default: the model's bundled profile)",
+ )
parser.add_argument(
"--preset",
choices=["minicpmo_4_5", "personaplex", "none"],
diff --git a/examples/online_serving/barge_in_client_flow.md b/examples/online_serving/barge_in_client_flow.md
index d32a00e5a..9562d4c9c 100644
--- a/examples/online_serving/barge_in_client_flow.md
+++ b/examples/online_serving/barge_in_client_flow.md
@@ -1,8 +1,10 @@
# `barge_in_client.py` — what happens, at a glance
-The demo plays one everyday conversation pattern against the full-duplex
-server: ask something, get talked-over mid-answer, and see the assistant
-cope. High-level flow below — for the exact wire events, read the
+The demo plays one everyday conversation pattern against a full-duplex
+model: ask something, get talked-over mid-answer, and see the assistant
+cope. It runs either against a server over `/v1/realtime?duplex=1` or, with
+`--inline`, against an in-process `DuplexOmni` — the conversation below is the
+same in both modes. High-level flow — for the exact wire events, read the
docstring in `barge_in_client.py`.
```mermaid
@@ -48,7 +50,8 @@ answer is saved as `response_1_cancelled.wav`, a completed one as
duration per answer.
Companion: the runtime architecture lives in
-[`docs/design/fullduplex.md`](../../docs/design/fullduplex.md); the client
-API is `vllm_omni.clients.duplex.DuplexClient`, documented with the wire
-protocol in
+[`docs/design/fullduplex.md`](../../docs/design/fullduplex.md); the client API is
+`vllm_omni.clients.duplex.DuplexClient` over the websocket and
+`vllm_omni.clients.inline_duplex.InlineDuplexClient` in-process — both are
+documented with the wire protocol in
[`docs/serving/realtime_duplex_api.md`](../../docs/serving/realtime_duplex_api.md).
diff --git a/examples/online_serving/dynin_omni/README.md b/examples/online_serving/dynin_omni/README.md
deleted file mode 100644
index 6e78a5c1f..000000000
--- a/examples/online_serving/dynin_omni/README.md
+++ /dev/null
@@ -1,97 +0,0 @@
-# Dynin-Omni: Online serving
-
-## Installation
-
-Please refer to [README.md](../../../README.md).
-
-## Launch the Server
-
-First, find the `transformers_modules` path:
-
-```bash
-python - <<'PY'
-from transformers.utils.hub import HF_MODULES_CACHE
-print(HF_MODULES_CACHE)
-PY
-```
-
-Then export it for both `PYTHONPATH` and `HF_MODULES_CACHE`:
-
-```bash
-export PYTHONPATH=:$PYTHONPATH
-export HF_MODULES_CACHE=
-```
-
-Run from repository root. The 3-stage pipeline is resolved automatically from
-the model type (`vllm_omni/deploy/dynin_omni.yaml`), so no config path is
-required. Pass `--deploy-config` only to override the defaults:
-
-```bash
-vllm-omni serve snu-aidas/Dynin-Omni \
- --omni \
- --port 8091
-```
-
-If `vllm-omni` is not in PATH, run:
-
-```bash
-PYTHONPATH="$(pwd)" python -m vllm_omni.entrypoints.cli.main serve snu-aidas/Dynin-Omni \
- --omni \
- --port 8091
-```
-
-Wait until the server logs show both `All stages initialized successfully` and
-`Application startup complete.` before sending requests.
-
-## Send Requests via Python Client
-
-Move to the example directory:
-
-```bash
-cd examples/online_serving/dynin_omni
-```
-
-### Text -> Image
-
-```bash
-python openai_chat_completion_client_for_multimodal_generation.py \
- --query-type t2i \
- --prompt "A realistic indoor living room with natural daylight."
-```
-
-### Image -> Image
-
-```bash
-python openai_chat_completion_client_for_multimodal_generation.py \
- --query-type i2i \
- --image-path ../../offline_inference/dynin_omni/data/image/sofa_under_water.jpg \
- --prompt "Transform this surreal underwater setting into a realistic indoor living room while preserving the sofa layout."
-```
-
-### Text -> Speech
-
-```bash
-python openai_chat_completion_client_for_multimodal_generation.py \
- --query-type t2s \
- --prompt "Hello. This is Dynin-omni."
-```
-
-## CLI Arguments
-
-- `--query-type` (`t2i|t2s|i2i`)
-- `--model` (default: `snu-aidas/Dynin-Omni`)
-- `--host` / `--port` (OpenAI-compatible vLLM endpoint)
-- `--prompt` (custom text)
-- `--image-path` (required for `i2i`)
-- `--modalities` (optional output modalities override)
-- `--output-dir` (default: `/tmp/dynin_online_outputs`)
-
-## Notes
-
-- This client currently supports only `t2i`, `t2s`, and `i2i`.
-- `t2t` is intentionally not exposed in this online example.
-- This example intentionally uses the OpenAI-compatible chat completion endpoint.
-- Task routing for non-text outputs relies on Dynin task trigger tokens (`<|t2i|>`, `<|i2i|>`, `<|t2s|>`) injected by the client.
-- Outputs are saved under `/tmp/dynin_online_outputs` by default.
-- Dynin stage-0 warmup can take a while on first startup; do not send requests before startup completes.
-- Dynin itself can execute text-returning tasks such as `t2t`, `s2t`, `i2t`, and `v2t`, but this online serving example currently runs stage-0 in `generation` mode. In that path, the generation worker does not surface the final text as `output.text`, so OpenAI chat responses for those text-output tasks may complete internally but still return empty text.
diff --git a/examples/online_serving/dynin_omni/openai_chat_completion_client_for_multimodal_generation.py b/examples/online_serving/dynin_omni/openai_chat_completion_client_for_multimodal_generation.py
deleted file mode 100644
index 972855543..000000000
--- a/examples/online_serving/dynin_omni/openai_chat_completion_client_for_multimodal_generation.py
+++ /dev/null
@@ -1,342 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: Apache-2.0
-
-from __future__ import annotations
-
-import argparse
-import base64
-import json
-import mimetypes
-import os
-import time
-from pathlib import Path
-from typing import Any
-
-DEFAULT_MODEL = "snu-aidas/Dynin-Omni"
-DEFAULT_OUTPUT_DIR = "/tmp/dynin_online_outputs"
-
-QUERY_CHOICES = ("t2i", "t2s", "i2i")
-DEFAULT_PROMPT_BY_QUERY = {
- "t2i": "A high quality detailed living room interior photo.",
- "t2s": "Please read this sentence naturally: Hello from Dynin-Omni online serving.",
- "i2i": "Transform this image into a realistic indoor living room while preserving layout.",
-}
-DEFAULT_MODALITIES_BY_QUERY = {
- "t2i": ["image"],
- "t2s": ["audio"],
- "i2i": ["image"],
-}
-OFFLINE_PARITY_STAGE_COUNT = 3
-OFFLINE_PARITY_STAGE_SAMPLING = {
- "max_tokens": 1,
- "temperature": 0.0,
- "top_p": 1.0,
- "detokenize": False,
-}
-
-
-def _infer_mime_type(path: Path) -> str:
- mime_type, _ = mimetypes.guess_type(str(path))
- return mime_type or "application/octet-stream"
-
-
-def _encode_file_as_data_url(path: Path) -> str:
- mime_type = _infer_mime_type(path)
- raw = path.read_bytes()
- encoded = base64.b64encode(raw).decode("utf-8")
- return f"data:{mime_type};base64,{encoded}"
-
-
-def _to_image_url(path_or_url: str) -> str:
- value = str(path_or_url)
- if value.startswith(("http://", "https://", "data:image/")):
- return value
- path = Path(value).expanduser().resolve()
- if not path.exists():
- raise FileNotFoundError(f"Image file not found: {path}")
- return _encode_file_as_data_url(path)
-
-
-def _build_user_content(query_type: str, prompt: str, image_path: str | None) -> list[dict[str, Any]]:
- if query_type == "t2i":
- return [{"type": "text", "text": f"<|t2i|> {prompt}"}]
-
- if query_type == "t2s":
- return [{"type": "text", "text": f"<|t2s|> {prompt}"}]
-
- if query_type == "i2i":
- if not image_path:
- raise ValueError("--image-path is required for query type i2i")
- return [
- {"type": "text", "text": f"<|i2i|> {prompt}"},
- {"type": "image_url", "image_url": {"url": _to_image_url(image_path)}},
- ]
-
- raise ValueError(f"Unsupported query_type: {query_type}")
-
-
-def _collect_text_from_content(content: Any) -> list[str]:
- texts: list[str] = []
- if isinstance(content, str):
- stripped = content.strip()
- if stripped:
- texts.append(stripped)
- return texts
-
- if isinstance(content, dict):
- for key in ("text", "content", "value", "output_text"):
- text_value = content.get(key)
- if isinstance(text_value, str) and text_value.strip():
- texts.append(text_value.strip())
- return texts
-
- if isinstance(content, list):
- for item in content:
- texts.extend(_collect_text_from_content(item))
- return texts
-
- content_text = getattr(content, "text", None)
- if isinstance(content_text, str) and content_text.strip():
- texts.append(content_text.strip())
- content_value = getattr(content, "content", None)
- if isinstance(content_value, str) and content_value.strip():
- texts.append(content_value.strip())
- output_text = getattr(content, "output_text", None)
- if isinstance(output_text, str) and output_text.strip():
- texts.append(output_text.strip())
- return texts
-
-
-def _extract_text_outputs(chat_completion: Any) -> list[str]:
- texts: list[str] = []
- for choice in getattr(chat_completion, "choices", []) or []:
- message = getattr(choice, "message", None)
- if message is None:
- continue
- content = getattr(message, "content", None)
- texts.extend(_collect_text_from_content(content))
- reasoning_content = getattr(message, "reasoning_content", None)
- if isinstance(reasoning_content, str) and reasoning_content.strip():
- texts.append(reasoning_content.strip())
- choice_text = getattr(choice, "text", None)
- if isinstance(choice_text, str) and choice_text.strip():
- texts.append(choice_text.strip())
- top_level_output_text = getattr(chat_completion, "output_text", None)
- if isinstance(top_level_output_text, str) and top_level_output_text.strip():
- texts.append(top_level_output_text.strip())
- return texts
-
-
-def _extract_image_data_urls(chat_completion: Any) -> list[str]:
- urls: list[str] = []
- for choice in getattr(chat_completion, "choices", []) or []:
- message = getattr(choice, "message", None)
- if message is None:
- continue
- content = getattr(message, "content", None)
- if not isinstance(content, list):
- continue
- for item in content:
- if not isinstance(item, dict):
- continue
- if item.get("type") != "image_url":
- continue
- image_url = (item.get("image_url") or {}).get("url")
- if isinstance(image_url, str) and image_url.startswith("data:image"):
- urls.append(image_url)
- return urls
-
-
-def _extract_audio_payloads(chat_completion: Any) -> list[bytes]:
- payloads: list[bytes] = []
- for choice in getattr(chat_completion, "choices", []) or []:
- message = getattr(choice, "message", None)
- if message is None:
- continue
- message_audio = getattr(message, "audio", None)
- if message_audio is None:
- continue
- data_b64 = getattr(message_audio, "data", None)
- if isinstance(data_b64, str) and data_b64:
- try:
- payloads.append(base64.b64decode(data_b64))
- except Exception:
- continue
- return payloads
-
-
-def _decode_data_url(data_url: str) -> tuple[bytes, str]:
- header, data = data_url.split(",", 1)
- mime_type = "image/png"
- if ";" in header and ":" in header:
- mime_type = header.split(":", 1)[1].split(";", 1)[0]
- return base64.b64decode(data), mime_type
-
-
-def _image_extension_from_mime(mime_type: str) -> str:
- if mime_type == "image/jpeg":
- return ".jpg"
- if mime_type == "image/webp":
- return ".webp"
- if mime_type == "image/gif":
- return ".gif"
- return ".png"
-
-
-def _save_outputs(
- *,
- query_type: str,
- chat_completion: Any,
- output_dir: Path,
-) -> None:
- output_dir.mkdir(parents=True, exist_ok=True)
- stamp = time.strftime("%Y%m%d_%H%M%S")
-
- text_outputs = _extract_text_outputs(chat_completion)
- image_data_urls = _extract_image_data_urls(chat_completion)
- audio_payloads = _extract_audio_payloads(chat_completion)
-
- if text_outputs:
- text_path = output_dir / f"{query_type}_{stamp}.txt"
- text_path.write_text("\n\n".join(text_outputs) + "\n", encoding="utf-8")
- print(f"[dynin-online] text saved: {text_path}")
- print(text_outputs[0])
-
- for idx, image_url in enumerate(image_data_urls):
- image_bytes, mime_type = _decode_data_url(image_url)
- ext = _image_extension_from_mime(mime_type)
- image_path = output_dir / f"{query_type}_{stamp}_{idx}{ext}"
- image_path.write_bytes(image_bytes)
- print(f"[dynin-online] image saved: {image_path}")
-
- for idx, audio_bytes in enumerate(audio_payloads):
- audio_path = output_dir / f"{query_type}_{stamp}_{idx}.wav"
- audio_path.write_bytes(audio_bytes)
- print(f"[dynin-online] audio saved: {audio_path}")
-
- if not text_outputs and not image_data_urls and not audio_payloads:
- print("[dynin-online] no output extracted from response")
- raw_path = output_dir / f"{query_type}_{stamp}_raw_response.json"
- try:
- if hasattr(chat_completion, "model_dump_json"):
- serialized = chat_completion.model_dump_json(indent=2)
- else:
- if hasattr(chat_completion, "model_dump"):
- raw_payload: Any = chat_completion.model_dump(mode="json")
- else:
- raw_payload = chat_completion
- try:
- serialized = json.dumps(raw_payload, ensure_ascii=False, indent=2)
- except Exception:
- serialized = json.dumps({"repr": repr(raw_payload)}, ensure_ascii=False, indent=2)
- raw_path.write_text(serialized + "\n", encoding="utf-8")
- print(f"[dynin-online] raw response saved: {raw_path}")
- except Exception:
- pass
-
-
-def _build_offline_parity_sampling_params_list() -> list[dict[str, Any]]:
- return [dict(OFFLINE_PARITY_STAGE_SAMPLING) for _ in range(OFFLINE_PARITY_STAGE_COUNT)]
-
-
-def run_request(args: argparse.Namespace) -> None:
- from openai import OpenAI
-
- client = OpenAI(
- api_key="EMPTY",
- base_url=f"http://{args.host}:{args.port}/v1",
- )
- prompt = args.prompt.strip() if args.prompt else DEFAULT_PROMPT_BY_QUERY[args.query_type]
- user_content = _build_user_content(
- query_type=args.query_type,
- prompt=prompt,
- image_path=args.image_path,
- )
- if args.modalities:
- modalities = [item.strip() for item in args.modalities.split(",") if item.strip()]
- else:
- modalities = DEFAULT_MODALITIES_BY_QUERY[args.query_type]
-
- extra_body = {
- "sampling_params_list": _build_offline_parity_sampling_params_list(),
- }
- chat_completion = client.chat.completions.create(
- model=args.model,
- messages=[{"role": "user", "content": user_content}],
- modalities=modalities,
- extra_body=extra_body,
- )
- _save_outputs(
- query_type=args.query_type,
- chat_completion=chat_completion,
- output_dir=Path(args.output_dir).expanduser(),
- )
-
-
-def parse_args() -> argparse.Namespace:
- parser = argparse.ArgumentParser(description="Dynin-Omni online chat completion client")
- parser.add_argument(
- "--query-type",
- "-q",
- type=str,
- default="t2i",
- choices=QUERY_CHOICES,
- help="Dynin query type",
- )
- parser.add_argument(
- "--model",
- "-m",
- type=str,
- default=DEFAULT_MODEL,
- help="Model name/path",
- )
- parser.add_argument(
- "--host",
- type=str,
- default="localhost",
- help="Host/IP of the vLLM Omni API server",
- )
- parser.add_argument(
- "--port",
- type=int,
- default=8091,
- help="Port of the vLLM Omni API server",
- )
- parser.add_argument(
- "--prompt",
- "-p",
- type=str,
- default="",
- help="Custom prompt text",
- )
- parser.add_argument(
- "--image-path",
- "-i",
- type=str,
- default=None,
- help="Image path/URL for i2i",
- )
- parser.add_argument(
- "--modalities",
- type=str,
- default="",
- help="Comma-separated output modalities override (e.g., text,image,audio)",
- )
- parser.add_argument(
- "--output-dir",
- "-o",
- type=str,
- default=DEFAULT_OUTPUT_DIR,
- help="Directory to save outputs",
- )
- return parser.parse_args()
-
-
-def main() -> None:
- args = parse_args()
- os.environ.setdefault("HF_HUB_DISABLE_XET", "1")
- run_request(args)
-
-
-if __name__ == "__main__":
- main()
diff --git a/examples/online_serving/minicpmo/README.md b/examples/online_serving/minicpmo/README.md
index 2836eeb92..ffd50167f 100644
--- a/examples/online_serving/minicpmo/README.md
+++ b/examples/online_serving/minicpmo/README.md
@@ -183,10 +183,20 @@ python examples/online_serving/minicpmo/realtime_duplex_demo.py \
--output-dir /tmp/minicpmo_realtime_duplex_video_demo
```
-Detail inside a composite is capped by `scale_resolution=448` at
-`max_slice_nums=1`: official suggests HD slicing (`max_slice_nums=[2, 1]`) for
-stacked frames, which the duplex adapter does not implement yet. Reading small
-text or digits out of a wide scene is limited by that, not by frame timing.
+Stage 0 processes a stacked unit the way official HD mode does
+(`max_slice_nums=[2, 1]`): the base frame keeps its own 66-token block plus the
+patches `get_sliced_grid` cuts for its size at `scale_resolution=448`, and the
+composite adds one block. The wire still rejects a caller-supplied
+`max_slice_nums`, so slicing is Stage 0's decision, not the client's.
+
+That detail costs context. One 960x540 frame is 66 vision tokens per unit and a
+stacked pair is 264, on top of the unit's audio, so the Stage 0 prompt grows by
+277 tokens per second stacked against 79 with a single frame. Against the
+40960-token context this model ships with, a listen-heavy call reaches the limit
+after about 2.5 minutes stacked and about 8.5 minutes unstacked (measured on a
+264 s 960x540 clip). Duplex sessions cannot yet evict old units, so keep
+multi-minute calls on the default `--stack-frames 1`; a stacked session that
+outgrows the context ends with a context-length error.
## Open the experimental browser client
@@ -306,3 +316,12 @@ them unless `--allow-invalid-clock` is explicit.
[`examples/offline_inference/minicpmo/`](../../offline_inference/minicpmo/)
- Recipe:
[`recipes/OpenBMB/MiniCPM-o-4_5.md`](../../../recipes/OpenBMB/MiniCPM-o-4_5.md)
+
+### Shared realtime UI implementation
+
+The `python -m examples.online_serving.minicpmo.realtime_web` command remains
+available with the same MiniCPM defaults and required `--ref-audio`. Its assets
+now live in [the shared realtime UI](../realtime_web/README.md), with a dedicated
+`minicpm-native` profile preserving native duplex input, playback ACKs and camera
+frames. Qwen3 uses a separate profile in the same shell, supporting manual STT
+turns or Server VAD turns with speech interruption.
diff --git a/examples/online_serving/minicpmo/barge_in_serve.sh b/examples/online_serving/minicpmo/barge_in_serve.sh
index 6dade8c5c..1aabd3042 100644
--- a/examples/online_serving/minicpmo/barge_in_serve.sh
+++ b/examples/online_serving/minicpmo/barge_in_serve.sh
@@ -5,10 +5,10 @@
# Server side for barge_in_client.py: start MiniCPM-o 4.5 in native
# full-duplex mode.
#
-# The duplex deploy profile registers the WS /v1/duplex endpoint (with
-# /v1/realtime?duplex=1 projecting onto the same handler), enables the
-# engine duplex control plane, and bounds the Thinker/Talker/Code2Wav
-# stages to four live duplex sessions on one GPU. See
+# MiniCPM-o 4.5 declares a duplex plugin, so vllm-omni serve runs it through
+# DuplexOmni: the server is duplex-only (WS /v1/realtime?duplex=1, alias
+# /v1/duplex, plus /v1/models and /health) and bounds the
+# Thinker/Talker/Code2Wav stages to four live duplex sessions on one GPU. See
# vllm_omni/deploy/minicpmo_4_5.yaml (session_mode: duplex) for the session
# limits; every other duplex_session knob keeps its runtime default
# (idle TTL 300s, disconnect grace 30s, 16MiB pending input per session).
@@ -22,4 +22,4 @@ exec vllm-omni serve "$MODEL" \
--deploy-config vllm_omni/deploy/minicpmo_4_5.yaml \
--trust-remote-code \
--host 0.0.0.0 --port "$PORT"
-# duplex endpoints: ws://:$PORT/v1/duplex and /v1/realtime?duplex=1
+# duplex endpoint: ws://:$PORT/v1/realtime?duplex=1 (alias /v1/duplex)
diff --git a/examples/online_serving/minicpmo/realtime_duplex_demo.py b/examples/online_serving/minicpmo/realtime_duplex_demo.py
index 9cbc7f29b..6dc3fd6e7 100644
--- a/examples/online_serving/minicpmo/realtime_duplex_demo.py
+++ b/examples/online_serving/minicpmo/realtime_duplex_demo.py
@@ -63,14 +63,14 @@ class _StreamingOutputWriter:
def handle(self, event: dict[str, object]) -> None:
event_type = event.get("type")
if event_type in {
- "response.audio_transcript.delta",
+ "response.output_audio_transcript.delta",
"response.output_text.delta",
}:
delta = event.get("delta")
if isinstance(delta, str) and delta:
print(delta, end="", file=sys.stderr, flush=True)
return
- if event_type != "response.audio.delta":
+ if event_type != "response.output_audio.delta":
return
delta = event.get("delta") or event.get("audio")
@@ -445,7 +445,6 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
ref_audio=_ref_audio_data_url(args.ref_audio),
temperature=args.temperature,
),
- session_id=args.session_id,
reconnect=None,
heartbeat_interval_s=None,
handshake_timeout_s=args.timeout_s,
@@ -509,12 +508,12 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
audio = collector.audio_bytes()
first_text_at_s = collector.first_received_at(
- "response.audio_transcript.delta",
+ "response.output_audio_transcript.delta",
"response.output_text.delta",
after_s=commit_sent_at_s,
)
first_audio_at_s = collector.first_received_at(
- "response.audio.delta",
+ "response.output_audio.delta",
after_s=commit_sent_at_s,
)
response_created_at_s = collector.first_received_at(
@@ -536,7 +535,7 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
for event in collector.events
if event.get("type")
in {
- "response.audio_transcript.delta",
+ "response.output_audio_transcript.delta",
"response.output_text.delta",
}
]
@@ -638,7 +637,6 @@ def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--url", default="ws://localhost:8099/v1/realtime?duplex=1")
parser.add_argument("--model", default="openbmb/MiniCPM-o-4_5")
- parser.add_argument("--session-id")
parser.add_argument(
"--input-wav",
help="Mono 16 kHz PCM16 clip to stream. Optional when --input-video carries the audio track.",
diff --git a/examples/online_serving/minicpmo/realtime_web/server.py b/examples/online_serving/minicpmo/realtime_web/server.py
index ee78fff74..1ef12d186 100644
--- a/examples/online_serving/minicpmo/realtime_web/server.py
+++ b/examples/online_serving/minicpmo/realtime_web/server.py
@@ -1,185 +1,23 @@
-"""Static host and same-origin Realtime proxy for the full-duplex demo."""
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
-from __future__ import annotations
+"""Backward-compatible MiniCPM entry point for the shared realtime UI."""
-import argparse
-import asyncio
-import base64
-import contextlib
-import hashlib
-import json
-import logging
-from pathlib import Path
-from urllib.parse import urlencode
+from examples.online_serving.realtime_web.server import (
+ APP_DIR,
+ STATIC_DIR,
+ _join_ws_url,
+ build_app,
+)
+from examples.online_serving.realtime_web.server import (
+ main as _main,
+)
-import uvicorn
-import websockets
-from fastapi import FastAPI, WebSocket, WebSocketDisconnect
-from fastapi.responses import HTMLResponse, Response
-from fastapi.staticfiles import StaticFiles
-
-logger = logging.getLogger(__name__)
-APP_DIR = Path(__file__).parent / "app"
-STATIC_DIR = APP_DIR / "static"
-
-
-def _join_ws_url(base: str, path: str, query: str) -> str:
- return base.rstrip("/") + path + (("?" + query) if query else "")
-
-
-async def _pump_client_to_backend(client: WebSocket, backend) -> None:
- try:
- while True:
- message = await client.receive()
- if message["type"] == "websocket.disconnect":
- await backend.close()
- return
- if message.get("text") is not None:
- await backend.send(message["text"])
- elif message.get("bytes") is not None:
- await backend.send(message["bytes"])
- except (WebSocketDisconnect, websockets.ConnectionClosed):
- with contextlib.suppress(Exception):
- await backend.close()
-
-
-async def _pump_backend_to_client(client: WebSocket, backend) -> None:
- try:
- async for message in backend:
- if isinstance(message, bytes):
- await client.send_bytes(message)
- else:
- await client.send_text(message)
- except (WebSocketDisconnect, websockets.ConnectionClosed):
- return
- except RuntimeError as exc:
- if "websocket.send" in str(exc) and "websocket.close" in str(exc):
- return
- raise
-
-
-def _expected_proxy_close(exc: BaseException) -> bool:
- if isinstance(exc, (WebSocketDisconnect, websockets.ConnectionClosed, asyncio.CancelledError)):
- return True
- return isinstance(exc, RuntimeError) and "websocket.send" in str(exc) and "websocket.close" in str(exc)
-
-
-def build_app(
- *,
- ws_backend: str = "ws://127.0.0.1:8099",
- model: str = "openbmb/MiniCPM-o-4_5",
- public_realtime_url: str | None = None,
- ref_audio: str | None = None,
-) -> FastAPI:
- app = FastAPI(title="Experimental Full-Duplex Web Demo")
- index_path = APP_DIR / "index.html"
- app_version_hash = hashlib.sha256()
- for asset_path in (
- STATIC_DIR / "app.js",
- STATIC_DIR / "pcm_worklet.js",
- STATIC_DIR / "playback_worklet.js",
- ):
- app_version_hash.update(asset_path.read_bytes())
- app_version = app_version_hash.hexdigest()[:12]
-
- ref_audio_uri: str | None = None
- if ref_audio:
- ref_path = Path(ref_audio)
- if not ref_path.is_file():
- raise SystemExit(f"--ref-audio not found: {ref_audio}")
- encoded = base64.b64encode(ref_path.read_bytes()).decode("ascii")
- ref_audio_uri = f"data:audio/wav;base64,{encoded}"
-
- @app.get("/", response_class=HTMLResponse)
- def index() -> HTMLResponse:
- config = json.dumps(
- {
- "model": model,
- "realtimePath": public_realtime_url or "v1/realtime",
- "refAudio": ref_audio_uri,
- "appVersion": app_version,
- },
- ensure_ascii=True,
- )
- html = (
- index_path.read_text(encoding="utf-8")
- .replace("__FULL_DUPLEX_CONFIG__", config)
- .replace("__FULL_DUPLEX_APP_VERSION__", app_version)
- )
- return HTMLResponse(html, headers={"Cache-Control": "no-store"})
-
- @app.get("/healthz")
- def healthz() -> Response:
- return Response(content="ok", media_type="text/plain")
-
- @app.websocket("/v1/realtime")
- async def realtime_proxy(websocket: WebSocket) -> None:
- await websocket.accept()
- query = urlencode(websocket.query_params.multi_items())
- backend_url = _join_ws_url(ws_backend, "/v1/realtime", query)
- logger.info("Proxying Realtime WebSocket to %s", backend_url)
- try:
- async with websockets.connect(
- backend_url,
- max_size=64 * 1024 * 1024,
- ) as backend:
- tasks = {
- asyncio.create_task(_pump_client_to_backend(websocket, backend)),
- asyncio.create_task(_pump_backend_to_client(websocket, backend)),
- }
- done, pending = await asyncio.wait(
- tasks,
- return_when=asyncio.FIRST_COMPLETED,
- )
- for task in pending:
- task.cancel()
- await asyncio.gather(*pending, return_exceptions=True)
- for result in await asyncio.gather(*done, return_exceptions=True):
- if isinstance(result, BaseException) and not _expected_proxy_close(result):
- raise result
- except (WebSocketDisconnect, websockets.ConnectionClosed):
- return
- except Exception:
- logger.exception("Realtime WebSocket proxy failed")
- with contextlib.suppress(Exception):
- await websocket.close(code=1011)
-
- app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
- return app
+__all__ = ["APP_DIR", "STATIC_DIR", "_join_ws_url", "build_app", "main"]
def main() -> None:
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("--host", default="0.0.0.0")
- parser.add_argument("--port", type=int, default=7862)
- parser.add_argument("--ws-backend", default="ws://127.0.0.1:8099")
- parser.add_argument(
- "--public-realtime-url",
- help="Browser-visible ws:// or wss:// Realtime URL; defaults to the same-origin proxy.",
- )
- parser.add_argument("--model", default="openbmb/MiniCPM-o-4_5")
- parser.add_argument(
- "--ref-audio",
- required=True,
- help=(
- "Reference voice wav for TTS voice cloning, e.g. the "
- "official MiniCPM-o-Demo assets/ref_audio/ref_minicpm_signature.wav"
- ),
- )
- args = parser.parse_args()
-
- logging.basicConfig(level=logging.INFO)
- uvicorn.run(
- build_app(
- ws_backend=args.ws_backend,
- model=args.model,
- public_realtime_url=args.public_realtime_url,
- ref_audio=args.ref_audio,
- ),
- host=args.host,
- port=args.port,
- log_level="info",
- )
+ _main(default_profile="minicpm-native")
if __name__ == "__main__":
diff --git a/examples/online_serving/qwen3_omni/README.md b/examples/online_serving/qwen3_omni/README.md
index ef2367412..5eb9a2042 100644
--- a/examples/online_serving/qwen3_omni/README.md
+++ b/examples/online_serving/qwen3_omni/README.md
@@ -1,5 +1,7 @@
# Qwen3-Omni: Online serving
+
+
## 🛠️ Installation
Please refer to [README.md](../../../README.md)
@@ -12,6 +14,17 @@ Please refer to [README.md](../../../README.md)
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091
```
+For a local deployment with multiple API frontend processes sharing one set
+of stage engines, add `--api-server-count`:
+
+```bash
+vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --api-server-count 2 --port 8091
+```
+
+This mode currently supports local EngineCore stages. It is not available
+with headless or remote stage deployments, and pure diffusion stages remain
+single-frontend.
+
The default deployment configuration, situated at `vllm_omni/deploy/qwen3_omni_moe.yaml`, is resolved and loaded
automatically via the model registry, obviating the `--deploy-config` flag in standard deployment topologies.
Asynchronous chunk streaming operates as **enabled by default** within this bundled configuration.
@@ -19,6 +32,7 @@ Additionally, NPU, ROCm, and XPU per-platform configuration deltas are determini
`platforms`: section of the corresponding YAML.
To explicitly utilize a custom deployment YAML, mandate the configuration path accordingly:
+
```bash
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 \
--deploy-config /path/to/your_deploy_config.yaml
@@ -53,7 +67,7 @@ Use the stage-based CLI when you want to run one stage per process.
The example below pins Stage 0 to GPU 0 and Stage 1/2 to GPU 1 via
`CUDA_VISIBLE_DEVICES`.
-**1. Stage 0 (Thinker + API server)**
+#### 1. Stage 0 (Thinker + API server)
```bash
CUDA_VISIBLE_DEVICES=0 vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni \
@@ -63,7 +77,7 @@ CUDA_VISIBLE_DEVICES=0 vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni \
--omni-master-port 26000
```
-**2. Stage 1 (Talker)**
+#### 2. Stage 1 (Talker)
```bash
CUDA_VISIBLE_DEVICES=1 vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni \
@@ -73,7 +87,7 @@ CUDA_VISIBLE_DEVICES=1 vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni \
--omni-master-port 26000
```
-**3. Stage 2 (Code2Wav)**
+#### 3. Stage 2 (Code2Wav)
```bash
CUDA_VISIBLE_DEVICES=1 vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni \
@@ -153,7 +167,7 @@ parser defaults). If you don't pass a flag, the YAML value wins.
> chunked vs end-to-end modes (e.g. qwen3_tts code2wav) dispatch
> automatically based on that bool — no extra flag or variant yaml is
> needed.
-
+>
> ⚠️ **For multi-stage models that share GPUs (qwen3_omni_moe by default
> shares cuda:1 between stages 1 and 2), avoid using global memory flags.**
> A global `--gpu-memory-utilization 0.85` would apply to every stage and
@@ -252,11 +266,12 @@ pattern works for Qwen2.5-Omni — replace `base_config:` with the path to
### Send Multi-modal Request
Get into the example folder
+
```bash
cd examples/online_serving/qwen3_omni
```
-#### Send request via python
+#### Send request via python
```bash
python examples/online_serving/openai_chat_completion_client_for_multimodal_generation.py --model Qwen/Qwen3-Omni-30B-A3B-Instruct --query-type use_image --port 8091 --host "localhost"
@@ -264,7 +279,7 @@ python examples/online_serving/openai_chat_completion_client_for_multimodal_gene
#### Realtime WebSocket client (`openai_realtime_client.py`)
-[`openai_realtime_client.py`](./openai_realtime_client.py) connects to **`ws://:/v1/realtime`**, streams a local WAV as **PCM16 mono @ 16 kHz** in fixed-size chunks, and receives **`response.audio.*`**, **`response.output_text.*`**, and transcript events. By default it uses explicit `input_audio_buffer.commit`; pass **`--server-vad`** to let the server detect speech endpoints and commit the turn automatically. The client concatenates audio deltas and writes **`--output-wav`** (model output is typically **24 kHz**). Optional **`--delta-dump-dir`** saves each delta as `delta_000001.wav`, … for debugging.
+[`openai_realtime_client.py`](./openai_realtime_client.py) connects to **`ws://:/v1/realtime`**, streams a local WAV as **PCM16 mono @ 16 kHz** in fixed-size chunks, and receives **`response.output_audio.*`**, **`response.output_text.*`**, and transcript events. By default it uses explicit `input_audio_buffer.commit`; pass **`--server-vad`** to let the server detect speech endpoints and commit the turn automatically. The client concatenates audio deltas and writes **`--output-wav`** (model output is typically **24 kHz**). Optional **`--delta-dump-dir`** saves each delta as `delta_000001.wav`, … for debugging.
Streaming input works well for translation-style use cases; if the Thinker runs while input is still incomplete, consider limiting **`max_tokens`** in your session / server defaults to avoid over-generation.
@@ -289,7 +304,7 @@ python openai_realtime_client.py \
**Arguments:**
| Flag | Default | Description |
-|------|---------|-------------|
+| ------ | --------- | ------------- |
| `--url` | `ws://localhost:8091/v1/realtime` | Full WebSocket URL including path |
| `--model` | `Qwen/Qwen3-Omni-30B-A3B-Instruct` | Must match the served model (sent in `session.update`) |
| `--input-wav` | *(required)* | Input WAV: mono, 16-bit PCM, **16 kHz** |
@@ -297,7 +312,7 @@ python openai_realtime_client.py \
| `--output-text` | *(optional)* | If set, write final transcription text to this path |
| `--chunk-ms` | `200` | Size of each uploaded audio chunk (milliseconds of audio) |
| `--send-delay-ms` | `0` | Delay between chunk sends (simulate realtime upload) |
-| `--delta-dump-dir` | *(optional)* | Directory to write per-`response.audio.delta` WAV files |
+| `--delta-dump-dir` | *(optional)* | Directory to write per-`response.output_audio.delta` WAV files |
| `--server-vad` | disabled | Enable server-side Silero VAD instead of sending explicit commits |
| `--num-requests` | `1` | Number of sequential sessions (see `--concurrency`) |
| `--concurrency` | `1` | Max concurrent WebSocket sessions when `--num-requests` > 1 |
@@ -305,24 +320,17 @@ python openai_realtime_client.py \
Server VAD uses the pinned Silero v6.2 ONNX artifact from `istupakov/silero-vad-onnx` and never downloads model files while processing a session. Make the artifact available in the Hugging Face cache before startup, or configure a local artifact in the deployment YAML:
```yaml
+base_config: /path/to/vllm-omni/vllm_omni/deploy/qwen3_omni_duplex.yaml
duplex_session:
server_vad_model_path: /models/silero_vad.onnx
```
-Start from the bundled Qwen3-Omni deployment YAML, add the fields above at the top level, and serve that complete configuration:
-
-```bash
-cp vllm_omni/deploy/qwen3_omni_moe.yaml /path/to/qwen3_omni_server_vad.yaml
-# Edit /path/to/qwen3_omni_server_vad.yaml and add duplex_session.
-vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct \
- --omni \
- --port 8091 \
- --deploy-config /path/to/qwen3_omni_server_vad.yaml
-```
-
-Keep Qwen's default `session_mode: turn`; `duplex_session` enables the Realtime handler without changing scheduler
-semantics. Bare `/v1/realtime` selects that handler. The client uses `?duplex=0` only for the existing non-Server-VAD
-wire flow; `?duplex=1` remains a supported compatibility alias.
+For the engine-owned plugin, use an overlay based on
+`vllm_omni/deploy/qwen3_omni_duplex.yaml`, with `duplex_session.server_vad_model_path`
+pointing to your local artifact. See the [complete duplex setup](../realtime_web/README.md#qwen3-with-server-vad-automatic-turns).
+The default `qwen3_omni_moe.yaml` remains a turn deployment. Duplex mode serves
+`/v1/realtime?duplex=1` and `/v1/chat/completions`; the legacy STT handler requires
+a turn deployment.
The Python client supports the following command-line arguments:
@@ -334,7 +342,6 @@ The Python client supports the following command-line arguments:
- `--prompt` (or `-p`): Custom text prompt/question. If not provided, uses default prompt for the selected query type. Example: `--prompt "What are the main activities shown in this video?"`
- `--speaker`: TTS speaker/voice for audio output when requesting audio (e.g. `ethan`, `chelsie`, `aiden`). Omit to use the model default. Example: `--speaker "chelsie"`
-
For example, to use a local video file with custom prompt:
```bash
@@ -345,22 +352,22 @@ python examples/online_serving/openai_chat_completion_client_for_multimodal_gene
--prompt "What are the main activities shown in this video?"
```
-#### Send request via curl
+#### Send request via curl
```bash
bash run_curl_multimodal_generation.sh use_image
```
-
### FAQ
## Modality control
+
You can control output modalities to specify which types of output the model should generate. This is useful when you only need text output and want to skip audio generation stages for better performance.
### Supported modalities
| Modalities | Output |
-|------------|--------|
+| ------------ | -------- |
| `["text"]` | Text only |
| `["audio"]` | Audio only |
| `["text", "audio"]` | Text + Audio |
@@ -496,7 +503,9 @@ print(response.choices[0].message.audio)
Supported speaker names depend on the model (e.g. `Ethan`, `Chelsie`, `Aiden`). Omit `speaker` to use the default.
## Streaming Output
+
If you want to enable streaming output, please set the argument as below. The final output will be obtained just after generated by corresponding stage. We support both text streaming output and audio streaming output. Other modalities can output normally.
+
```bash
python examples/online_serving/openai_chat_completion_client_for_multimodal_generation.py \
--query-type use_image \
@@ -521,12 +530,14 @@ The convenience script launches both the vLLM server and Gradio demo together:
```
This script will:
+
1. Start the vLLM server in the background
2. Wait for the server to be ready
3. Launch the Gradio demo
4. Handle cleanup when you press Ctrl+C
The script supports the following arguments:
+
- `--model`: Model name/path (default: Qwen/Qwen3-Omni-30B-A3B-Instruct)
- `--server-port`: Port for vLLM server (default: 8091)
- `--gradio-port`: Port for Gradio demo (default: 7861)
@@ -537,18 +548,19 @@ The script supports the following arguments:
#### Option 2: Manual Launch (Two-Step Process)
-**Step 1: Launch the vLLM API server**
+##### Step 1: Launch the vLLM API server
```bash
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091
```
If you have a custom deploy config file:
+
```bash
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 --deploy-config /path/to/deploy_config_file
```
-**Step 2: Run the Gradio demo**
+##### Step 2: Run the Gradio demo
In a separate terminal:
@@ -561,7 +573,57 @@ Then open `http://localhost:7861/` on your local browser to interact with the we
The gradio script supports the following arguments:
- `--model`: Model name/path (should match the server model)
-- `--api-base`: Base URL for the vLLM API server (default: http://localhost:8091/v1)
+- `--api-base`: Base URL for the vLLM API server (default: )
- `--ip`: Host/IP for Gradio server (default: 127.0.0.1)
- `--port`: Port for Gradio server (default: 7861)
- `--share`: Share the Gradio demo publicly (creates a public link)
+
+## Browser voice call (shared realtime UI)
+
+Run these commands from the repository root with the vLLM-Omni environment activated.
+
+### Without VAD: manually submit each turn
+
+Start the backend:
+
+```bash
+vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091
+```
+
+In another terminal, check backend readiness and start the UI:
+
+```bash
+curl --fail http://127.0.0.1:8091/health
+python -m examples.online_serving.qwen3_omni.realtime_web \
+ --backend ws://127.0.0.1:8091 --stt --port 7863
+```
+
+Open `http://localhost:7863`, start a session, speak, and press **Send turn**.
+`--stt` is the default. Each turn uses a separate connection without shared model
+history; the conversation shown in the browser is a local log.
+
+### With VAD: automatically submit after silence
+
+Start the backend with a Server VAD deployment overlay and a compatible Silero
+ONNX artifact, following the
+[complete VAD setup](../realtime_web/README.md#qwen3-with-server-vad-automatic-turns).
+After its health check succeeds, start the UI:
+
+```bash
+python -m examples.online_serving.qwen3_omni.realtime_web \
+ --backend ws://127.0.0.1:8091 --vad --port 7863
+```
+
+Speak and pause for 500 ms to submit automatically. `--vad` configures the client;
+it does not load a VAD model or change the backend deployment by itself.
+
+VAD mode keeps uploading audio during replies and supports speech interruption.
+The engine-owned Qwen duplex plugin maintains conversation history and tracks
+played replies through playback acknowledgements. Each committed utterance starts
+a new generation request; the model does not use native streaming-input KV decoding.
+STT mode pauses microphone upload while the reply is generated and played.
+In VAD mode, click **Camera** to upload sampled frames with your spoken question;
+see [camera setup and limits](../realtime_web/README.md#qwen-vad-camera-input).
+STT remains audio-only. To switch back to manual turns, use the default turn
+deployment, restart the UI with `--stt`, refresh the page, and reconnect. Stop the existing UI before reusing port 7863.
+See the [shared UI guide](../realtime_web/README.md) for HTTPS access and testing.
diff --git a/examples/online_serving/qwen3_omni/openai_realtime_client.py b/examples/online_serving/qwen3_omni/openai_realtime_client.py
index 8a6871580..2a6bca0fe 100644
--- a/examples/online_serving/qwen3_omni/openai_realtime_client.py
+++ b/examples/online_serving/qwen3_omni/openai_realtime_client.py
@@ -6,14 +6,14 @@
This client:
1) Reads a local WAV file (must be mono, 16-bit PCM, 16kHz),
2) Streams PCM16 chunks to /v1/realtime with OpenAI-style events,
-3) Receives response.audio.*, response.output_text.*, and transcript events,
+3) Receives response.output_audio.*, response.output_text.*, and transcript events,
4) Saves synthesized audio to an output WAV file and optional text file.
-By default each ``response.audio.delta`` is treated as an **incremental PCM**
+By default each ``response.output_audio.delta`` is treated as an **incremental PCM**
chunk and all chunks are concatenated into the final ``--output-wav``.
Optional debugging: pass ``--delta-dump-dir DIR`` to write every
-``response.audio.delta`` payload as ``delta_000001.wav``, ``delta_000002.wav``, …
+``response.output_audio.delta`` payload as ``delta_000001.wav``, ``delta_000002.wav``, …
Usage:
python openai_realtime_client.py \
@@ -130,7 +130,7 @@ async def run_client(
"prefix_padding_ms": 300,
"silence_duration_ms": 500,
"create_response": True,
- "interrupt_response": False,
+ "interrupt_response": True,
},
}
},
@@ -187,7 +187,7 @@ async def run_client(
if event_type == "session.created":
continue
- if event_type == "response.audio.delta":
+ if event_type == "response.output_audio.delta":
sr = event.get("sample_rate_hz")
if isinstance(sr, int) and sr > 0:
output_sample_rate = sr
@@ -223,16 +223,17 @@ async def run_client(
print(f"{log_prefix}text usage: {usage}")
continue
- if event_type == "response.audio_transcript.done":
+ if event_type == "response.output_audio_transcript.done":
final_audio_transcript = event.get("transcript", "")
continue
- if event_type == "response.audio.done":
+ if event_type == "response.output_audio.done":
if not server_vad:
break
continue
if event_type == "response.done" and server_vad:
+ await ws.send(json.dumps({"type": "session.close"}))
break
if event_type == "error":
@@ -339,7 +340,7 @@ def main() -> None:
"--delta-dump-dir",
type=Path,
default=None,
- help="If set, each response.audio.delta is saved as delta_NNNNNN.wav under this directory",
+ help="If set, each response.output_audio.delta is saved as delta_NNNNNN.wav under this directory",
)
parser.add_argument(
"--server-vad",
diff --git a/examples/online_serving/qwen3_omni/realtime_web/__init__.py b/examples/online_serving/qwen3_omni/realtime_web/__init__.py
new file mode 100644
index 000000000..14c5daad2
--- /dev/null
+++ b/examples/online_serving/qwen3_omni/realtime_web/__init__.py
@@ -0,0 +1,4 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+
+"""Qwen3-Omni turn-based browser call UI."""
diff --git a/examples/online_serving/qwen3_omni/realtime_web/__main__.py b/examples/online_serving/qwen3_omni/realtime_web/__main__.py
new file mode 100644
index 000000000..698179528
--- /dev/null
+++ b/examples/online_serving/qwen3_omni/realtime_web/__main__.py
@@ -0,0 +1,7 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+
+from examples.online_serving.realtime_web.server import main
+
+if __name__ == "__main__":
+ main(default_profile="qwen3-turn")
diff --git a/examples/online_serving/realtime_web/README.md b/examples/online_serving/realtime_web/README.md
new file mode 100644
index 000000000..854aa4718
--- /dev/null
+++ b/examples/online_serving/realtime_web/README.md
@@ -0,0 +1,221 @@
+# Shared Realtime Web UI
+
+One browser shell provides microphone capture, speaker playback, a level meter,
+conversation text, and an event log. Model profiles own the wire protocol.
+This implements [RFC #7222](https://github.com/vllm-project/vllm-omni/issues/7222).
+
+| Profile | Turn control | Camera | Playback acknowledgement |
+| --- | --- | --- | --- |
+| `minicpm-native` | Model-controlled listen/speak, continuous audio input | Frames accompany audio | Yes |
+| `qwen3-turn --stt` (default) | User presses **Send turn** | No | No |
+| `qwen3-turn --vad` | Server detects trailing silence; speech interrupts replies | Sampled frames with each spoken turn | Yes |
+
+Qwen3 VAD uses an engine-owned duplex plugin: microphone upload continues
+while replies stream, and speech can interrupt generation and playback. The
+checkpoint still generates committed turns; this is not native streaming-input
+KV decoding. STT remains explicit-turn and audio-only.
+
+## MiniCPM: existing command stays valid
+
+Start your MiniCPM backend using the [existing deployment instructions](../minicpmo/README.md), then:
+
+```bash
+python -m examples.online_serving.minicpmo.realtime_web \
+ --ws-backend ws://127.0.0.1:8099 \
+ --ref-audio /path/to/ref_minicpm_signature.wav
+```
+
+The compatibility wrapper serves the shared assets with `minicpm-native`.
+The current native query, `extra_body`, reference voice, continuous microphone
+upload, camera frames and playback acknowledgements are retained.
+
+## Qwen3: explicit-turn STT adapter
+
+Start the backend, then the UI in a second terminal:
+
+```bash
+vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091
+
+python -m examples.online_serving.qwen3_omni.realtime_web \
+ --backend ws://127.0.0.1:8091 --stt --port 7863
+```
+
+Open `http://localhost:7863`, start a session, speak, and press **Send turn**.
+Wait for the answer to finish playing before speaking again. The UI opens a
+fresh STT connection for each turn; the displayed conversation is a local log,
+and **previous turns are not provided as model history**. The STT endpoint
+accepts only model selection, so system-prompt controls are hidden in this mode.
+
+The adapter follows the shipped `qwen3_omni/openai_realtime_client.py` and
+`vllm_omni/entrypoints/openai/realtime_connection.py`:
+
+1. Explicit `duplex=0` selects the legacy STT handler on a turn deployment.
+ It does not enable STT on a duplex deployment.
+2. Send `{type: "session.update", model: ...}` and `commit(final=false)`.
+3. Stream mono PCM16 at 16 kHz; **Send turn** flushes buffered audio, then sends `commit(final=true)`.
+4. `response.output_audio.delta.audio` contains PCM; `response.output_audio.done`
+ terminates this adapter, including a response with no audio.
+5. The local implementation emits **model output** on `transcription.*`, so the
+ UI displays it as assistant text. This differs from the early RFC's suggested
+ user-transcript mapping. The VAD adapter also supports `response.output_text.*`
+ and audio-transcript events without duplicating the answer.
+
+## Qwen3 with Server VAD (automatic turns)
+
+Run commands from the repository root with the vLLM-Omni environment activated.
+Prepare the pinned Silero v6.2 ONNX artifact as described in the
+[Qwen3 Server VAD instructions](../qwen3_omni/README.md#realtime-websocket-client-openai_realtime_clientpy).
+The backend requires ONNX Runtime and a compatible local artifact; setting
+`--vad` on the UI alone does not enable VAD on the backend.
+
+Create an overlay named `qwen3_vad.yaml`, replacing both paths with absolute paths
+on the backend host:
+
+```yaml
+base_config: /path/to/vllm-omni/vllm_omni/deploy/qwen3_omni_duplex.yaml
+duplex_session:
+ server_vad_model_path: /path/to/silero_vad.onnx
+```
+
+The base configuration enables duplex mode and sets the per-prompt audio and image limits.
+
+Start the backend:
+
+```bash
+vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 \
+ --deploy-config qwen3_vad.yaml
+```
+
+In a second terminal, wait until the health check succeeds, then start the UI:
+
+```bash
+curl --fail http://127.0.0.1:8091/health
+python -m examples.online_serving.qwen3_omni.realtime_web \
+ --backend ws://127.0.0.1:8091 --vad --port 7863
+```
+
+Open `http://localhost:7863` and start a session. Speak and pause: the server
+commits the turn automatically after 500 ms of silence. No **Send turn** action
+is needed. Defaults are a speech threshold of 0.5 and 300 ms of prefix padding.
+Speak again to interrupt an answer. The engine owns input buffering, VAD,
+request cancellation, playback acknowledgements, and conversation history.
+Each committed utterance starts a normal Thinker → Talker → Code2Wav request;
+it does not append input to a live Qwen KV cache. The capability response reports
+`implementation_level: turn_based_duplex` and `supports_core_resumable_request: false`.
+
+The plugin retains up to four audio inputs (including the current turn), pruning
+older audio at an 8 MiB base64 budget; one current utterance is bounded by the
+engine's pending-input limit. The UI reports playback progress both on normal
+drain and on interruption. Qwen does not provide text/audio alignment, so assistant
+text enters history only after the complete audio response has been generated and
+acknowledged as played. A partially played answer contributes no guessed text
+prefix. Image conversation items remain available until deleted. Model context limits still apply.
+The bundled duplex deploy allows four audio inputs and eight images per prompt.
+Use headphones to avoid speaker audio triggering VAD interruption. Reference
+voices and tool calls are not supported by this plugin.
+
+### Switching between VAD and manual turns
+
+| Mode | Backend | UI flag | Submit a turn |
+| --- | --- | --- | --- |
+| Without VAD | Default Qwen turn deployment | `--stt` (default) | Press **Send turn** |
+| With VAD | Server VAD configuration and Silero artifact | `--vad` | Pause after speaking |
+
+Stop the existing UI process before starting another on the same port, then
+refresh the browser and reconnect. To use STT, start a backend with the default
+turn deployment and restart the UI with `--stt`. The duplex backend exposes
+Realtime duplex and ChatCompletion; it does not expose the legacy STT handler.
+To enable VAD on a default backend, first restart that backend with the overlay.
+Make `--backend` match the actual backend port; the UI and backend use separate
+ports. `Connection refused` means the target backend is unavailable: check its
+startup log and `/health` before connecting.
+
+VAD connects with `duplex=1` and sends nested `session.audio.input` configuration
+with `create_response: true` and `interrupt_response: true`. It sends playback
+ACKs when speaker audio drains. `response.audio.done` / `response.output_audio.done`
+only drain playback; `response.done` terminates a turn. Backpressure displays a
+notice without pausing the microphone. Failed handshakes time out after 15 seconds;
+Silero initialization failures display a specific error.
+
+After updating the backend Python code, restart the backend, refresh the UI, and
+start a new session so the plugin and browser assets use the updated code. A
+successful `/health` check confirms readiness, not which deployment mode is active.
+
+## Qwen VAD camera input
+
+After starting a session with `--vad`, click **Camera** and allow access. The UI
+sends one JPEG per second, resized to a longest side of 448 pixels, independently
+of microphone uploads (including while muted). Each frame is a user conversation
+item using the [OpenAI Realtime image input](https://developers.openai.com/api/docs/guides/realtime-conversations#image-inputs) shape:
+
+```json
+{"type":"conversation.item.create","item":{"id":"camera_1","type":"message","role":"user","content":[{"type":"input_image","image_url":"data:image/jpeg;base64,..."}]}}
+```
+
+Images become conversation context immediately; they do not start a response.
+Speak and let VAD commit your audio, or send `response.create` after adding an
+image/text item. A manual `input_audio_buffer.commit` only commits audio; follow
+it with `response.create` to generate. Images persist across audio commits,
+interruptions, and audio-buffer clears. Remove them with `conversation.item.delete`.
+The browser retains eight camera items and deletes the oldest before adding a
+ninth. Camera-off stops capture but leaves those items in the conversation.
+
+This deployment accepts JPEG/PNG Base64 data URLs with a total image-context
+budget of eight images and 4 MiB; external image URLs are not supported. Clients
+must delete old image items when reaching the limit. There is no precise
+video/audio timestamp alignment or native streaming-video KV update.
+
+### Protocol compatibility boundary
+
+Qwen VAD uses `session.update`, 24 kHz PCM16 `input_audio_buffer.append`,
+`conversation.item.create` with `input_image`/`input_text`, and `response.create`.
+The engine resamples input to Qwen's 16 kHz processing rate. Qwen rejects the
+custom `video_frames` field on an append: it answers whole turns, so it has no
+audio unit for a frame track to align to. MiniCPM keeps that field for its
+native camera protocol, where Stage 0 interleaves frames at unit boundaries.
+
+This is a supported subset, not full OpenAI Realtime API compatibility.
+`duplex=1`, `overlap_policy`, `playback.ack`, and `session.close` remain vLLM-Omni
+extensions. The legacy `--stt` mode is unchanged and is not the standard Realtime
+conversation API. This demo does not add WebRTC, semantic VAD, or tool calling.
+
+## Shared host options
+
+The shared entry point can select either profile explicitly:
+
+```bash
+python -m examples.online_serving.realtime_web --profile qwen3-turn \
+ --backend ws://127.0.0.1:8091 --stt --port 7863
+```
+
+- `--backend` / `--ws-backend`: backend WebSocket origin (not the full `/v1/realtime` URL).
+- `--model`: override the wrapper's model name.
+- `--public-realtime-url`: optional browser-visible WebSocket URL; otherwise the
+ static host proxies `/v1/realtime` on the same origin.
+- `--host`, `--port`: UI bind address and port (default port 7862).
+- `--ref-audio`: required for MiniCPM; rejected for Qwen3.
+
+Microphone and camera access require `localhost` or HTTPS. For a remote backend,
+use an SSH tunnel to the UI host or an HTTPS reverse proxy with WebSocket support.
+An explicit `wss://` public URL retains its scheme.
+
+## Validation
+
+```bash
+node --test tests/examples/test_realtime_web_profiles.cjs
+pytest tests/examples/test_minicpmo_realtime_web_server.py \
+ tests/examples/test_minicpmo_realtime_web_static.py \
+ tests/examples/test_realtime_web_profiles.py
+pytest -m cpu tests/model_executor/models/qwen3_omni/test_duplex_plugin.py \
+ tests/engine/duplex/test_session_runner.py \
+ tests/engine/test_duplex_orchestrator.py
+```
+
+These CPU tests cover profile messages, two STT turns, VAD terminal/drain ordering,
+backpressure, native capture during playback, server config injection, and shared
+worklets. Backend regressions cover current audio inclusion in history-based
+prompts, delayed playback ACK ordering, and no extra generation during silence
+after a completed VAD turn. Hardware acceptance still requires a MiniCPM call
+with camera/barge-in and two Qwen turns against each enabled backend, including
+speech interruption in VAD mode; simulated tests do not
+establish model quality or hardware latency.
diff --git a/examples/online_serving/realtime_web/__init__.py b/examples/online_serving/realtime_web/__init__.py
new file mode 100644
index 000000000..9e82f8967
--- /dev/null
+++ b/examples/online_serving/realtime_web/__init__.py
@@ -0,0 +1,4 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+
+"""Shared browser audio UI and model-specific realtime profiles."""
diff --git a/examples/online_serving/realtime_web/__main__.py b/examples/online_serving/realtime_web/__main__.py
new file mode 100644
index 000000000..618f3c542
--- /dev/null
+++ b/examples/online_serving/realtime_web/__main__.py
@@ -0,0 +1,7 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+
+from .server import main
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/online_serving/minicpmo/realtime_web/app/index.html b/examples/online_serving/realtime_web/app/index.html
similarity index 76%
rename from examples/online_serving/minicpmo/realtime_web/app/index.html
rename to examples/online_serving/realtime_web/app/index.html
index 46ab15907..a38a731e4 100644
--- a/examples/online_serving/minicpmo/realtime_web/app/index.html
+++ b/examples/online_serving/realtime_web/app/index.html
@@ -4,37 +4,35 @@
- MiniCPM-o 4.5 Voice
+ Omni Realtime Voice
+
Start session
+ Send turn
Mute
Camera
-
+
Prompt preset
- Omni (official omni preset)
- 中文通话 (official audio preset)
- English Call (official audio preset)
- Custom
-
+
Mic
@@ -53,7 +51,7 @@
Live transcript
Conversation
-
Model-controlled listen / speak
+
Start a session and speak naturally.
@@ -73,7 +71,10 @@
-
+
+
+
+