Merge main and preserve multi-API engine and duplex guards
Signed-off-by: Sy03 <1370724210@qq.com>
This commit is contained in:
commit
daf91b4d7a
329 files changed
+36428
-35780
No files matched your search
@@ -205,6 +205,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
|
||||
|
||||
@@ -38,9 +38,13 @@ x-minicpmo-4-5: &minicpmo_4_5
|
||||
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/clients/
|
||||
- vllm_omni/entrypoints/duplex_request_client.py
|
||||
- vllm_omni/outputs/duplex.py
|
||||
- vllm_omni/model_executor/duplex_sampling.py
|
||||
- vllm_omni/experimental/fullduplex/
|
||||
@@ -249,7 +253,11 @@ source_file_dependencies:
|
||||
- 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/
|
||||
|
||||
omni_minicpmo_4_5_duplex_perf:
|
||||
|
||||
@@ -66,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"
|
||||
|
||||
@@ -191,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
|
||||
|
||||
@@ -139,6 +139,80 @@ You can configure per-stage sampling parameters for multi-stage models.
|
||||
>
|
||||
> Do not use `frame` and `references` together. 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 `<Picture 1>`, `<Video 1>`, and `<Audio 1>` 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.
|
||||
|
||||
#### 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).
|
||||
|
||||
@@ -10,6 +10,9 @@ 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,
|
||||
@@ -918,6 +921,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)},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -934,21 +941,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,)
|
||||
@@ -20,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,
|
||||
@@ -31,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__)
|
||||
|
||||
@@ -304,38 +311,44 @@ 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] = []
|
||||
video_task: str | None = None
|
||||
|
||||
if frame is not None:
|
||||
input_reference_image = frame
|
||||
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"
|
||||
|
||||
@@ -348,21 +361,6 @@ 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",
|
||||
)
|
||||
|
||||
# === 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)
|
||||
|
||||
@@ -30,6 +30,12 @@ class MiniMaxH3ModelSpecificParams(dict):
|
||||
pass
|
||||
|
||||
|
||||
MAX_REFERENCE_IMAGES = 9
|
||||
MAX_REFERENCE_VIDEOS = 3
|
||||
MAX_REFERENCE_AUDIOS = 3
|
||||
MAX_TOTAL_REFERENCES = 12
|
||||
|
||||
|
||||
class VideoReferences(dict):
|
||||
pass
|
||||
|
||||
|
||||
+786
@@ -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 <Picture 1>. 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 <Picture 1>, <Video 1>, <Audio 1> 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
|
||||
@@ -82,6 +82,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 |
|
||||
|
||||
@@ -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. |
|
||||
@@ -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. Duplex models (pipelines with a `duplex_plugin`, e.g. MiniCPM-o 4.5) set `"duplex"`; `vllm-omni serve` then serves it over `/v1/realtime?duplex=1` and `/v1/chat/completions`, both on duplex sessions. |
|
||||
| `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). |
|
||||
|
||||
@@ -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`.
|
||||
+359
-1056
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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,13 @@ 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/**
|
||||
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 +43,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:
|
||||
@@ -53,7 +56,9 @@ validation_paths:
|
||||
- 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/**
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -107,7 +107,7 @@ th {
|
||||
| `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` | ✅︎ | ✅︎ | | | — |
|
||||
|
||||
+12
-5
@@ -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
|
||||
|
||||
|
||||
@@ -15,40 +15,52 @@ 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`). That declaration alone
|
||||
decides it: `vllm-omni serve` constructs `DuplexOmni` instead of `AsyncOmni`,
|
||||
and every surface the server exposes is backed by a duplex session. 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". Turn-based use of such a model stays available offline through the
|
||||
Python API (`Omni` / `AsyncOmni`).
|
||||
|
||||
`/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 model started with a deploy configuration that does not set it fails
|
||||
at startup rather than falling back to turn-based serving.
|
||||
|
||||
!!! 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).
|
||||
@@ -101,22 +113,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.
|
||||
+214
-135
@@ -1,17 +1,18 @@
|
||||
# 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 — currently
|
||||
MiniCPM-o 4.5 only — 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).
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -26,11 +27,38 @@ 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://<host>:8099/v1/realtime` (this page) and `ws://<host>: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://<host>:8099/v1/realtime?duplex=1` (this page; `ws://<host>: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 by a duplex model.
|
||||
|
||||
`/v1/chat/completions` is not a turn-based path on a duplex server: each
|
||||
request opens a short-lived duplex session and reads the answer off it. Audio
|
||||
content becomes an ordinary committed turn; text is seeded as the session's
|
||||
opening turn, because a model-native model takes a turn only when it hears
|
||||
speech. A model that cannot be seeded that way declares so, and a text request
|
||||
to it is refused with 400. Two consequences are worth sizing for:
|
||||
|
||||
- **Every request holds an admission slot** for its lifetime, so
|
||||
`duplex_session.max_sessions` caps HTTP concurrency as well as websocket
|
||||
sessions. Beyond it a request is refused with HTTP 503.
|
||||
- **Latency follows the model's own clock.** A model-native session generates
|
||||
per audio unit, so even a short answer costs what that many units cost --
|
||||
noticeably more than the same model would take turn-based.
|
||||
|
||||
`n > 1`, `logprobs`, `tools`, and image or video content parts have no
|
||||
representation in one duplex turn and are refused with HTTP 400 -- a Realtime
|
||||
conversation item carries text and audio only. `temperature`,
|
||||
`max_completion_tokens`, `modalities` and
|
||||
`chat_template_kwargs.use_tts_template` apply; the other
|
||||
`chat_template_kwargs` keys are logged as ignored. `extra_body` is passed to
|
||||
the session unchanged, exactly as a websocket client's session `extra_body`
|
||||
is, which is how a request asking for `modalities: ["text", "audio"]` supplies
|
||||
the `ref_audio` MiniCPM-o requires for audio output. A model that should not
|
||||
serve the route lists it in the deploy config's `endpoint_restrictions`,
|
||||
exactly as a turn-based model does.
|
||||
|
||||
### Run the Example Client
|
||||
|
||||
@@ -81,25 +109,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 +222,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 +265,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 +338,26 @@ its protocol tests.
|
||||
|
||||
### Endpoint and transport
|
||||
|
||||
The endpoint is `ws(s)://<host>/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)://<host>/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)://<host>/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-<uuid>`,
|
||||
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_<response_id>`) names one assistant utterance.
|
||||
@@ -281,12 +371,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` |
|
||||
@@ -316,14 +407,8 @@ work unmodified. The overlap falls into the three tiers used by the event catalo
|
||||
| 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` |
|
||||
|
||||
Audio/transcript output uses only the current (non-beta) OpenAI event names
|
||||
(`response.output_audio.delta`, not the deprecated `response.audio.delta`),
|
||||
matching `openai.types.realtime` in the official `openai` Python SDK and what
|
||||
clients built against it (e.g. `livekit-plugins-openai`'s non-Azure code path)
|
||||
expect; a client still on the old beta SDK types would not recognize these
|
||||
events. Session fields and `conversation.item.*` events, in contrast, are
|
||||
deliberately dual: the server emits and accepts **both** the beta and the GA
|
||||
spellings at once (`output_modalities` alongside `modalities`,
|
||||
The server deliberately emits and accepts **both** the beta and the GA
|
||||
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.
|
||||
|
||||
@@ -333,7 +418,7 @@ 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.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`) |
|
||||
@@ -349,14 +434,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`.
|
||||
@@ -365,11 +445,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
|
||||
|
||||
@@ -433,11 +513,15 @@ 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
|
||||
|
||||
@@ -447,18 +531,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`. |
|
||||
@@ -469,10 +553,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. |
|
||||
@@ -508,8 +592,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
|
||||
@@ -528,7 +612,6 @@ additionally carries `"server_event_seq": <int>` (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",
|
||||
@@ -541,7 +624,6 @@ additionally carries `"server_event_seq": <int>` (omitted below for brevity).
|
||||
"idle_timeout_s": 300,
|
||||
"extra_body": {
|
||||
"auto_response": true,
|
||||
"native_duplex": true,
|
||||
"force_listen_count": 0
|
||||
}
|
||||
}
|
||||
@@ -553,13 +635,12 @@ additionally carries `"server_event_seq": <int>` (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",
|
||||
@@ -612,7 +693,7 @@ additionally carries `"server_event_seq": <int>` (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`
|
||||
@@ -622,7 +703,7 @@ additionally carries `"server_event_seq": <int>` (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`
|
||||
@@ -638,7 +719,7 @@ additionally carries `"server_event_seq": <int>` (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`)
|
||||
@@ -646,8 +727,7 @@ additionally carries `"server_event_seq": <int>` (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
|
||||
}
|
||||
@@ -659,8 +739,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…"
|
||||
}
|
||||
@@ -669,19 +749,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
|
||||
@@ -772,7 +852,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,
|
||||
@@ -802,7 +882,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": {"…": "…"}}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -911,7 +991,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}}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -947,7 +1027,7 @@ 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}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -964,7 +1044,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,
|
||||
@@ -1029,7 +1109,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}}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1055,9 +1135,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"}}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1104,7 +1184,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
|
||||
@@ -1120,7 +1200,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,
|
||||
@@ -1145,20 +1225,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
|
||||
@@ -1172,12 +1249,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
|
||||
@@ -1203,71 +1274,79 @@ 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.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 duplex session surfaces only: the websocket
|
||||
route and `/v1/chat/completions`, the latter on a session per request (see
|
||||
[Start the Server](#start-the-server)). The other turn-based HTTP routes
|
||||
report "not available" on a MiniCPM-o 4.5 server; turn-based use of the
|
||||
model stays available offline through `Omni` / `AsyncOmni`.
|
||||
@@ -6,6 +6,7 @@ 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, MOSS-TTS-Nano, and Breeze-TTS-2.
|
||||
|
||||
@@ -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
|
||||
@@ -761,6 +784,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 |
|
||||
|
||||
@@ -50,6 +50,14 @@ WebSocket /v1/video/chat/stream
|
||||
| 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 |
|
||||
|
||||
@@ -109,6 +109,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
|
||||
|
||||
@@ -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 <model> \
|
||||
vllm serve <model> --omni \
|
||||
--diffusion-attention-backend FASTVIDEO_VSA \
|
||||
--fastvideo-vsa-topk 64
|
||||
```
|
||||
|
||||
The backwards-compatible environment variable selects the backend with the
|
||||
default `topk=64`:
|
||||
<details markdown="1">
|
||||
<summary>Alternative configuration formats</summary>
|
||||
|
||||
Equivalent structured configuration:
|
||||
|
||||
```bash
|
||||
export DIFFUSION_ATTENTION_BACKEND=FASTVIDEO_VSA
|
||||
vllm-omni serve <model>
|
||||
```
|
||||
|
||||
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 <model> \
|
||||
vllm serve <model> --omni \
|
||||
--diffusion-attention-config \
|
||||
'{"default":{"backend":"FASTVIDEO_VSA","fastvideo_vsa_topk":64}}'
|
||||
```
|
||||
@@ -73,8 +76,15 @@ stages:
|
||||
fastvideo_vsa_topk: 64
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -212,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.
|
||||
|
||||
@@ -19,7 +19,7 @@ list of supported architectures across all modalities, see
|
||||
| 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 |
|
||||
@@ -167,7 +167,7 @@ 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)
|
||||
```
|
||||
@@ -491,8 +491,8 @@ Not yet — PR1 is zero-shot only (the learned `null_prefix` default voice). Ref
|
||||
### 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.
|
||||
|
||||
@@ -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.<model>`` 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,
|
||||
@@ -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"],
|
||||
|
||||
@@ -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).
|
||||
@@ -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://<host>:$PORT/v1/duplex and /v1/realtime?duplex=1
|
||||
# duplex endpoint: ws://<host>:$PORT/v1/realtime?duplex=1 (alias /v1/duplex)
|
||||
@@ -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,
|
||||
@@ -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.",
|
||||
|
||||
@@ -86,7 +86,6 @@
|
||||
url.protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
url.searchParams.set('duplex', '1');
|
||||
url.searchParams.set('model', config.model || 'openbmb/MiniCPM-o-4_5');
|
||||
url.searchParams.set('native_duplex', '1');
|
||||
url.searchParams.set('autostart', '0');
|
||||
return url.toString();
|
||||
}
|
||||
@@ -458,7 +457,6 @@
|
||||
settled = true;
|
||||
const extraBody = {
|
||||
auto_response: true,
|
||||
native_duplex: true,
|
||||
};
|
||||
const session = {
|
||||
modalities: ['audio', 'text'],
|
||||
|
||||
@@ -17,6 +17,7 @@ For the full list of supported architectures across all modalities, see
|
||||
| Breeze-TTS-2 | `BreezeBlue/Breeze-TTS-2` | ✓ (`ref_audio`+`ref_text`) | ✓ (PCM stream) | speaker tags (`S0`..`S9`, default `S0`) | — |
|
||||
| Audio8 TTS Preview | `Audio8/Audio8-TTS-Preview-0.6b` | ✓ (`ref_audio`+`ref_text`) | ✓ (PCM stream) | uploaded audio voice only; no presets | ✓ |
|
||||
| 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 | — |
|
||||
| GLM-TTS | `zai-org/GLM-TTS` | ✓ (`ref_audio`+`ref_text`, required) | ✓ (PCM stream) | — | ✓ |
|
||||
| IndexTTS-2 | `IndexTeam/IndexTTS-2` | ✓ (`ref_audio` or uploaded `voice`) | `stream=true` response, non-chunk | uploaded audio voice only; no presets | — |
|
||||
| IndexTTS-2.5 | native `checkpoints/` bundle | ✓ (`ref_audio` or uploaded `voice`) | `stream=true` response, non-chunk | uploaded audio voice only; no presets | — |
|
||||
@@ -96,12 +97,52 @@ 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, 22.05 kHz for IndexTTS-2, and 24 kHz for many 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 and IndexTTS-2, and 24 kHz for many others).
|
||||
|
||||
For full request-shape documentation (all parameters, response formats, error codes), see the [Speech API reference](../../../docs/serving/speech_api.md).
|
||||
|
||||
---
|
||||
|
||||
## 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](../../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 (same note as offline). 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
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
## dots.tts
|
||||
|
||||
Single-stage, text-only TTS at 48 kHz. The current integration supports
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
# Launch vLLM-Omni server for Gepard-1.0 TTS (zero-shot default voice).
|
||||
#
|
||||
# Usage:
|
||||
# ./run_server.sh
|
||||
# CUDA_VISIBLE_DEVICES=0 ./run_server.sh
|
||||
#
|
||||
# Packaged deploy config: vllm_omni/deploy/gepard.yaml (async_chunk=false,
|
||||
# 22.05 kHz mono, default seed 42 until the YAML seed is removed).
|
||||
# Cold start downloads the talker and NeMo NanoCodec; the serve default of
|
||||
# --stage-init-timeout 300 is too tight (offline end2end.py uses 900).
|
||||
#
|
||||
# On a host whose CUDA toolkit cannot JIT FlashInfer (no nvcc/ninja, or a
|
||||
# consumer Blackwell sm_120 card), also:
|
||||
# export VLLM_USE_FLASHINFER_SAMPLER=0
|
||||
|
||||
set -e
|
||||
|
||||
MODEL="${MODEL:-nineninesix/gepard-1.0}"
|
||||
PORT="${PORT:-8091}"
|
||||
|
||||
echo "Starting Gepard-1.0 server with model: $MODEL"
|
||||
|
||||
vllm-omni serve "$MODEL" \
|
||||
--host 0.0.0.0 \
|
||||
--port "$PORT" \
|
||||
--trust-remote-code \
|
||||
--omni \
|
||||
--stage-init-timeout 900 \
|
||||
--deploy-config vllm_omni/deploy/gepard.yaml
|
||||
@@ -0,0 +1,116 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Client for Gepard-1.0 TTS via /v1/audio/speech.
|
||||
|
||||
Gepard is zero-shot: omit ``voice`` or pass ``"default"``. Output is 22.05 kHz
|
||||
mono. ``seed`` is optional; the packaged deploy YAML currently pins ``seed: 42``
|
||||
until that default is removed, so requests without an explicit seed are still
|
||||
deterministic.
|
||||
|
||||
Examples:
|
||||
python speech_client.py --text "Hello, this is Gepard speaking."
|
||||
python speech_client.py --text "Hello, this is Gepard speaking." --seed 7
|
||||
python speech_client.py --text "Hello, this is Gepard speaking." --stream --output output.pcm
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
import httpx
|
||||
|
||||
DEFAULT_API_BASE = "http://localhost:8091"
|
||||
DEFAULT_API_KEY = "EMPTY"
|
||||
DEFAULT_MODEL = "nineninesix/gepard-1.0"
|
||||
|
||||
|
||||
def run_tts(args) -> None:
|
||||
payload = {
|
||||
"model": args.model,
|
||||
"input": args.text,
|
||||
"voice": args.voice,
|
||||
"response_format": args.response_format,
|
||||
}
|
||||
if args.seed is not None:
|
||||
payload["seed"] = args.seed
|
||||
if args.max_new_tokens is not None:
|
||||
payload["max_new_tokens"] = args.max_new_tokens
|
||||
if args.stream:
|
||||
payload["stream"] = True
|
||||
payload["stream_format"] = "audio"
|
||||
if args.response_format not in ("pcm", "wav"):
|
||||
print(f"Note: streaming requires pcm/wav; overriding --response-format {args.response_format} to 'pcm'")
|
||||
payload["response_format"] = "pcm"
|
||||
|
||||
print(f"Model: {args.model}")
|
||||
print(f"Text: {args.text}")
|
||||
print(f"Voice: {args.voice}")
|
||||
if args.seed is not None:
|
||||
print(f"Seed: {args.seed}")
|
||||
print("Generating audio...")
|
||||
|
||||
api_url = f"{args.api_base}/v1/audio/speech"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {args.api_key}",
|
||||
}
|
||||
|
||||
if args.stream:
|
||||
output_path = args.output or ("output.wav" if payload["response_format"] == "wav" else "output.pcm")
|
||||
with httpx.Client(timeout=300.0) as client:
|
||||
with client.stream("POST", api_url, json=payload, headers=headers) as resp:
|
||||
if resp.status_code != 200:
|
||||
print(f"Error: {resp.status_code}")
|
||||
print(resp.read().decode())
|
||||
return
|
||||
total_bytes = 0
|
||||
with open(output_path, "wb") as f:
|
||||
for chunk in resp.iter_bytes():
|
||||
f.write(chunk)
|
||||
total_bytes += len(chunk)
|
||||
print(f"Streamed {total_bytes} bytes to: {output_path}")
|
||||
return
|
||||
|
||||
with httpx.Client(timeout=300.0) as client:
|
||||
response = client.post(api_url, json=payload, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"Error: {response.status_code}")
|
||||
print(response.text)
|
||||
return
|
||||
|
||||
output_path = args.output or "output.wav"
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(response.content)
|
||||
print(f"Audio saved to: {output_path}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Gepard-1.0 TTS client")
|
||||
parser.add_argument("--api-base", default=DEFAULT_API_BASE, help="API base URL")
|
||||
parser.add_argument("--api-key", default=DEFAULT_API_KEY, help="API key")
|
||||
parser.add_argument("--model", "-m", default=DEFAULT_MODEL, help="Model name")
|
||||
parser.add_argument("--text", required=True, help="Text to synthesize")
|
||||
parser.add_argument("--voice", default="default", help="Voice name (only 'default' is supported)")
|
||||
parser.add_argument("--seed", type=int, default=None, help="Sampling seed")
|
||||
parser.add_argument(
|
||||
"--max-new-tokens",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Frame budget (1 token = 1 frame ≈ 46.4 ms at 21.5 fps)",
|
||||
)
|
||||
parser.add_argument("--stream", action="store_true", help="Enable streaming (PCM output)")
|
||||
parser.add_argument(
|
||||
"--response-format",
|
||||
default="wav",
|
||||
choices=["wav", "mp3", "flac", "pcm"],
|
||||
help="Audio format (default: wav). Streaming is pcm/wav only. Opus is not supported at 22.05 kHz.",
|
||||
)
|
||||
parser.add_argument("--output", "-o", default=None, help="Output file path")
|
||||
args = parser.parse_args()
|
||||
run_tts(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Batch speech client for Qwen3-TTS via /v1/audio/speech/batch endpoint.
|
||||
|
||||
This script demonstrates how to synthesize multiple texts in a single request.
|
||||
@@ -7,7 +10,7 @@ the reference for each item.
|
||||
|
||||
Start the server (with batch-optimized stage settings for best throughput):
|
||||
|
||||
vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \
|
||||
vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-Base \
|
||||
--omni \
|
||||
--trust-remote-code \
|
||||
--stage-overrides '{"0":{"max_num_seqs":4,"gpu_memory_utilization":0.2},
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Gradio demo for Qwen3-TTS with gapless streaming audio playback.
|
||||
|
||||
Uses a custom AudioWorklet-based player for gap-free streaming,
|
||||
@@ -351,7 +354,7 @@ def generate_speech(
|
||||
raise gr.Error(f"Failed to decode audio: {e}")
|
||||
|
||||
|
||||
def create_app(api_base: str):
|
||||
def create_app(api_base: str, task_type: str = "CustomVoice"):
|
||||
"""Create the FastAPI app with streaming proxy + Gradio UI."""
|
||||
fastapi_app = FastAPI()
|
||||
|
||||
@@ -463,10 +466,11 @@ def create_app(api_base: str):
|
||||
lines=4,
|
||||
)
|
||||
with gr.Row():
|
||||
task_type = gr.Radio(
|
||||
task_type_input = gr.Radio(
|
||||
choices=TASK_TYPES,
|
||||
value="CustomVoice",
|
||||
value=task_type,
|
||||
label="Task Type",
|
||||
interactive=False,
|
||||
scale=2,
|
||||
)
|
||||
language = gr.Dropdown(
|
||||
@@ -479,17 +483,21 @@ def create_app(api_base: str):
|
||||
choices=voices,
|
||||
value=voices[0] if voices else None,
|
||||
label="speaker",
|
||||
visible=True,
|
||||
visible=task_type == "CustomVoice",
|
||||
allow_custom_value=True,
|
||||
)
|
||||
instructions = gr.Textbox(
|
||||
label="Instructions",
|
||||
placeholder="e.g., Speak with excitement / A warm, friendly female voice",
|
||||
lines=2,
|
||||
visible=True,
|
||||
info="Optional style/emotion instructions",
|
||||
visible=task_type != "Base",
|
||||
info=(
|
||||
"Required: describe the voice style"
|
||||
if task_type == "VoiceDesign"
|
||||
else "Optional style/emotion instructions"
|
||||
),
|
||||
)
|
||||
with gr.Column(visible=False) as ref_group:
|
||||
with gr.Column(visible=task_type == "Base") as ref_group:
|
||||
ref_audio = gr.Audio(
|
||||
label="Reference Audio (upload for voice cloning)",
|
||||
type="numpy",
|
||||
@@ -563,7 +571,7 @@ def create_app(api_base: str):
|
||||
autoplay=True,
|
||||
visible=False,
|
||||
)
|
||||
with gr.Column(visible=True) as examples_cv:
|
||||
with gr.Column(visible=task_type == "CustomVoice") as examples_cv:
|
||||
gr.Examples(
|
||||
examples=[
|
||||
[
|
||||
@@ -588,7 +596,7 @@ def create_app(api_base: str):
|
||||
inputs=[text_input, voice, language, instructions],
|
||||
label="examples",
|
||||
)
|
||||
with gr.Column(visible=False) as examples_vd:
|
||||
with gr.Column(visible=task_type == "VoiceDesign") as examples_vd:
|
||||
gr.Examples(
|
||||
examples=[
|
||||
[
|
||||
@@ -605,7 +613,7 @@ def create_app(api_base: str):
|
||||
inputs=[text_input, language, instructions],
|
||||
label="examples",
|
||||
)
|
||||
with gr.Column(visible=False) as examples_base:
|
||||
with gr.Column(visible=task_type == "Base") as examples_base:
|
||||
gr.Examples(
|
||||
examples=[
|
||||
[
|
||||
@@ -653,9 +661,9 @@ def create_app(api_base: str):
|
||||
gr.update(visible=is_base), # examples_base
|
||||
)
|
||||
|
||||
task_type.change(
|
||||
task_type_input.change(
|
||||
fn=on_task_change,
|
||||
inputs=[task_type],
|
||||
inputs=[task_type_input],
|
||||
outputs=[
|
||||
voice,
|
||||
instructions,
|
||||
@@ -703,7 +711,7 @@ def create_app(api_base: str):
|
||||
|
||||
all_inputs = [
|
||||
text_input,
|
||||
task_type,
|
||||
task_type_input,
|
||||
voice,
|
||||
language,
|
||||
instructions,
|
||||
@@ -788,6 +796,9 @@ def parse_args():
|
||||
description="Gradio demo for Qwen3-TTS with gapless AudioWorklet streaming.",
|
||||
)
|
||||
add_common_args(parser)
|
||||
parser.add_argument(
|
||||
"--task-type", choices=TASK_TYPES, default="CustomVoice", help="Task matching the served checkpoint"
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -796,7 +807,7 @@ def main():
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
print(f"Connecting to vLLM server at: {args.api_base}")
|
||||
|
||||
app = create_app(args.api_base)
|
||||
app = create_app(args.api_base, task_type=args.task_type)
|
||||
|
||||
import uvicorn
|
||||
|
||||
|
||||
@@ -8,25 +8,28 @@ to generate audio from text using Qwen3-TTS models.
|
||||
|
||||
Examples:
|
||||
# CustomVoice task (predefined speaker)
|
||||
python openai_speech_client.py --text "Hello, how are you?" --voice vivian
|
||||
python openai_speech_client.py --text "Hello, how are you?" --speaker vivian
|
||||
|
||||
# CustomVoice with emotion instruction
|
||||
python openai_speech_client.py --text "I'm so happy!" --voice vivian \
|
||||
python openai_speech_client.py --text "I'm so happy!" --speaker vivian \
|
||||
--instructions "Speak with excitement"
|
||||
|
||||
# VoiceDesign task (voice from description)
|
||||
python openai_speech_client.py --text "Hello world" \
|
||||
--model Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign \
|
||||
--task-type VoiceDesign \
|
||||
--instructions "A warm, friendly female voice"
|
||||
|
||||
# Base task (voice cloning)
|
||||
python openai_speech_client.py --text "Hello world" \
|
||||
--model Qwen/Qwen3-TTS-12Hz-1.7B-Base \
|
||||
--task-type Base \
|
||||
--ref-audio "https://example.com/reference.wav" \
|
||||
--ref-text "This is the reference transcript"
|
||||
|
||||
# Base task with pre-computed speaker embedding
|
||||
python openai_speech_client.py --text "Hello world" \
|
||||
--model Qwen/Qwen3-TTS-12Hz-1.7B-Base \
|
||||
--task-type Base \
|
||||
--speaker-embedding embedding.json
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#!/bin/bash
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
# Launch both vLLM server and Gradio demo for Qwen3-TTS
|
||||
#
|
||||
# Usage:
|
||||
@@ -179,7 +182,7 @@ fi
|
||||
echo ""
|
||||
echo "Starting Gradio demo..."
|
||||
cd "$SCRIPT_DIR"
|
||||
GRADIO_CMD=("python" "gradio_demo.py" "--api-base" "$API_BASE" "--host" "$GRADIO_IP" "--port" "$GRADIO_PORT")
|
||||
GRADIO_CMD=("python" "gradio_demo.py" "--api-base" "$API_BASE" "--host" "$GRADIO_IP" "--port" "$GRADIO_PORT" "--task-type" "$TASK_TYPE")
|
||||
if [ "$GRADIO_SHARE" = true ]; then
|
||||
GRADIO_CMD+=("--share")
|
||||
fi
|
||||
|
||||
@@ -123,6 +123,11 @@ fa4 = [
|
||||
"flash-attn-4[cu13]==4.0.0b18",
|
||||
]
|
||||
|
||||
# FastVideo VSA kernels for the supported Linux/Python 3.12 wheel environment.
|
||||
vsa = [
|
||||
"fastvideo-kernel==0.3.4",
|
||||
]
|
||||
|
||||
docs = [
|
||||
"mkdocs>=1.5.0",
|
||||
"mkdocs-api-autonav",
|
||||
|
||||
@@ -1230,11 +1230,9 @@ vllm serve "${MODEL_ROOT}/FL2VA" \
|
||||
mode does not support `cache_backend`.
|
||||
- The first regional-compile request is a warmup and should not be included in
|
||||
steady-state performance measurements.
|
||||
- The serving path accepts fewer references than the model supports. H3 documents up
|
||||
to 9 images, 3 video clips, and 3 audio clips (12 files) per Omni Reference
|
||||
request; the current vLLM-Omni path takes exactly one image plus one audio
|
||||
reference, or one or more videos with no separate `audio_reference` (it uses the
|
||||
source soundtracks).
|
||||
- Ref2VA accepts up to 9 images, 3 video clips, and 3 audio clips, with at most
|
||||
12 references in total. At least one image or video is required; audio-only
|
||||
requests are rejected.
|
||||
- The 768 px short-edge mode is available for T2VA and FL2VA; 1344x768 is the
|
||||
documented 16:9 request shape.
|
||||
- `--cfg-parallel-size > 1` is rejected by design (CFG-distilled, no negative branch).
|
||||
@@ -1247,9 +1245,6 @@ vllm serve "${MODEL_ROOT}/FL2VA" \
|
||||
in host memory on every rank during startup before retaining only each rank's
|
||||
shard. Size startup host memory for that transient peak.
|
||||
- TeaCache and Cache-DiT cannot be enabled on the same server.
|
||||
- Image+audio Ref2VA accepts exactly one image and one audio reference.
|
||||
- Video Ref2VA accepts one or more video files, but not an additional standalone
|
||||
audio reference.
|
||||
- Pure Ulysses still replicates the full DiT on every rank, so smaller-memory GPUs
|
||||
cannot use `--usp N --tp 1` as a resident capacity path. Use DiT tensor parallelism
|
||||
or model-level CPU offload; text-encoder TP alone is not sufficient.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MiniCPM-o 4.5
|
||||
|
||||
> Online serving and offline inference for omni multimodal chat
|
||||
> Online full-duplex and chat-completions serving, and offline inference, for omni multimodal chat
|
||||
> (text / image / audio / video → text + 24 kHz speech)
|
||||
|
||||
## Summary
|
||||
@@ -9,8 +9,12 @@
|
||||
- Model: [`openbmb/MiniCPM-o-4_5`](https://huggingface.co/openbmb/MiniCPM-o-4_5)
|
||||
- Task: Omni multimodal chat — accepts text / image / audio / video input;
|
||||
emits text and 24 kHz mono speech in the same response
|
||||
- Mode: Online serving via the OpenAI-compatible `/v1/chat/completions`
|
||||
API (plus Gradio demo), and offline inference via `Omni.generate`
|
||||
- Mode: Online full-duplex serving over the OpenAI Realtime protocol
|
||||
(`/v1/realtime?duplex=1`, alias `/v1/duplex`), the OpenAI-compatible
|
||||
`/v1/chat/completions` API (plus Gradio demo) on the same server, and
|
||||
offline turn-based inference via `Omni.generate`. Besides those the server
|
||||
exposes only `/v1/models` and `/health`; the other turn-based HTTP routes
|
||||
are not available on a duplex deployment.
|
||||
- Maintainer: [`@tc-mb`](https://github.com/tc-mb) (MiniCPM-V / MiniCPM-o team)
|
||||
|
||||
## When to use this recipe
|
||||
@@ -18,9 +22,10 @@
|
||||
Use this recipe as a known-good starting point for serving
|
||||
`openbmb/MiniCPM-o-4_5` on vLLM-Omni. MiniCPM-o 4.5 is the omni member
|
||||
of the MiniCPM-o family — it runs a multimodal thinker, a streaming
|
||||
MiniCPMTTS codec talker, and a separate batched Code2Wav stage so a single
|
||||
`/v1/chat/completions` call can return text and 24 kHz speech in one
|
||||
shot. The recommended batching deploy isolates the Thinker on GPU 0 and
|
||||
MiniCPMTTS codec talker, and a separate batched Code2Wav stage, so a live
|
||||
duplex session streams text and 24 kHz speech while it keeps listening, and a
|
||||
single `/v1/chat/completions` call returns both in one shot. The
|
||||
recommended batching deploy isolates the Thinker on GPU 0 and
|
||||
co-locates Talker and Code2Wav on GPU 1; 1-GPU, 3-GPU, and 8x4090 layouts are
|
||||
also provided.
|
||||
|
||||
@@ -36,8 +41,11 @@ also provided.
|
||||
[`vllm_omni/deploy/minicpmo_4_5_3gpu.yaml`](../../vllm_omni/deploy/minicpmo_4_5_3gpu.yaml)
|
||||
- 8x RTX 4090 layout:
|
||||
[`vllm_omni/deploy/minicpmo_4_5_8x4090.yaml`](../../vllm_omni/deploy/minicpmo_4_5_8x4090.yaml)
|
||||
- Online example + Gradio demo:
|
||||
- Online examples (Realtime CLI demo, browser client, barge-in client,
|
||||
chat-completions client, curl script, Gradio demo):
|
||||
[`examples/online_serving/minicpmo/`](../../examples/online_serving/minicpmo/)
|
||||
- Duplex API and Python client docs:
|
||||
[`docs/serving/realtime_duplex_api.md`](../../docs/serving/realtime_duplex_api.md)
|
||||
- Offline end-to-end example:
|
||||
[`examples/offline_inference/minicpmo/`](../../examples/offline_inference/minicpmo/)
|
||||
- Pipeline / talker source:
|
||||
@@ -179,6 +187,13 @@ as one aggregated chunk, so the table reports Stage 0 metrics.
|
||||
|
||||
#### Verification
|
||||
|
||||
**Health and model listing**:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8099/health
|
||||
curl http://localhost:8099/v1/models
|
||||
```
|
||||
|
||||
**Quick smoke test (text-only output)**:
|
||||
|
||||
```bash
|
||||
@@ -191,8 +206,31 @@ curl http://localhost:8099/v1/chat/completions \
|
||||
}'
|
||||
```
|
||||
|
||||
**Text + speech in one response** (the headline 4.5 feature). The model
|
||||
bridge conditions the Talker from the generated assistant span, so the
|
||||
**One duplex turn over the Realtime WebSocket** (text + speech stream back
|
||||
while the session keeps listening):
|
||||
|
||||
```bash
|
||||
python examples/online_serving/minicpmo/realtime_duplex_demo.py \
|
||||
--url ws://localhost:8099/v1/realtime?duplex=1 \
|
||||
--model openbmb/MiniCPM-o-4_5 \
|
||||
--input-wav /path/to/input_16k_mono_pcm16.wav \
|
||||
--ref-audio /path/to/MiniCPM-o-Demo/assets/ref_audio/ref_minicpm_signature.wav \
|
||||
--output-dir /tmp/minicpmo_realtime_duplex_demo
|
||||
```
|
||||
|
||||
The demo prints transcript deltas as they arrive and saves the streamed
|
||||
24 kHz audio to WAV files. The same session shape is available from Python
|
||||
through `vllm_omni.clients.duplex.DuplexClient` (over the server) or
|
||||
`vllm_omni.clients.inline_duplex.InlineDuplexClient` (in-process, no server;
|
||||
`examples/online_serving/barge_in_client.py --inline`).
|
||||
|
||||
**Barge-in**: `examples/online_serving/barge_in_client.py` plays a question,
|
||||
interrupts the answer with a second WAV and checks that the first response is
|
||||
cancelled and the second one is answered.
|
||||
|
||||
**Text + speech in one response over `/v1/chat/completions`** (the headline
|
||||
4.5 feature, served by the ordinary chat service on the duplex engine). The
|
||||
model bridge conditions the Talker from the generated assistant span, so the
|
||||
generic serving layer does not inject MiniCPM-specific template defaults.
|
||||
`use_tts_template=true` remains supported when explicitly requested:
|
||||
|
||||
@@ -243,6 +281,12 @@ python examples/online_serving/minicpmo/gradio_demo.py \
|
||||
Open `http://<host>:7862` and try a text prompt with the **"Generate
|
||||
speech output (TTS)"** checkbox on / off.
|
||||
|
||||
**Turn-based generation** is also available offline
|
||||
(`examples/offline_inference/minicpmo/`, `Omni.generate`).
|
||||
|
||||
The Daily-Omni numbers above were measured on the `/v1/chat/completions`
|
||||
route.
|
||||
|
||||
#### Notes
|
||||
|
||||
- Memory budget: Thinker, Talker, and Code2Wav reserve 0.55, 0.15, and
|
||||
@@ -326,9 +370,13 @@ vllm serve openbmb/MiniCPM-o-4_5 --omni \
|
||||
and defaults to deterministic seed 42. Stage-1 deploy sampling parameters
|
||||
control only vLLM's binary continue/stop token.
|
||||
|
||||
- **Output audio**: 24 kHz mono WAV inside the OpenAI-style
|
||||
`message.audio.data` (base64). The Gradio demo's WAV player decodes
|
||||
this automatically.
|
||||
- **Output audio**: 24 kHz mono. On `/v1/chat/completions` it is base64 WAV
|
||||
inside the OpenAI-style `message.audio.data` (the Gradio demo's WAV player
|
||||
decodes this automatically); on a duplex session it streams as base64
|
||||
`response.audio.delta` events; offline `Omni.generate` returns WAV.
|
||||
- **Response choices**: on `/v1/chat/completions` text and audio are separate
|
||||
choices. SDK clients should select the choice whose `message.audio.data` is
|
||||
populated rather than assuming `choices[0]` contains audio.
|
||||
|
||||
- **Routing**: MiniCPM-o 4.5 and 2.6 both ship `architectures=
|
||||
["MiniCPMO"]` in HF config; routing is disambiguated by
|
||||
@@ -340,6 +388,3 @@ vllm serve openbmb/MiniCPM-o-4_5 --omni \
|
||||
- **Async chunking**: enabled in all deploy configs. Talker sends
|
||||
25-code chunks with three-code left context to Code2Wav through
|
||||
`SharedMemoryConnector`; terminal chunks flush held lookahead state.
|
||||
- **Response choices**: text and audio are separate choices. SDK clients
|
||||
should select the choice whose `message.audio.data` is populated rather
|
||||
than assuming `choices[0]` contains audio.
|
||||
+1
-1
@@ -71,7 +71,7 @@ recipes/
|
||||
| [`cosmos3/Cosmos3-Nano.md`](./cosmos3/Cosmos3-Nano.md) | Text-to-image, text-to-video, image-to-video, video-to-video generation, text to video with sound, action policy | 1x H200 141GB / B300 |
|
||||
| [`cosmos3/Cosmos3-Edge.md`](./cosmos3/Cosmos3-Edge.md) | T2I / T2V / I2V + action (Physical-AI) generation on the Nemotron-based Edge transformer | 1x GPU (~8 GiB) |
|
||||
| [`cosmos3/Cosmos3-Super.md`](./cosmos3/Cosmos3-Super.md) | 64B T2I / T2V / I2V / V2V generation (+ optional audio) / Action policy | 8x H200/H100/A100 / 2x H200 / B300 |
|
||||
| [`OpenBMB/MiniCPM-o-4_5.md`](./OpenBMB/MiniCPM-o-4_5.md) | Online serving for omni multimodal chat (text / image / audio / video → text + 24 kHz speech) | 2x A100/H100 80GB / 3x mid-tier GPU / 8x RTX 4090 24GB |
|
||||
| [`OpenBMB/MiniCPM-o-4_5.md`](./OpenBMB/MiniCPM-o-4_5.md) | Online full-duplex serving and offline inference for omni multimodal chat (text / image / audio / video → text + 24 kHz speech) | 2x A100/H100 80GB / 3x mid-tier GPU / 8x RTX 4090 24GB |
|
||||
| [`OpenBMB/VoxCPM2.md`](./OpenBMB/VoxCPM2.md) | Online + offline TTS with native AR pipeline (48 kHz, 30+ languages) | 1x RTX 4090 24GB |
|
||||
| [`OpenMOSS/MOSS-TTS.md`](./OpenMOSS/MOSS-TTS.md) | Online + offline multilingual TTS (MOSS-TTS family, 8B) | 1x H100 80GB |
|
||||
| [`OpenMOSS/MOSS-TTS-Nano-NPU.md`](./OpenMOSS/MOSS-TTS-Nano-NPU.md) | Online multilingual voice-cloned TTS | 1x Ascend 910B3 64GB |
|
||||
|
||||
@@ -32,13 +32,13 @@ mode is selected per request:
|
||||
T2V/I2V `/v1/videos/sync` request to also generate synchronized audio, muxed into
|
||||
the mp4 as AAC 48 kHz stereo. See the official model card's "Video + Audio" examples.
|
||||
- **Action** — pass `extra_params={"action_mode": ...}` to drive Physical-AI tasks:
|
||||
- `forward_dynamics` — given a first frame or video **and** an action trajectory,
|
||||
- `forward_dynamics` — given a first frame or video **and** an action trajectory,
|
||||
roll out the resulting video. Synchronous: `POST /v1/videos/sync`.
|
||||
- `policy` — given a first frame or video and a language instruction,
|
||||
- `policy` — given a first frame or video and a language instruction,
|
||||
**predict** the action trajectory (and a rollout video). Use the async
|
||||
`POST /v1/videos` endpoint and read the predicted action from the top-level
|
||||
`action` field.
|
||||
- `inverse_dynamics` — given a video, **recover** the action trajectory. Use
|
||||
- `inverse_dynamics` — given a video, **recover** the action trajectory. Use
|
||||
the async `POST /v1/videos` endpoint and read the recovered action from
|
||||
the top-level `action` field
|
||||
(`{data, shape, dtype, raw_action_dim, domain_id}`).
|
||||
@@ -106,6 +106,25 @@ vllm serve nvidia/Cosmos3-Nano \
|
||||
--init-timeout 1800
|
||||
```
|
||||
|
||||
For Cosmos3, SeaCache is the recommended default choice when opting into
|
||||
diffusion caching. Caching remains opt-in; add `--cache-backend sea_cache` to
|
||||
the command above. Its default threshold and maximum cached-step streak are
|
||||
tuned for Cosmos3, so no `--cache-config` is required.
|
||||
|
||||
Override individual defaults with a JSON cache configuration; for example:
|
||||
|
||||
```bash
|
||||
vllm serve nvidia/Cosmos3-Nano \
|
||||
--omni \
|
||||
--cache-backend sea_cache \
|
||||
--cache-config '{"sea_threshold":0.2,"sea_max_consecutive_cached":3}'
|
||||
```
|
||||
|
||||
Lower `sea_threshold` values and smaller `sea_max_consecutive_cached` caps are
|
||||
more conservative. Higher values allow more cached steps and may improve
|
||||
speed, but can increase quality loss. Setting `sea_max_consecutive_cached` to
|
||||
`0` removes the streak cap.
|
||||
|
||||
To run **without** guardrails (you are responsible for license compliance),
|
||||
add `--no-guardrails` (no token/`cosmos-guardrail` needed). For extra GPUs use
|
||||
`--ulysses-degree N` (context parallel) or `--tensor-parallel-size N`;
|
||||
@@ -382,12 +401,12 @@ OpenPI route uses the normal API authentication middleware. RoboLab's
|
||||
|
||||
#### Notes
|
||||
|
||||
- **Measured latency (1x B300, bf16, guardrails off):**
|
||||
- T2I 1024² — 10 / 25 / 50 steps → ~0.4 / 0.7 / **1.3 s**
|
||||
- T2V 1280×720 @ 35 steps — 25 / 49 / 93 / **189** frames → ~7 / 15 / 33 / **~93 s**
|
||||
- I2V 1280×720, 189 frames @ 35 steps → ~**99 s**
|
||||
- Action 640×480 @ 30 steps — forward-dynamics 61f ~**4 s**, policy 17f ~**1–3 s**.
|
||||
- Guardrails-on overhead: ~8% on T2I, negligible on video.
|
||||
- **Measured latency (1x B300, bf16, guardrails off, diffusion cache off):**
|
||||
- T2I 1024² — 10 / 25 / 50 steps → ~0.4 / 0.7 / **1.3 s**
|
||||
- T2V 1280×720 @ 35 steps — 25 / 49 / 93 / **189** frames → ~7 / 15 / 33 / **~93 s**
|
||||
- I2V 1280×720, 189 frames @ 35 steps → ~**99 s**
|
||||
- Action 640×480 @ 30 steps — forward-dynamics 61f ~**4 s**, policy 17f ~**1–3 s**.
|
||||
- Guardrails-on overhead: ~8% on T2I, negligible on video.
|
||||
- **Memory:** transformer ~17 GiB (bf16); peak ~46 GiB for 720p video on 1 GPU;
|
||||
full repo (transformer + Wan VAE + Qwen3-VL vision encoder + audio tokenizer)
|
||||
~33 GB on disk.
|
||||
@@ -443,12 +462,12 @@ OpenPI route uses the normal API authentication middleware. RoboLab's
|
||||
`conditioning_fps`, `action_chunk_size`, `raw_action_dim`, `deterministic_seed`,
|
||||
and `session_id`.
|
||||
- **Known limitations:**
|
||||
- Guardrails-on requires `cosmos-guardrail` **and** access to the gated
|
||||
- Guardrails-on requires `cosmos-guardrail` **and** access to the gated
|
||||
`nvidia/Cosmos-1.0-Guardrail` repo (accept license + `HF_TOKEN`); otherwise
|
||||
the server fails at pipeline build with a gated-repo / safety-checker error.
|
||||
- A guardrail-blocked prompt currently returns HTTP 500
|
||||
- A guardrail-blocked prompt currently returns HTTP 500
|
||||
(`"Guardrail blocked prompt"`).
|
||||
- Action `forward_dynamics`, `policy`, and `inverse_dynamics` are supported
|
||||
- Action `forward_dynamics`, `policy`, and `inverse_dynamics` are supported
|
||||
online. Use async `POST /v1/videos` when you need the predicted/recovered
|
||||
action payload under the top-level `action` field; sync `/v1/videos/sync`
|
||||
returns raw MP4 bytes and does not expose action metadata in the response body.
|
||||
@@ -579,14 +598,14 @@ so an md5 comparison across two runs also works as a smoke check.
|
||||
a ~390 s build of the VAE decode path *for that shape*, so warming 1024² images
|
||||
does nothing for 189-frame video. At 720p / 189 frames / 35 steps this is
|
||||
**540 s cold vs 161 s warm, with byte-identical output**.
|
||||
- **Measured on 1x MI350X (bf16, guardrails off, warm):** T2I 1024² @ 50 steps
|
||||
- **Measured on 1x MI350X (bf16, guardrails off, diffusion cache off, warm):** T2I 1024² @ 50 steps
|
||||
**~2.7 s**; T2V 1280×720 / 189 frames @ 35 steps **~161 s**, of which ~92% is
|
||||
the DiT denoise loop and ~4% VAE decode, so optimization effort belongs in the
|
||||
denoise loop. The optional flags act on different terms of the memory bill and
|
||||
are therefore complementary rather than redundant:
|
||||
|
||||
| Flag | Latency | Peak reserved | Peak allocated | Acts on |
|
||||
|---|---|---|---|---|
|
||||
| --- | --- | --- | --- | --- |
|
||||
| *(none)* | 161 s | 120 GiB | 95 GiB | — |
|
||||
| `--vae-use-tiling` | 183 s (+13.5%) | **38 GiB** (−68%) | **36 GiB** (−62%) | decode activations |
|
||||
| `--enable-layerwise-offload` | 162 s (+0.8%) | 84 GiB (−30%) | 69 GiB (−27%) | weights |
|
||||
@@ -768,11 +787,11 @@ curl -sS -X POST http://localhost:8000/v1/videos/sync \
|
||||
|
||||
#### Notes
|
||||
|
||||
- **Measured latency (1x Ascend 910B / 910C, bf16, guardrails off):**
|
||||
- T2I 1024² — 10 steps → ~8 s
|
||||
- T2V 1280×720 @ 20 steps — 49 frames → ~55 s
|
||||
- I2V 1280×720 @ 10 steps — 25 frames → ~25 s
|
||||
- V2V 480×320 @ 10 steps — 17 frames → ~12 s
|
||||
- **Measured latency (1x Ascend 910B / 910C, bf16, guardrails off, diffusion cache off):**
|
||||
- T2I 1024² — 10 steps → ~8 s
|
||||
- T2V 1280×720 @ 20 steps — 49 frames → ~55 s
|
||||
- I2V 1280×720 @ 10 steps — 25 frames → ~25 s
|
||||
- V2V 480×320 @ 10 steps — 17 frames → ~12 s
|
||||
- **Memory:** transformer ~17 GiB (bf16); peak ~46 GiB for 720p video on 1 NPU;
|
||||
full repo (transformer + Wan VAE + Qwen3-VL vision encoder + audio tokenizer)
|
||||
~33 GB on disk.
|
||||
@@ -785,9 +804,9 @@ curl -sS -X POST http://localhost:8000/v1/videos/sync \
|
||||
(for model loading), `--tensor-parallel-size 8` for multi-NPU, and
|
||||
`--model-class-name Cosmos3OmniDiffusersPipeline` to force the pipeline class.
|
||||
- **Known limitations:**
|
||||
- Transfer V2V with `extra_params` (`edge`/`blur`/`depth`/`seg`/`wsm`) hits a
|
||||
- Transfer V2V with `extra_params` (`edge`/`blur`/`depth`/`seg`/`wsm`) hits a
|
||||
resolution-parsing bug; basic V2V without transfer hints works.
|
||||
- FP8 online quantization and layerwise offload are not supported on NPU.
|
||||
- FP8 online quantization and layerwise offload are not supported on NPU.
|
||||
|
||||
### 1x Ascend 910B / 910C (Atlas A2 / A3) — Offline generation
|
||||
|
||||
|
||||
@@ -62,6 +62,21 @@ disable. `--enable-layerwise-offload` reduces VRAM on smaller GPUs;
|
||||
generation from ~83 GB to ~55 GB per GPU (2-GPU) with BF16-level quality (T2V
|
||||
composition can shift at the same seed).
|
||||
|
||||
SeaCache is the recommended default choice when opting into diffusion caching,
|
||||
which remains opt-in. Add `--cache-backend sea_cache` to either command above;
|
||||
its defaults are tuned for Cosmos3.
|
||||
|
||||
Override individual defaults with `--cache-config`; for example:
|
||||
|
||||
```bash
|
||||
--cache-config '{"sea_threshold":0.2,"sea_max_consecutive_cached":3}'
|
||||
```
|
||||
|
||||
Lower `sea_threshold` values and smaller `sea_max_consecutive_cached` caps are
|
||||
more conservative. Higher values allow more cached steps and may improve
|
||||
speed, but can increase quality loss. Setting `sea_max_consecutive_cached` to
|
||||
`0` removes the streak cap.
|
||||
|
||||
#### Verification
|
||||
|
||||
Requests are identical to Nano (see [`Cosmos3-Nano.md`](./Cosmos3-Nano.md) for full
|
||||
@@ -109,12 +124,12 @@ curl -sS -X POST http://localhost:8000/v1/videos/sync -H "Accept: video/mp4" \
|
||||
|
||||
#### Notes
|
||||
|
||||
- **Measured (2x B300, bf16, guardrails off, official 2-GPU config above):**
|
||||
- T2I 1024², 50 steps → **~6 s**
|
||||
- T2V 1280×720, 189 frames, 35 steps → **~197 s**
|
||||
- I2V 1280×720, 189 frames, 35 steps → **~200 s**
|
||||
- T2V + sound (189 frames, 35 steps) → **~198 s**, output muxes **AAC 48 kHz stereo**
|
||||
- (NVIDIA's reference: 8×H200 @ 50 steps ≈ 55 s/video; 2×H200 @ 35 steps ≈ 3 min/video.)
|
||||
- **Measured (2x B300, bf16, guardrails off, diffusion cache off, official 2-GPU config above):**
|
||||
- T2I 1024², 50 steps → **~6 s**
|
||||
- T2V 1280×720, 189 frames, 35 steps → **~197 s**
|
||||
- I2V 1280×720, 189 frames, 35 steps → **~200 s**
|
||||
- T2V + sound (189 frames, 35 steps) → **~198 s**, output muxes **AAC 48 kHz stereo**
|
||||
- (NVIDIA's reference: 8×H200 @ 50 steps ≈ 55 s/video; 2×H200 @ 35 steps ≈ 3 min/video.)
|
||||
- **Memory:** ~61.5 GiB per GPU when sharded across 2 GPUs (HSDP shard 2); repo ~135 GB on disk.
|
||||
- Same generation defaults, supported sizes, V2V reference-video controls
|
||||
(`condition_frame_indexes_vision`, `condition_video_keep`), and
|
||||
|
||||
@@ -178,7 +178,7 @@ mistralai/Voxtral-4B-TTS-2603 checkpoint. running the
|
||||
voice-cloning e2e test ([`Voice cloning (capability gated upstream)`](../../examples/offline_inference/text_to_speech/README.md#voice-cloning-capability-gated-upstream)
|
||||
) or adding a `ref_audio` request parameter will fail
|
||||
because the public checkpoint does not provide the encoder weights needed to
|
||||
convert reference audio into conditioning features. The expected failure is: `RuntimeError: encode_waveforms requires encoder weights which are not available in the open-source checkpoint.`
|
||||
convert reference audio into conditioning features. The expected failure is a 400 BadRequest `Voice cloning with 'ref_audio' requires the full Voxtral checkpoint with encoder weights. The open-source variant only supports preset voices via the 'voice' parameter.`.
|
||||
|
||||
### 1x NVIDIA RTX A6000 48GB
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ class MockResponse:
|
||||
async def test_seed_tts_realtime_duplex_exports_per_request_metrics(monkeypatch):
|
||||
class FakeRealtimeClient:
|
||||
last_instance = None
|
||||
instances: list = []
|
||||
|
||||
def __init__(self, url):
|
||||
assert url == "ws://localhost:8000/v1/realtime?duplex=1"
|
||||
@@ -143,6 +144,9 @@ async def test_seed_tts_realtime_duplex_exports_per_request_metrics(monkeypatch)
|
||||
self.sent = []
|
||||
self.response_count = 0
|
||||
self.ack_count = 0
|
||||
self.silence_seconds = []
|
||||
self.closed = 0
|
||||
type(self).instances.append(self)
|
||||
type(self).last_instance = self
|
||||
|
||||
async def __aenter__(self):
|
||||
@@ -155,10 +159,22 @@ async def test_seed_tts_realtime_duplex_exports_per_request_metrics(monkeypatch)
|
||||
assert model == "openbmb/MiniCPM-o-4_5"
|
||||
self.configure_kwargs = kwargs
|
||||
|
||||
async def stream_silence(self, *, seconds, chunk_ms=200, until=None):
|
||||
# A model-native session speaks off its seeded context once audio
|
||||
# units arrive; the silence itself carries no content. The probe
|
||||
# stops the silence as soon as the turn settles.
|
||||
self.silence_seconds.append(seconds)
|
||||
self._emit_response()
|
||||
assert until is not None and until()
|
||||
return 1.0
|
||||
|
||||
async def send(self, event):
|
||||
self.sent.append(event)
|
||||
if event["type"] != "response.create":
|
||||
return
|
||||
self._emit_response()
|
||||
|
||||
def _emit_response(self):
|
||||
self.response_count += 1
|
||||
response_id = f"resp-{self.response_count}"
|
||||
now = time.monotonic()
|
||||
@@ -210,7 +226,7 @@ async def test_seed_tts_realtime_duplex_exports_per_request_metrics(monkeypatch)
|
||||
self.ack_count += 1
|
||||
|
||||
async def close_session(self, **_kwargs):
|
||||
return None
|
||||
self.closed += 1
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.benchmarks.patch.patch._RealtimeTTSProbe",
|
||||
@@ -239,30 +255,27 @@ async def test_seed_tts_realtime_duplex_exports_per_request_metrics(monkeypatch)
|
||||
session=None,
|
||||
)
|
||||
|
||||
client = FakeRealtimeClient.last_instance
|
||||
assert client.configure_kwargs["native_duplex"] is False
|
||||
assert client.configure_kwargs["extra_body"] == {
|
||||
"ref_audio": "data:audio/wav;base64,AAAA",
|
||||
"return_stage_metrics": True,
|
||||
}
|
||||
assert client.sent == [
|
||||
event
|
||||
for index in range(4)
|
||||
for event in (
|
||||
{
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": f"text {index}"}],
|
||||
},
|
||||
},
|
||||
{"type": "response.create"},
|
||||
)
|
||||
]
|
||||
assert client.ack_count == 4
|
||||
# One session per utterance: a model-native duplex session takes its text
|
||||
# once, in the session context, so it cannot be re-seeded for a second one.
|
||||
sessions = FakeRealtimeClient.instances
|
||||
assert len(sessions) == 4
|
||||
for index, client in enumerate(sessions):
|
||||
assert "native_duplex" not in client.configure_kwargs
|
||||
assert client.configure_kwargs["extra_body"] == {
|
||||
"ref_audio": "data:audio/wav;base64,AAAA",
|
||||
"return_stage_metrics": True,
|
||||
"duplex_initial_user_text": f"text {index}",
|
||||
"force_listen_count": 0,
|
||||
}
|
||||
# The target text rides the session context, never a conversation item:
|
||||
# a text-only response.create is rejected by a model-native session.
|
||||
assert client.sent == []
|
||||
assert client.silence_seconds and all(seconds > 0 for seconds in client.silence_seconds)
|
||||
assert client.ack_count == 1
|
||||
assert client.closed == 1
|
||||
|
||||
assert output.success is True
|
||||
assert output.generated_text == "turn 1 turn 2 turn 3 turn 4"
|
||||
assert output.generated_text == "turn 1 turn 1 turn 1 turn 1"
|
||||
assert output.audio_duration == pytest.approx(0.4)
|
||||
assert output.ttft > 0
|
||||
assert output.audio_ttfp > output.ttft
|
||||
@@ -281,11 +294,12 @@ async def test_seed_tts_realtime_duplex_exports_per_request_metrics(monkeypatch)
|
||||
"session_id": session_id,
|
||||
"request_index": index,
|
||||
"utterance_id": f"utt-{index}",
|
||||
"response_id": f"resp-{index + 1}",
|
||||
# Response ids are per session, and each utterance now gets its own.
|
||||
"response_id": "resp-1",
|
||||
"source": "client_monotonic_receive",
|
||||
"measurement_origin": {
|
||||
"ttft": "conversation.item.create client send to first non-empty text delta",
|
||||
"ttfp": "conversation.item.create client send to first audio packet",
|
||||
"ttft": "first silence append client send to first non-empty text delta",
|
||||
"ttfp": "first silence append client send to first audio packet",
|
||||
"rtf": "request-start-to-last-audio receive time divided by emitted audio duration",
|
||||
"tpot": "Stage-0 engine mean time per output token",
|
||||
},
|
||||
|
||||
@@ -467,6 +467,27 @@ def test_tolerated_playback_ack_rejection_is_a_warning_not_a_failure():
|
||||
oi._raise_if_session_terminated(collector, 0, warnings=warnings)
|
||||
|
||||
|
||||
def test_our_own_close_is_expected_even_though_the_server_stamps_a_reason():
|
||||
"""``session.close`` is answered with ``session.closed`` carrying ``client_close``.
|
||||
|
||||
The guard exists to catch a session that ended for a reason we did not ask
|
||||
for. A close we requested is not that, whether or not the server names it.
|
||||
"""
|
||||
collector = _collector(({"type": "session.closed", "reason": "client_close"}, 1.0))
|
||||
oi._raise_if_session_terminated(collector, 0, explicit_close_from=0)
|
||||
|
||||
collector = _collector(({"type": "session.closed", "event": {"reason": "client_close"}}, 1.0))
|
||||
oi._raise_if_session_terminated(collector, 0, explicit_close_from=0)
|
||||
|
||||
collector = _collector(({"type": "session.closed", "reason": "disconnect"}, 1.0))
|
||||
with pytest.raises(RuntimeError, match="Unexpected session.closed: disconnect"):
|
||||
oi._raise_if_session_terminated(collector, 0, explicit_close_from=0)
|
||||
|
||||
collector = _collector(({"type": "session.expired", "reason": "timeout"}, 1.0))
|
||||
with pytest.raises(RuntimeError, match="session.expired: timeout"):
|
||||
oi._raise_if_session_terminated(collector, 0, explicit_close_from=0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("event", "match"),
|
||||
[
|
||||
@@ -561,8 +582,8 @@ class _RealtimeClient(oi._RealtimeSession):
|
||||
|
||||
instances: list[_RealtimeClient] = []
|
||||
|
||||
def __init__(self, config: oi.OmniInteractBenchmarkConfig, session_id: str, reference_audio: str):
|
||||
super().__init__(config, session_id, reference_audio)
|
||||
def __init__(self, config: oi.OmniInteractBenchmarkConfig, reference_audio: str):
|
||||
super().__init__(config, reference_audio)
|
||||
self.acks: list[tuple[str, int]] = []
|
||||
self.instances.append(self)
|
||||
|
||||
@@ -621,7 +642,7 @@ async def test_public_runner_executes_one_prepared_session(tmp_path: Path, monke
|
||||
assert "autostart=0" in _RealtimeClient.instances[-1].url
|
||||
session_config = _RealtimeClient.instances[-1].session_config
|
||||
assert session_config.extra_body["custom"] == "value"
|
||||
assert session_config.extra_body["native_duplex"] is True
|
||||
assert "native_duplex" not in session_config.extra_body
|
||||
assert session_config.ref_audio == "data:audio/wav;base64,ref"
|
||||
acks = _RealtimeClient.instances[-1].acks
|
||||
# Cumulative acks for the one response: an optional 0 ms checkpoint the
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""The Seed-TTS Realtime probe queues for a duplex session slot instead of failing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.benchmarks.patch import patch as bench_patch
|
||||
from vllm_omni.clients import duplex as duplex_client
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class _AdmissionGate:
|
||||
"""Stands in for ``DuplexClient``: refuses admission ``refusals`` times, then admits."""
|
||||
|
||||
instances: list[_AdmissionGate] = []
|
||||
refusals = 0
|
||||
refusal_code = "resource_exhausted"
|
||||
|
||||
def __init__(self, url: str, **kwargs: object) -> None:
|
||||
del url, kwargs
|
||||
self.entered = False
|
||||
type(self).instances.append(self)
|
||||
|
||||
async def __aenter__(self) -> _AdmissionGate:
|
||||
cls = type(self)
|
||||
if cls.refusals > 0:
|
||||
cls.refusals -= 1
|
||||
raise duplex_client.DuplexProtocolError("duplex_session_capacity_exhausted: limit=4", code=cls.refusal_code)
|
||||
self.entered = True
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
del exc_type, exc, tb
|
||||
|
||||
async def events(self): # the collector's consume loop ends at once
|
||||
return
|
||||
yield # pragma: no cover
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gate(monkeypatch: pytest.MonkeyPatch) -> type[_AdmissionGate]:
|
||||
_AdmissionGate.instances = []
|
||||
_AdmissionGate.refusals = 0
|
||||
_AdmissionGate.refusal_code = "resource_exhausted"
|
||||
monkeypatch.setattr(duplex_client, "DuplexClient", _AdmissionGate)
|
||||
return _AdmissionGate
|
||||
|
||||
|
||||
async def _configure(probe: bench_patch._RealtimeTTSProbe) -> None:
|
||||
try:
|
||||
await probe.configure("test-model", auto_response=True)
|
||||
finally:
|
||||
await probe.__aexit__(None, None, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configure_waits_for_a_free_session_slot(gate) -> None:
|
||||
"""Two refusals, then a slot: the request is served, not counted as failed."""
|
||||
gate.refusals = 2
|
||||
probe = bench_patch._RealtimeTTSProbe("ws://test/v1/realtime?duplex=1")
|
||||
await _configure(probe)
|
||||
assert len(gate.instances) == 3
|
||||
assert gate.instances[-1].entered is True
|
||||
assert probe._client is gate.instances[-1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configure_raises_other_rejections_at_once(gate) -> None:
|
||||
gate.refusals = 1
|
||||
gate.refusal_code = "unsupported_turn_detection"
|
||||
probe = bench_patch._RealtimeTTSProbe("ws://test/v1/realtime?duplex=1")
|
||||
with pytest.raises(duplex_client.DuplexProtocolError):
|
||||
await _configure(probe)
|
||||
assert len(gate.instances) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configure_gives_up_when_the_slot_wait_budget_is_spent(gate, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(bench_patch._RealtimeTTSProbe, "_SESSION_SLOT_WAIT_S", 0.0)
|
||||
gate.refusals = 5
|
||||
probe = bench_patch._RealtimeTTSProbe("ws://test/v1/realtime?duplex=1")
|
||||
with pytest.raises(duplex_client.DuplexProtocolError) as excinfo:
|
||||
await _configure(probe)
|
||||
assert excinfo.value.code == "resource_exhausted"
|
||||
assert len(gate.instances) == 1
|
||||
|
||||
|
||||
class _SilenceSink:
|
||||
"""Stands in for a configured ``DuplexClient``: records every appended chunk."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.config = duplex_client.SessionConfig()
|
||||
self.chunks: list[bytes] = []
|
||||
|
||||
async def append_audio(self, pcm: bytes, *, is_speech: bool | None = None, video_frames=None) -> None:
|
||||
del video_frames
|
||||
assert is_speech is False
|
||||
self.chunks.append(pcm)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_silence_stops_at_the_turn_end(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Silence is fed until the turn settles, not for the whole cap."""
|
||||
probe = bench_patch._RealtimeTTSProbe("ws://test/v1/realtime?duplex=1")
|
||||
sink = _SilenceSink()
|
||||
probe._client = sink
|
||||
|
||||
async def no_wait(_: float) -> None:
|
||||
return
|
||||
|
||||
monkeypatch.setattr(bench_patch.asyncio, "sleep", no_wait)
|
||||
streamed = await probe.stream_silence(seconds=12.0, chunk_ms=200, until=lambda: len(sink.chunks) >= 3)
|
||||
assert len(sink.chunks) == 3
|
||||
assert streamed == pytest.approx(0.6)
|
||||
assert all(len(chunk) == sink.config.input_audio.byte_count(200) for chunk in sink.chunks)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_silence_runs_to_the_cap_when_the_turn_never_settles(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
probe = bench_patch._RealtimeTTSProbe("ws://test/v1/realtime?duplex=1")
|
||||
sink = _SilenceSink()
|
||||
probe._client = sink
|
||||
|
||||
async def no_wait(_: float) -> None:
|
||||
return
|
||||
|
||||
monkeypatch.setattr(bench_patch.asyncio, "sleep", no_wait)
|
||||
streamed = await probe.stream_silence(seconds=1.0, chunk_ms=200, until=lambda: False)
|
||||
assert len(sink.chunks) == 5
|
||||
assert streamed == pytest.approx(1.0)
|
||||
|
||||
|
||||
class _TurnEvents:
|
||||
def __init__(self, response_ids: list[str], audio: dict[str, bytes], text: dict[str, str] | None = None) -> None:
|
||||
self.response_ids = response_ids
|
||||
self._audio = audio
|
||||
self._text = text or {}
|
||||
|
||||
def audio_bytes(self, response_id: str) -> bytes:
|
||||
return self._audio.get(response_id, b"")
|
||||
|
||||
def response_text(self, response_id: str) -> str:
|
||||
return self._text.get(response_id, "")
|
||||
|
||||
|
||||
def test_turn_response_is_the_first_one_with_audio_even_if_the_model_spoke_again(caplog) -> None:
|
||||
events = _TurnEvents(["r0", "r1", "r2"], {"r1": b"\x01\x00", "r2": b"\x02\x00"})
|
||||
with caplog.at_level("WARNING", logger=bench_patch.logger.name):
|
||||
assert bench_patch._seed_tts_turn_response_id(events, 0, 0) == "r1"
|
||||
assert "spoke again" in caplog.text
|
||||
|
||||
|
||||
def test_turn_response_ignores_responses_from_before_the_turn() -> None:
|
||||
events = _TurnEvents(["r0", "r1"], {"r0": b"\x01\x00", "r1": b"\x02\x00"})
|
||||
assert bench_patch._seed_tts_turn_response_id(events, 1, 0) == "r1"
|
||||
|
||||
|
||||
def test_turn_without_audio_is_an_error() -> None:
|
||||
events = _TurnEvents(["r0"], {})
|
||||
with pytest.raises(RuntimeError, match="no audio response"):
|
||||
bench_patch._seed_tts_turn_response_id(events, 0, 0)
|
||||
|
||||
|
||||
def test_stall_report_says_the_model_never_answered() -> None:
|
||||
events = _TurnEvents(["r0"], {"r0": b"\x01\x00"})
|
||||
report = bench_patch._seed_tts_turn_stall_report(events, 1, 3, 30.0)
|
||||
assert "turn 3 never started a response" in report
|
||||
assert "30.0s of silence" in report
|
||||
|
||||
|
||||
def test_stall_report_lists_a_response_that_never_finished() -> None:
|
||||
events = _TurnEvents(["r1"], {"r1": b"\x01\x00\x02\x00"}, {"r1": "hello"})
|
||||
report = bench_patch._seed_tts_turn_stall_report(events, 0, 0, 4.2)
|
||||
assert "started 1 response(s) after 4.2s of silence" in report
|
||||
assert "r1 (4 audio bytes, text 'hello')" in report
|
||||
+399
-115
@@ -1,6 +1,12 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Unit tests for the public duplex client (fake transport, no server)."""
|
||||
"""Unit tests for the public duplex clients (fake transports, no server).
|
||||
|
||||
``DuplexClient`` runs over a fake websocket; ``InlineDuplexClient`` over a
|
||||
fake ``DuplexOmni`` whose handle records typed commands and replays typed
|
||||
events. The demux / handshake tests that only need "feed a server event" are
|
||||
parameterized over both clients.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,6 +14,8 @@ import asyncio
|
||||
import base64
|
||||
import json
|
||||
import wave
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import pytest
|
||||
@@ -16,8 +24,11 @@ from vllm_omni.clients.duplex import (
|
||||
AudioFormat,
|
||||
ConnectionResumed,
|
||||
DuplexClient,
|
||||
DuplexClientBase,
|
||||
DuplexConnectionError,
|
||||
DuplexProtocolError,
|
||||
DuplexSessionClosedError,
|
||||
ErrorEvent,
|
||||
EventCollector,
|
||||
ReconnectPolicy,
|
||||
SessionConfig,
|
||||
@@ -31,17 +42,26 @@ from vllm_omni.clients.duplex import (
|
||||
summarize_session_request_metrics,
|
||||
write_pcm16_wav,
|
||||
)
|
||||
from vllm_omni.clients.inline_duplex import InlineDuplexClient
|
||||
from vllm_omni.engine.duplex import commands as duplex_commands
|
||||
from vllm_omni.engine.duplex import events as duplex_events
|
||||
from vllm_omni.engine.duplex.messages import DuplexSessionError
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
SESSION_ID = "duplex-0123456789abcdef"
|
||||
# session.created is not journaled by the server, so it carries no server_event_seq.
|
||||
SESSION_CREATED = {
|
||||
"type": "session.created",
|
||||
"session": {"session_id": "sess-1"},
|
||||
"incarnation": 0,
|
||||
"session": {"id": SESSION_ID, "model": "test-model"},
|
||||
"attachment_generation": 1,
|
||||
"resume_token": "tok-1",
|
||||
"server_event_seq": 1,
|
||||
}
|
||||
SESSION_CLOSED = {"type": "session.closed", "session_id": "sess-1", "server_event_seq": 99}
|
||||
SESSION_CLOSED = {"type": "session.closed", "session_id": SESSION_ID, "reason": "client_close", "server_event_seq": 99}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake websocket transport
|
||||
|
||||
|
||||
class FakeSocket:
|
||||
@@ -88,14 +108,139 @@ def make_client(*sockets: FakeSocket, **kwargs) -> tuple[DuplexClient, list[str]
|
||||
return client, calls
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake DuplexOmni for the inline client
|
||||
|
||||
|
||||
class _WireEvent:
|
||||
"""A typed-event stand-in that renders a fixed wire payload."""
|
||||
|
||||
def __init__(self, raw: dict[str, object]) -> None:
|
||||
self.raw = raw
|
||||
|
||||
@property
|
||||
def is_terminal(self) -> bool:
|
||||
return self.raw.get("type") in {"session.closed", "session.expired"}
|
||||
|
||||
def to_realtime(self) -> dict[str, object]:
|
||||
return dict(self.raw)
|
||||
|
||||
|
||||
class FakeInlineHandle:
|
||||
def __init__(self, session_id: str) -> None:
|
||||
self.session_id = session_id
|
||||
self.closed = False
|
||||
self.close_calls = 0
|
||||
self.commands: list[duplex_commands.DuplexCommand] = []
|
||||
self.consumer_active = False
|
||||
self._outbox: asyncio.Queue = asyncio.Queue()
|
||||
|
||||
def deliver(self, event: Any) -> None:
|
||||
self._outbox.put_nowait(event)
|
||||
|
||||
def feed(self, raw: dict[str, object]) -> None:
|
||||
self.deliver(_WireEvent(raw))
|
||||
|
||||
async def submit(self, command: duplex_commands.DuplexCommand) -> None:
|
||||
if self.closed:
|
||||
raise DuplexSessionError("closed", code="session_closed", session_id=self.session_id)
|
||||
self.commands.append(command)
|
||||
|
||||
async def events(self):
|
||||
self.consumer_active = True
|
||||
try:
|
||||
while True:
|
||||
event = await self._outbox.get()
|
||||
yield event
|
||||
if event.is_terminal:
|
||||
return
|
||||
finally:
|
||||
self.consumer_active = False
|
||||
|
||||
async def close(self, *, reason: str = "client_close", timeout: float | None = None) -> None:
|
||||
self.close_calls += 1
|
||||
if self.closed:
|
||||
return
|
||||
self.closed = True
|
||||
self.deliver(duplex_events.SessionClosed(session_id=self.session_id, reason=reason))
|
||||
|
||||
|
||||
class FakeInlineOmni:
|
||||
def __init__(self) -> None:
|
||||
self.opened: list[dict[str, object]] = []
|
||||
self.handles: list[FakeInlineHandle] = []
|
||||
|
||||
async def open_session(self, config: dict[str, object]) -> FakeInlineHandle:
|
||||
self.opened.append(dict(config))
|
||||
handle = FakeInlineHandle(SESSION_ID)
|
||||
self.handles.append(handle)
|
||||
handle.deliver(
|
||||
duplex_events.SessionCreated(
|
||||
session_id=SESSION_ID,
|
||||
session={"id": SESSION_ID, "model": config.get("model")},
|
||||
)
|
||||
)
|
||||
return handle
|
||||
|
||||
|
||||
def make_inline_client(**kwargs) -> tuple[InlineDuplexClient, FakeInlineOmni]:
|
||||
omni = FakeInlineOmni()
|
||||
kwargs.setdefault("handshake_timeout_s", 5.0)
|
||||
return InlineDuplexClient(omni, model="test-model", **kwargs), omni
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# One rig over both clients: ``feed`` a server event, drive ``client``
|
||||
|
||||
|
||||
class Rig:
|
||||
def __init__(self, client: DuplexClientBase, feed: Callable[[dict[str, object]], None]) -> None:
|
||||
self.client = client
|
||||
self.feed = feed
|
||||
|
||||
|
||||
def _websocket_rig(*, handshake: dict[str, object] | None = SESSION_CREATED) -> Rig:
|
||||
sock = FakeSocket()
|
||||
if handshake is not None:
|
||||
sock.feed(handshake)
|
||||
client, _ = make_client(sock)
|
||||
return Rig(client, sock.feed)
|
||||
|
||||
|
||||
def _inline_rig(*, handshake: dict[str, object] | None = SESSION_CREATED) -> Rig:
|
||||
client, omni = make_inline_client()
|
||||
original_open = omni.open_session
|
||||
|
||||
async def open_session(config: dict[str, object]) -> FakeInlineHandle:
|
||||
handle = await original_open(config)
|
||||
if handshake is not SESSION_CREATED:
|
||||
# Replace the default session.created with the requested first event.
|
||||
handle._outbox = asyncio.Queue()
|
||||
if handshake is not None:
|
||||
handle.feed(handshake)
|
||||
return handle
|
||||
|
||||
omni.open_session = open_session # type: ignore[method-assign]
|
||||
|
||||
def feed(raw: dict[str, object]) -> None:
|
||||
omni.handles[-1].feed(raw)
|
||||
|
||||
return Rig(client, feed)
|
||||
|
||||
|
||||
@pytest.fixture(params=["websocket", "inline"])
|
||||
def make_rig(request: pytest.FixtureRequest) -> Callable[..., Rig]:
|
||||
return _websocket_rig if request.param == "websocket" else _inline_rig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SessionConfig
|
||||
|
||||
|
||||
def test_session_config_payload_defaults():
|
||||
payload = SessionConfig().to_session_payload(model="m", session_id="s")
|
||||
payload = SessionConfig().to_session_payload(model="m")
|
||||
assert payload["model"] == "m"
|
||||
assert payload["session_id"] == "s"
|
||||
assert "session_id" not in payload
|
||||
assert payload["modalities"] == ["audio", "text"]
|
||||
assert payload["input_audio_format"] == "pcm16"
|
||||
assert payload["output_audio_format"] == "pcm16"
|
||||
@@ -137,19 +282,59 @@ def test_audio_format_math():
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handshake and lifecycle
|
||||
# Handshake and lifecycle (both clients)
|
||||
|
||||
|
||||
async def test_handshake_adopts_session_and_acks():
|
||||
@pytest.mark.asyncio
|
||||
async def test_handshake_adopts_server_allocated_session_id(make_rig):
|
||||
rig = make_rig()
|
||||
async with rig.client as client:
|
||||
assert client.session_id == SESSION_ID
|
||||
assert client.session_info == {"id": SESSION_ID, "model": "test-model"}
|
||||
rig.feed(SESSION_CLOSED)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handshake_error_raises_protocol_error(make_rig):
|
||||
rig = make_rig(handshake={"type": "error", "error": {"code": "unsupported_audio_format", "message": "bad"}})
|
||||
with pytest.raises(DuplexProtocolError) as excinfo:
|
||||
async with rig.client:
|
||||
pass
|
||||
assert excinfo.value.code == "unsupported_audio_format"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_expired_raises_from_event_stream(make_rig):
|
||||
rig = make_rig()
|
||||
async with rig.client as client:
|
||||
rig.feed(
|
||||
{"type": "session.expired", "session_id": SESSION_ID, "reason": "lease_expired", "server_event_seq": 2}
|
||||
)
|
||||
with pytest.raises(DuplexSessionClosedError) as excinfo:
|
||||
async for _ in client.events():
|
||||
pass
|
||||
assert "lease_expired" in excinfo.value.reason
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Websocket transport specifics
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_websocket_handshake_sends_session_update_and_acks_sequenced_events():
|
||||
sock = FakeSocket()
|
||||
sock.feed(SESSION_CREATED)
|
||||
client, calls = make_client(sock)
|
||||
async with client:
|
||||
assert client.session_id == "sess-1"
|
||||
assert client.session_id == SESSION_ID
|
||||
assert client.resume_token == "tok-1"
|
||||
assert calls == ["ws://test-host:8099/v1/realtime?duplex=1&model=test-model&autostart=0"]
|
||||
assert sock.sent[0]["type"] == "session.update"
|
||||
assert sock.sent[0]["session"]["model"] == "test-model"
|
||||
assert "session_id" not in sock.sent[0]["session"]
|
||||
# session.created is unjournaled (no seq): nothing to ack yet.
|
||||
assert _acks(sock) == []
|
||||
sock.feed({"type": "response.listen", "server_event_seq": 1})
|
||||
await _drain(lambda: {"type": "session.event_ack", "server_event_seq": 1} in _acks(sock))
|
||||
close_task = asyncio.create_task(client.close())
|
||||
await _drain(lambda: "session.close" in sock.sent_types())
|
||||
@@ -158,28 +343,7 @@ async def test_handshake_adopts_session_and_acks():
|
||||
assert "session.close" in sock.sent_types()
|
||||
|
||||
|
||||
async def test_handshake_error_raises_protocol_error():
|
||||
sock = FakeSocket()
|
||||
sock.feed({"type": "error", "error": {"code": "unsupported_audio_format", "message": "bad"}})
|
||||
client, _ = make_client(sock)
|
||||
with pytest.raises(DuplexProtocolError) as excinfo:
|
||||
async with client:
|
||||
pass
|
||||
assert excinfo.value.code == "unsupported_audio_format"
|
||||
|
||||
|
||||
async def test_session_expired_raises_from_event_stream():
|
||||
sock = FakeSocket()
|
||||
sock.feed(SESSION_CREATED)
|
||||
client, _ = make_client(sock)
|
||||
async with client:
|
||||
sock.feed({"type": "session.expired", "reason": "lease_expired", "server_event_seq": 2})
|
||||
with pytest.raises(DuplexSessionClosedError) as excinfo:
|
||||
async for _ in client.events():
|
||||
pass
|
||||
assert "lease_expired" in excinfo.value.reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resync_required_drops_resume_credential():
|
||||
sock = FakeSocket()
|
||||
sock.feed(SESSION_CREATED)
|
||||
@@ -187,7 +351,7 @@ async def test_resync_required_drops_resume_credential():
|
||||
async with client:
|
||||
assert client.resume_token == "tok-1"
|
||||
stream = client.events()
|
||||
sock.feed({"type": "session.resync_required", "session_id": "sess-1", "server_event_seq": 2})
|
||||
sock.feed({"type": "session.resync_required", "session_id": SESSION_ID, "server_event_seq": 2})
|
||||
async for event in stream:
|
||||
if event.type == "session.resync_required":
|
||||
break
|
||||
@@ -202,9 +366,10 @@ async def test_resync_required_drops_resume_credential():
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input events
|
||||
# Input events (websocket wire shape)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_audio_tracks_cumulative_end_ms():
|
||||
sock = FakeSocket()
|
||||
sock.feed(SESSION_CREATED)
|
||||
@@ -224,6 +389,7 @@ async def test_append_audio_tracks_cumulative_end_ms():
|
||||
sock.feed(SESSION_CLOSED)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_audio_strips_video_frame_data_url_prefix():
|
||||
sock = FakeSocket()
|
||||
sock.feed(SESSION_CREATED)
|
||||
@@ -238,6 +404,7 @@ async def test_append_audio_strips_video_frame_data_url_prefix():
|
||||
sock.feed(SESSION_CLOSED)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_pcm_chunking():
|
||||
sock = FakeSocket()
|
||||
sock.feed(SESSION_CREATED)
|
||||
@@ -249,6 +416,7 @@ async def test_stream_pcm_chunking():
|
||||
sock.feed(SESSION_CLOSED)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interruption_primitives_send_documented_events():
|
||||
sock = FakeSocket()
|
||||
sock.feed(SESSION_CREATED)
|
||||
@@ -262,22 +430,21 @@ async def test_interruption_primitives_send_documented_events():
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response demultiplexing
|
||||
# Response demultiplexing (both clients)
|
||||
|
||||
|
||||
def _b64(data: bytes) -> str:
|
||||
return base64.b64encode(data).decode("ascii")
|
||||
|
||||
|
||||
async def test_response_handle_flow():
|
||||
sock = FakeSocket()
|
||||
sock.feed(SESSION_CREATED)
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_handle_flow(make_rig):
|
||||
rig = make_rig()
|
||||
chunk = b"\x00\x01" * 2400 # 100 ms of pcm16 @ 24 kHz
|
||||
client, _ = make_client(sock)
|
||||
async with client:
|
||||
sock.feed({"type": "response.created", "response": {"id": "resp-1"}, "server_event_seq": 2})
|
||||
sock.feed({"type": "response.speak", "response_id": "resp-1", "server_event_seq": 3})
|
||||
sock.feed(
|
||||
async with rig.client as client:
|
||||
rig.feed({"type": "response.created", "response": {"id": "resp-1"}, "server_event_seq": 2})
|
||||
rig.feed({"type": "response.speak", "response_id": "resp-1", "server_event_seq": 3})
|
||||
rig.feed(
|
||||
{
|
||||
"type": "response.output_audio.delta",
|
||||
"response_id": "resp-1",
|
||||
@@ -286,7 +453,7 @@ async def test_response_handle_flow():
|
||||
"server_event_seq": 4,
|
||||
}
|
||||
)
|
||||
sock.feed(
|
||||
rig.feed(
|
||||
{
|
||||
"type": "response.output_audio_transcript.delta",
|
||||
"response_id": "resp-1",
|
||||
@@ -294,7 +461,7 @@ async def test_response_handle_flow():
|
||||
"server_event_seq": 5,
|
||||
}
|
||||
)
|
||||
sock.feed({"type": "response.done", "response_id": "resp-1", "server_event_seq": 6})
|
||||
rig.feed({"type": "response.done", "response_id": "resp-1", "server_event_seq": 6})
|
||||
|
||||
async for response in client.responses():
|
||||
chunks = [piece async for piece in response.audio()]
|
||||
@@ -304,49 +471,46 @@ async def test_response_handle_flow():
|
||||
assert response.played_ms == pytest.approx(100.0)
|
||||
assert response.finished
|
||||
break
|
||||
sock.feed(SESSION_CLOSED)
|
||||
rig.feed(SESSION_CLOSED)
|
||||
|
||||
|
||||
async def test_listen_decision_yields_finished_silent_handle():
|
||||
sock = FakeSocket()
|
||||
sock.feed(SESSION_CREATED)
|
||||
client, _ = make_client(sock)
|
||||
async with client:
|
||||
sock.feed({"type": "response.listen", "server_event_seq": 2})
|
||||
@pytest.mark.asyncio
|
||||
async def test_listen_decision_yields_finished_silent_handle(make_rig):
|
||||
rig = make_rig()
|
||||
async with rig.client as client:
|
||||
rig.feed({"type": "response.listen", "server_event_seq": 2})
|
||||
async for response in client.responses():
|
||||
assert response.decision == "listen"
|
||||
assert response.finished
|
||||
assert [piece async for piece in response.audio()] == []
|
||||
break
|
||||
sock.feed(SESSION_CLOSED)
|
||||
rig.feed(SESSION_CLOSED)
|
||||
|
||||
|
||||
async def test_listen_terminates_active_response():
|
||||
sock = FakeSocket()
|
||||
sock.feed(SESSION_CREATED)
|
||||
client, _ = make_client(sock)
|
||||
async with client:
|
||||
sock.feed({"type": "response.created", "response": {"id": "resp-1"}, "server_event_seq": 2})
|
||||
sock.feed({"type": "response.listen", "response_id": "resp-1", "server_event_seq": 3})
|
||||
@pytest.mark.asyncio
|
||||
async def test_listen_terminates_active_response(make_rig):
|
||||
rig = make_rig()
|
||||
async with rig.client as client:
|
||||
rig.feed({"type": "response.created", "response": {"id": "resp-1"}, "server_event_seq": 2})
|
||||
rig.feed({"type": "response.listen", "response_id": "resp-1", "server_event_seq": 3})
|
||||
async for response in client.responses():
|
||||
await response.wait(timeout_s=5.0)
|
||||
assert response.decision == "listen"
|
||||
break
|
||||
sock.feed(SESSION_CLOSED)
|
||||
rig.feed(SESSION_CLOSED)
|
||||
|
||||
|
||||
async def test_id_less_listen_never_closes_an_in_flight_response():
|
||||
@pytest.mark.asyncio
|
||||
async def test_id_less_listen_never_closes_an_in_flight_response(make_rig):
|
||||
# An id-less listen is a standalone decision beat (e.g. a silence-skip
|
||||
# while a response streams); it must surface as its own finished handle
|
||||
# and leave the in-flight response to its real terminal.
|
||||
sock = FakeSocket()
|
||||
sock.feed(SESSION_CREATED)
|
||||
client, _ = make_client(sock)
|
||||
async with client:
|
||||
sock.feed({"type": "response.created", "response": {"id": "resp-1"}, "server_event_seq": 2})
|
||||
sock.feed({"type": "response.speak", "response_id": "resp-1", "server_event_seq": 3})
|
||||
sock.feed({"type": "response.listen", "server_event_seq": 4})
|
||||
sock.feed({"type": "response.done", "response_id": "resp-1", "server_event_seq": 5})
|
||||
rig = make_rig()
|
||||
async with rig.client as client:
|
||||
rig.feed({"type": "response.created", "response": {"id": "resp-1"}, "server_event_seq": 2})
|
||||
rig.feed({"type": "response.speak", "response_id": "resp-1", "server_event_seq": 3})
|
||||
rig.feed({"type": "response.listen", "server_event_seq": 4})
|
||||
rig.feed({"type": "response.done", "response_id": "resp-1", "server_event_seq": 5})
|
||||
seen: list[tuple[str | None, str | None, bool]] = []
|
||||
async for response in client.responses():
|
||||
await response.wait(timeout_s=5.0)
|
||||
@@ -355,20 +519,19 @@ async def test_id_less_listen_never_closes_an_in_flight_response():
|
||||
break
|
||||
assert ("resp-1", "speak", True) in seen
|
||||
assert (None, "listen", True) in seen
|
||||
sock.feed(SESSION_CLOSED)
|
||||
rig.feed(SESSION_CLOSED)
|
||||
|
||||
|
||||
async def test_terminal_listen_keeps_spoken_decision():
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminal_listen_keeps_spoken_decision(make_rig):
|
||||
# A terminal listen after the response spoke is the model yielding the
|
||||
# turn; the handle must stay decision="speak" with its audio intact.
|
||||
sock = FakeSocket()
|
||||
sock.feed(SESSION_CREATED)
|
||||
rig = make_rig()
|
||||
chunk = b"\x00\x01" * 2400
|
||||
client, _ = make_client(sock)
|
||||
async with client:
|
||||
sock.feed({"type": "response.created", "response": {"id": "resp-1"}, "server_event_seq": 2})
|
||||
sock.feed({"type": "response.speak", "response_id": "resp-1", "server_event_seq": 3})
|
||||
sock.feed(
|
||||
async with rig.client as client:
|
||||
rig.feed({"type": "response.created", "response": {"id": "resp-1"}, "server_event_seq": 2})
|
||||
rig.feed({"type": "response.speak", "response_id": "resp-1", "server_event_seq": 3})
|
||||
rig.feed(
|
||||
{
|
||||
"type": "response.output_audio.delta",
|
||||
"response_id": "resp-1",
|
||||
@@ -377,24 +540,23 @@ async def test_terminal_listen_keeps_spoken_decision():
|
||||
"server_event_seq": 4,
|
||||
}
|
||||
)
|
||||
sock.feed({"type": "response.listen", "response_id": "resp-1", "server_event_seq": 5})
|
||||
rig.feed({"type": "response.listen", "response_id": "resp-1", "server_event_seq": 5})
|
||||
async for response in client.responses():
|
||||
chunks = [piece async for piece in response.audio()]
|
||||
assert chunks == [chunk]
|
||||
assert response.decision == "speak"
|
||||
assert response.finished
|
||||
break
|
||||
sock.feed(SESSION_CLOSED)
|
||||
rig.feed(SESSION_CLOSED)
|
||||
|
||||
|
||||
async def test_error_event_surfaces_on_response_path():
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_event_surfaces_on_response_path(make_rig):
|
||||
# A rejected send produces no response; a consumer waiting in responses()
|
||||
# must see the rejection instead of waiting forever.
|
||||
sock = FakeSocket()
|
||||
sock.feed(SESSION_CREATED)
|
||||
client, _ = make_client(sock)
|
||||
async with client:
|
||||
sock.feed(
|
||||
rig = make_rig()
|
||||
async with rig.client as client:
|
||||
rig.feed(
|
||||
{
|
||||
"type": "error",
|
||||
"error": {"code": "input_audio_buffer_empty", "message": "empty commit"},
|
||||
@@ -405,9 +567,10 @@ async def test_error_event_surfaces_on_response_path():
|
||||
async for _ in client.responses():
|
||||
pass
|
||||
assert excinfo.value.code == "input_audio_buffer_empty"
|
||||
sock.feed(SESSION_CLOSED)
|
||||
rig.feed(SESSION_CLOSED)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slow_audio_consumer_drops_oldest_instead_of_stalling():
|
||||
from vllm_omni.clients.duplex import AudioDelta, ResponseHandle
|
||||
|
||||
@@ -425,24 +588,24 @@ async def test_slow_audio_consumer_drops_oldest_instead_of_stalling():
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resume
|
||||
# Resume (websocket only)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_after_transport_drop():
|
||||
first = FakeSocket()
|
||||
first.feed(SESSION_CREATED)
|
||||
second = FakeSocket()
|
||||
# Mirror the real resume activation payload
|
||||
# (entrypoints/duplex/serving.py, activation_payload_factory): it carries
|
||||
# no nested "session" object, only the identity fields.
|
||||
# the identity fields plus the (empty) public session object.
|
||||
second.feed(
|
||||
{
|
||||
"type": "session.resumed",
|
||||
"session_id": "sess-1",
|
||||
"incarnation": 0,
|
||||
"attachment_generation": 1,
|
||||
"session_id": SESSION_ID,
|
||||
"session": {},
|
||||
"attachment_generation": 2,
|
||||
"resume_token": "tok-2",
|
||||
"server_event_seq": 5,
|
||||
}
|
||||
)
|
||||
client, calls = make_client(
|
||||
@@ -453,34 +616,39 @@ async def test_resume_after_transport_drop():
|
||||
async with client:
|
||||
stream = client.events()
|
||||
|
||||
async def take_two():
|
||||
async def collect_until_resumed():
|
||||
seen = []
|
||||
async for event in stream:
|
||||
seen.append(event)
|
||||
if len(seen) == 2:
|
||||
if isinstance(event, SessionResumed):
|
||||
return seen
|
||||
|
||||
consumer = asyncio.create_task(take_two())
|
||||
consumer = asyncio.create_task(collect_until_resumed())
|
||||
await asyncio.sleep(0) # let the consumer subscribe before the drop
|
||||
await asyncio.sleep(0)
|
||||
first.feed({"type": "response.listen", "server_event_seq": 7})
|
||||
await _drain(lambda: {"type": "session.event_ack", "server_event_seq": 7} in _acks(first))
|
||||
first.feed(RuntimeError("transport dropped"))
|
||||
seen = await asyncio.wait_for(consumer, timeout=5.0)
|
||||
assert isinstance(seen[0], ConnectionResumed)
|
||||
assert isinstance(seen[1], SessionResumed)
|
||||
assert [type(event) for event in seen[-2:]] == [ConnectionResumed, SessionResumed]
|
||||
assert client.resume_token == "tok-2"
|
||||
# The superseded transport is closed, not leaked.
|
||||
assert first.closed
|
||||
# The activation payload has no session object; the info captured at
|
||||
# the original handshake must survive the resume.
|
||||
assert client.session_info == {"session_id": "sess-1"}
|
||||
assert client.session_info == {"id": SESSION_ID, "model": "test-model"}
|
||||
assert len(calls) == 2
|
||||
resume = second.sent[0]
|
||||
assert resume["type"] == "session.resume"
|
||||
assert resume["session_id"] == "sess-1"
|
||||
assert resume["incarnation"] == 0
|
||||
assert resume["resume_token"] == "tok-1"
|
||||
assert resume["last_received_server_event_seq"] == 1
|
||||
assert resume == {
|
||||
"type": "session.resume",
|
||||
"session_id": SESSION_ID,
|
||||
"resume_token": "tok-1",
|
||||
"last_received_server_event_seq": 7,
|
||||
}
|
||||
second.feed(SESSION_CLOSED)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_reconnect_policy_surfaces_closed():
|
||||
sock = FakeSocket()
|
||||
sock.feed(SESSION_CREATED)
|
||||
@@ -493,6 +661,7 @@ async def test_no_reconnect_policy_surfaces_closed():
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heartbeat_survives_transient_send_failure():
|
||||
sock = FakeSocket()
|
||||
sock.feed(SESSION_CREATED)
|
||||
@@ -514,6 +683,7 @@ async def test_heartbeat_survives_transient_send_failure():
|
||||
sock.feed(SESSION_CLOSED)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aexit_sends_session_close_on_error_path():
|
||||
sock = FakeSocket()
|
||||
sock.feed(SESSION_CREATED)
|
||||
@@ -534,6 +704,7 @@ def test_target_url_forces_autostart_off():
|
||||
assert query["autostart"] == ["0"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fatal_resume_error_gives_up():
|
||||
first = FakeSocket()
|
||||
first.feed(SESSION_CREATED)
|
||||
@@ -553,6 +724,121 @@ async def test_fatal_resume_error_gives_up():
|
||||
assert len(calls) == 2 # no retry after a fatal resume error
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inline client over a fake DuplexOmni
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inline_handshake_opens_session_with_payload_and_adopts_id():
|
||||
client, omni = make_inline_client(config=SessionConfig(ref_audio="data:audio/wav;base64,AAA="))
|
||||
async with client:
|
||||
assert omni.opened == [client.config.to_session_payload(model="test-model")]
|
||||
assert omni.opened[0]["ref_audio"] == "data:audio/wav;base64,AAA="
|
||||
assert client.session_id == SESSION_ID
|
||||
handle = omni.handles[0]
|
||||
# The session was opened with the full session object already; the
|
||||
# base handshake's session.update must not be re-submitted as a patch
|
||||
# (a ref_audio patch would be rejected by the runner).
|
||||
assert handle.commands == []
|
||||
await client.send({"type": "session.update", "session": {"instructions": "later"}})
|
||||
assert [type(command) for command in handle.commands] == [duplex_commands.UpdateSession]
|
||||
assert handle.commands[0].patch == {"instructions": "later"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inline_client_submits_typed_commands_in_order():
|
||||
client, omni = make_inline_client()
|
||||
async with client:
|
||||
handle = omni.handles[0]
|
||||
pcm = b"\x01\x00" * 1600
|
||||
await client.append_audio(pcm, is_speech=True)
|
||||
await client.commit(create_response=True)
|
||||
await client.cancel_response("resp-1")
|
||||
await client.ack_playback(120.9, response_id="resp-1")
|
||||
assert [type(command) for command in handle.commands] == [
|
||||
duplex_commands.AppendAudio,
|
||||
duplex_commands.Commit,
|
||||
duplex_commands.CancelResponse,
|
||||
duplex_commands.AckPlayback,
|
||||
]
|
||||
append, commit, cancel, ack = handle.commands
|
||||
assert append.is_speech is True
|
||||
assert append.audio_end_ms == 100
|
||||
assert append.event_id
|
||||
assert commit.final is True and commit.create_response is True
|
||||
assert cancel.response_id == "resp-1"
|
||||
assert ack.played_ms == 120 and ack.response_id == "resp-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inline_session_close_maps_to_handle_close():
|
||||
client, omni = make_inline_client()
|
||||
async with client:
|
||||
pass
|
||||
handle = omni.handles[0]
|
||||
assert handle.closed
|
||||
assert handle.close_calls >= 1
|
||||
assert not any(isinstance(command, duplex_commands.CloseSession) for command in handle.commands)
|
||||
# The typed session.closed reached the client as the expected end.
|
||||
with pytest.raises(DuplexSessionClosedError):
|
||||
await client.send({"type": "session.heartbeat"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inline_client_forwards_heartbeats_and_drops_event_acks():
|
||||
client, omni = make_inline_client()
|
||||
async with client:
|
||||
handle = omni.handles[0]
|
||||
await client.send({"type": "session.heartbeat"})
|
||||
await client.send({"type": "session.event_ack", "server_event_seq": 3})
|
||||
# A heartbeat is a lease touch the engine must see; the ack is a journal concern.
|
||||
assert [type(command).__name__ for command in handle.commands] == ["Heartbeat"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inline_malformed_payload_surfaces_as_error_event():
|
||||
client, omni = make_inline_client()
|
||||
async with client:
|
||||
handle = omni.handles[0]
|
||||
# Subscribe before sending: the translation error is dispatched inline.
|
||||
first_event = asyncio.create_task(client.wait_for("error", timeout_s=5.0))
|
||||
await asyncio.sleep(0)
|
||||
event_id = await client.send({"type": "playback.ack"}) # played_ms missing
|
||||
event = await first_event
|
||||
assert isinstance(event, ErrorEvent)
|
||||
assert event.code == "bad_event"
|
||||
assert event.related_event_id == event_id
|
||||
assert handle.commands == []
|
||||
with pytest.raises(DuplexProtocolError) as excinfo:
|
||||
async for _ in client.responses():
|
||||
pass
|
||||
assert excinfo.value.code == "bad_event"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inline_teardown_cancels_pump_and_closes_handle():
|
||||
client, omni = make_inline_client()
|
||||
async with client:
|
||||
handle = omni.handles[0]
|
||||
await _drain(lambda: handle.consumer_active)
|
||||
pump = client._pump_task
|
||||
assert pump is not None and not pump.done()
|
||||
assert pump.done()
|
||||
assert not handle.consumer_active
|
||||
assert handle.closed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inline_send_after_handle_closed_raises_connection_error():
|
||||
client, omni = make_inline_client()
|
||||
async with client:
|
||||
handle = omni.handles[0]
|
||||
handle.closed = True # the engine ended the session without an event yet
|
||||
with pytest.raises(DuplexConnectionError, match="not open"):
|
||||
await client.send({"type": "input_audio_buffer.clear"})
|
||||
handle.closed = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Collector
|
||||
|
||||
@@ -600,8 +886,7 @@ def test_build_realtime_url_with_model_extra_query():
|
||||
url = build_realtime_url(
|
||||
"ws://localhost:8099/v1/realtime?custom=1",
|
||||
"openbmb/MiniCPM-o-4_5",
|
||||
session_id="session-a",
|
||||
extra_query={"native_duplex": "1"},
|
||||
extra_query={"probe": "1"},
|
||||
)
|
||||
|
||||
query = parse_qs(urlsplit(url).query)
|
||||
@@ -609,8 +894,7 @@ def test_build_realtime_url_with_model_extra_query():
|
||||
"custom": ["1"],
|
||||
"duplex": ["1"],
|
||||
"model": ["openbmb/MiniCPM-o-4_5"],
|
||||
"native_duplex": ["1"],
|
||||
"session_id": ["session-a"],
|
||||
"probe": ["1"],
|
||||
}
|
||||
|
||||
|
||||
@@ -619,12 +903,11 @@ def test_build_realtime_url_resume_only_when_autostart_disabled():
|
||||
"ws://localhost:8099/v1/realtime?duplex=1",
|
||||
"openbmb/MiniCPM-o-4_5",
|
||||
autostart=False,
|
||||
extra_query={"native_duplex": "1"},
|
||||
)
|
||||
|
||||
query = parse_qs(urlsplit(url).query)
|
||||
assert query["autostart"] == ["0"]
|
||||
assert query["native_duplex"] == ["1"]
|
||||
assert "session_id" not in query
|
||||
|
||||
|
||||
def test_event_collector_partitions_audio_by_response():
|
||||
@@ -989,6 +1272,7 @@ def test_duplex_unit_boundary_and_residual_math():
|
||||
assert chunk_period_ms([{"type": "session.created", "session": {}}]) == 1000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_pcm_sends_each_units_composite_beside_its_base_frame():
|
||||
sock = FakeSocket()
|
||||
sock.feed(SESSION_CREATED)
|
||||
@@ -1011,16 +1295,16 @@ async def test_stream_pcm_sends_each_units_composite_beside_its_base_frame():
|
||||
sock.feed(SESSION_CLOSED)
|
||||
|
||||
|
||||
def test_build_realtime_url_native_duplex_flag_and_http_scheme():
|
||||
url = build_realtime_url("http://localhost:8099/v1/realtime", None, native_duplex=None)
|
||||
def test_build_realtime_url_rewrites_http_scheme_and_rejects_others():
|
||||
url = build_realtime_url("http://localhost:8099/v1/realtime", None)
|
||||
parts = urlsplit(url)
|
||||
assert parts.scheme == "ws"
|
||||
assert parse_qs(parts.query) == {"duplex": ["1"]}
|
||||
|
||||
url = build_realtime_url("https://host/v1/realtime", "m", native_duplex=False)
|
||||
url = build_realtime_url("https://host/v1/realtime", "m", autostart=True)
|
||||
parts = urlsplit(url)
|
||||
assert parts.scheme == "wss"
|
||||
assert parse_qs(parts.query)["native_duplex"] == ["0"]
|
||||
assert parse_qs(parts.query) == {"duplex": ["1"], "model": ["m"], "autostart": ["1"]}
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
build_realtime_url("ftp://host/v1/realtime", "m")
|
||||
|
||||
@@ -18,13 +18,13 @@ from vllm_omni.clients.personaplex import (
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_minicpmo45_session_config_matches_native_duplex_deployment():
|
||||
def test_minicpmo45_session_config_matches_duplex_deployment():
|
||||
config = create_minicpmo45_session_config(ref_audio="data:audio/wav;base64,AAA=", temperature=0.0)
|
||||
payload = config.to_session_payload(model="openbmb/MiniCPM-o-4_5")
|
||||
assert payload["ref_audio"] == "data:audio/wav;base64,AAA="
|
||||
assert payload["overlap_policy"] == "listen_only"
|
||||
assert payload["playback_commit_policy"] == "ack_only"
|
||||
assert payload["extra_body"]["native_duplex"] is True
|
||||
assert "native_duplex" not in payload["extra_body"]
|
||||
assert payload["extra_body"]["auto_response"] is True
|
||||
assert payload["temperature"] == 0.0
|
||||
|
||||
@@ -41,8 +41,9 @@ def test_personaplex_session_config_matches_deployment():
|
||||
@pytest.mark.parametrize(
|
||||
"duplex_package",
|
||||
[
|
||||
# PersonaPlex and Nemotron VoiceChat rejoin this list with the follow-up
|
||||
# PRs that port them to the duplex plugin framework.
|
||||
"vllm_omni.model_executor.models.minicpmo_4_5.duplex",
|
||||
"vllm_omni.model_executor.models.personaplex.duplex",
|
||||
],
|
||||
)
|
||||
def test_model_duplex_packages_stay_clear_of_client_library(duplex_package: str):
|
||||
|
||||
@@ -1328,7 +1328,11 @@ class TestDeployConfigLoading:
|
||||
stages = merge_pipeline_deploy(pipeline, deploy)
|
||||
|
||||
assert deploy.session_mode == "duplex"
|
||||
assert deploy.active_stream_window == max_sessions
|
||||
# ``0`` disables the limiter: the configured session/stage capacity is
|
||||
# what bounds the pipeline. Any positive window serializes concurrent
|
||||
# audio first-packet generation (measured: TTFP -23% at 0 vs 4 on
|
||||
# L20X, 128 requests / concurrency 8).
|
||||
assert deploy.active_stream_window == 0
|
||||
assert deploy.duplex_session.max_sessions == max_sessions
|
||||
assert [stage.session_mode for stage in stages] == ["duplex", "duplex", "duplex"]
|
||||
assert [stage.to_omegaconf().session_mode for stage in stages] == ["duplex", "duplex", "duplex"]
|
||||
@@ -2587,6 +2591,26 @@ class TestPlatformOverrides:
|
||||
assert rocm.stages[0].enforce_eager is None
|
||||
assert rocm.stages[1].enforce_eager is True
|
||||
|
||||
@pytest.mark.parametrize("deploy_name", ["qwen3_tts.yaml", "qwen3_tts_high_concurrency.yaml"])
|
||||
@pytest.mark.parametrize("platform", ["cuda", "npu", "rocm"])
|
||||
def test_qwen3_tts_default_code2wav_dtype_is_bf16(self, deploy_name, platform):
|
||||
deploy = load_deploy_config(Path(get_deploy_config_path(deploy_name)))
|
||||
deploy = _apply_platform_overrides(deploy, platform=platform)
|
||||
stages = merge_pipeline_deploy(resolve_pipeline_config("qwen3_tts"), deploy)
|
||||
|
||||
assert stages[1].yaml_engine_args["dtype"] == "bfloat16"
|
||||
|
||||
@pytest.mark.parametrize("deploy_name", ["qwen3_tts.yaml", "qwen3_tts_high_concurrency.yaml"])
|
||||
@pytest.mark.parametrize("platform", ["cuda", "npu"])
|
||||
@pytest.mark.parametrize("dtype", ["float32", "bfloat16", "float16"])
|
||||
def test_qwen3_tts_propagates_explicit_code2wav_dtype(self, deploy_name, platform, dtype):
|
||||
deploy = load_deploy_config(Path(get_deploy_config_path(deploy_name)))
|
||||
deploy.stages[1].engine_extras["dtype"] = dtype
|
||||
deploy = _apply_platform_overrides(deploy, platform=platform)
|
||||
stages = merge_pipeline_deploy(resolve_pipeline_config("qwen3_tts"), deploy)
|
||||
|
||||
assert stages[1].yaml_engine_args["dtype"] == dtype
|
||||
|
||||
def test_higgs_audio_v3_rocm_uses_triton_attention(self):
|
||||
deploy_path = Path(get_deploy_config_path("higgs_multimodal_qwen3.yaml"))
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ def test_inventory_matches_reviewed_snapshot_counts():
|
||||
"""Make an inventory expansion an explicit review decision."""
|
||||
category_counts = Counter(item.category for item in ENVIRONMENT_VARIABLE_INVENTORY.values())
|
||||
assert category_counts == {
|
||||
EnvironmentVariableCategory.PUBLIC_OMNI: 27,
|
||||
EnvironmentVariableCategory.PUBLIC_OMNI: 28,
|
||||
EnvironmentVariableCategory.INHERITED_VLLM: 20,
|
||||
EnvironmentVariableCategory.PLATFORM_EXTERNAL: 27,
|
||||
EnvironmentVariableCategory.MODEL_SPECIFIC: 64,
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
{
|
||||
"dataset_name": "seed-tts",
|
||||
"dataset_path": "zhaochenyang20/seed-tts-eval",
|
||||
"backend": "openai-chat-omni",
|
||||
"endpoint": "/v1/chat/completions",
|
||||
"backend": "openai-realtime-tts",
|
||||
"endpoint": "/v1/realtime",
|
||||
"num_prompts": [
|
||||
32,
|
||||
64,
|
||||
@@ -42,73 +42,7 @@
|
||||
"disable_shuffle": true,
|
||||
"trust_remote_code": true,
|
||||
"seed_tts_locale": "en",
|
||||
"extra_body": {
|
||||
"modalities": [
|
||||
"text",
|
||||
"audio"
|
||||
],
|
||||
"chat_template_kwargs": {
|
||||
"enable_thinking": false,
|
||||
"use_tts_template": true
|
||||
}
|
||||
},
|
||||
"percentile-metrics": "ttft,e2el,audio_ttfp,audio_rtf,audio_duration",
|
||||
"baseline": {
|
||||
"H100": {
|
||||
"request_throughput": [
|
||||
0.9731,
|
||||
1.1986,
|
||||
1.686
|
||||
],
|
||||
"mean_ttft_ms": [
|
||||
152.5665,
|
||||
270.2559,
|
||||
301.455
|
||||
],
|
||||
"mean_e2el_ms": [
|
||||
1027.2953,
|
||||
3306.3735,
|
||||
4686.5366
|
||||
],
|
||||
"mean_audio_ttfp_ms": [
|
||||
458.1553,
|
||||
2030.2335,
|
||||
2068.6233
|
||||
],
|
||||
"mean_audio_rtf": [
|
||||
0.2357,
|
||||
0.791,
|
||||
1.0324
|
||||
]
|
||||
},
|
||||
"A3": {
|
||||
"request_throughput": [
|
||||
0.5383,
|
||||
0.6042,
|
||||
0.7547
|
||||
],
|
||||
"mean_ttft_ms": [
|
||||
333.2633,
|
||||
533.8656,
|
||||
547.3388
|
||||
],
|
||||
"mean_e2el_ms": [
|
||||
1857.2154,
|
||||
6600.5101,
|
||||
10450.6811
|
||||
],
|
||||
"mean_audio_ttfp_ms": [
|
||||
986.4666,
|
||||
3411.0754,
|
||||
3352.7515
|
||||
],
|
||||
"mean_audio_rtf": [
|
||||
0.4423,
|
||||
1.5734,
|
||||
2.3024
|
||||
]
|
||||
}
|
||||
}
|
||||
"percentile-metrics": "ttft,e2el,audio_ttfp,audio_rtf,audio_duration"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -138,8 +72,8 @@
|
||||
{
|
||||
"dataset_name": "seed-tts",
|
||||
"dataset_path": "zhaochenyang20/seed-tts-eval",
|
||||
"backend": "openai-chat-omni",
|
||||
"endpoint": "/v1/chat/completions",
|
||||
"backend": "openai-realtime-tts",
|
||||
"endpoint": "/v1/realtime",
|
||||
"num_prompts": [
|
||||
32,
|
||||
64,
|
||||
@@ -154,16 +88,6 @@
|
||||
"disable_shuffle": true,
|
||||
"trust_remote_code": true,
|
||||
"seed_tts_locale": "en",
|
||||
"extra_body": {
|
||||
"modalities": [
|
||||
"text",
|
||||
"audio"
|
||||
],
|
||||
"chat_template_kwargs": {
|
||||
"enable_thinking": false,
|
||||
"use_tts_template": true
|
||||
}
|
||||
},
|
||||
"percentile-metrics": "ttft,e2el,audio_ttfp,audio_rtf,audio_duration"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -43,43 +43,7 @@
|
||||
"extra_body": {
|
||||
"save_duplex_request_metrics": true
|
||||
},
|
||||
"percentile-metrics": "ttft,tpot,itl,e2el,audio_ttfp,audio_rtf,audio_duration",
|
||||
"baseline": {
|
||||
"H100": {
|
||||
"request_throughput": [
|
||||
0.3038
|
||||
],
|
||||
"mean_ttft_ms": [
|
||||
139.997
|
||||
],
|
||||
"mean_e2el_ms": [
|
||||
3269.8807
|
||||
],
|
||||
"mean_audio_ttfp_ms": [
|
||||
456.073
|
||||
],
|
||||
"mean_audio_rtf": [
|
||||
0.2254
|
||||
]
|
||||
},
|
||||
"A3": {
|
||||
"request_throughput": [
|
||||
0.1594
|
||||
],
|
||||
"mean_ttft_ms": [
|
||||
274.164
|
||||
],
|
||||
"mean_e2el_ms": [
|
||||
6250.7696
|
||||
],
|
||||
"mean_audio_ttfp_ms": [
|
||||
994.422
|
||||
],
|
||||
"mean_audio_rtf": [
|
||||
0.46163
|
||||
]
|
||||
}
|
||||
}
|
||||
"percentile-metrics": "ttft,tpot,itl,e2el,audio_ttfp,audio_rtf,audio_duration"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
{
|
||||
"dataset_name": "seed-tts",
|
||||
"dataset_path": "zhaochenyang20/seed-tts-eval",
|
||||
"backend": "openai-chat-omni",
|
||||
"endpoint": "/v1/chat/completions",
|
||||
"backend": "openai-realtime-tts",
|
||||
"endpoint": "/v1/realtime",
|
||||
"num_prompts": [
|
||||
10,
|
||||
40
|
||||
@@ -40,41 +40,7 @@
|
||||
"disable_shuffle": true,
|
||||
"trust_remote_code": true,
|
||||
"seed_tts_locale": "en",
|
||||
"extra_body": {
|
||||
"modalities": [
|
||||
"text",
|
||||
"audio"
|
||||
],
|
||||
"chat_template_kwargs": {
|
||||
"enable_thinking": false,
|
||||
"use_tts_template": true
|
||||
}
|
||||
},
|
||||
"percentile-metrics": "ttft,e2el,audio_ttfp,audio_rtf,audio_duration",
|
||||
"baseline": {
|
||||
"H100": {
|
||||
"request_throughput": [
|
||||
0.9731,
|
||||
1.1986
|
||||
],
|
||||
"mean_ttft_ms": [
|
||||
152.5665,
|
||||
270.2559
|
||||
],
|
||||
"mean_e2el_ms": [
|
||||
1027.2953,
|
||||
3306.3735
|
||||
],
|
||||
"mean_audio_ttfp_ms": [
|
||||
458.1553,
|
||||
2030.2335
|
||||
],
|
||||
"mean_audio_rtf": [
|
||||
0.2357,
|
||||
0.791
|
||||
]
|
||||
}
|
||||
}
|
||||
"percentile-metrics": "ttft,e2el,audio_ttfp,audio_rtf,audio_duration"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -157,14 +157,8 @@ def test_speech_missing_required_fields(omni_server: OmniServer, online_client:
|
||||
"ref_audio": None,
|
||||
"ref_text": None,
|
||||
},
|
||||
# NOTE: This is delegating down the TTS adapter, so the message is both specific
|
||||
# to the Qwen3TTS adapter and dependent on whether or not the model has any speakers
|
||||
# (as opposed to having valida speakers, but getting an invalid speaker). it would
|
||||
# be more ideal to validate that this is landing on `.validate()` on the adapter
|
||||
# and ensuring that the returned message matches what we would get from calling the
|
||||
# adapter validate directly.
|
||||
("no speakers configured",),
|
||||
id="customvoice_invalid_voice",
|
||||
("Base checkpoint does not support task_type='CustomVoice'",),
|
||||
id="base_checkpoint_customvoice_task_mismatch",
|
||||
),
|
||||
pytest.param(
|
||||
{"task_type": "InvalidEnum"}, ("task_type", "literal_error", "CustomVoice"), id="task_type_invalid"
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch._subclasses.fake_tensor import FakeTensorMode
|
||||
|
||||
from vllm_omni.diffusion.attention.ops.block_sparse import (
|
||||
build_prefix_dense_block_map,
|
||||
fastvideo_block_sparse_attn_bshd,
|
||||
mean_pool_tiles,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix_blocks,topk", [(0, 1), (2, 0), (2, 1), (2, 3), (2, 5)])
|
||||
def test_block_map_keeps_prefix_dense_and_selects_non_prefix_keys(prefix_blocks, topk):
|
||||
sparse_blocks = 3
|
||||
blocks = prefix_blocks + sparse_blocks
|
||||
scores = torch.arange(2 * blocks * blocks, dtype=torch.float32).reshape(1, 2, blocks, blocks)
|
||||
block_map = build_prefix_dense_block_map(scores, prefix_blocks, sparse_blocks, topk)
|
||||
expected = torch.zeros_like(scores, dtype=torch.bool)
|
||||
# Scores increase with key index, so top-k always selects the last keys.
|
||||
for query_block in range(blocks):
|
||||
for key_block in range(blocks):
|
||||
if (
|
||||
query_block < prefix_blocks
|
||||
or key_block < prefix_blocks
|
||||
or key_block >= blocks - min(topk, sparse_blocks)
|
||||
):
|
||||
expected[..., query_block, key_block] = True
|
||||
assert torch.equal(block_map, expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("block_size", [4, 32, 64])
|
||||
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16])
|
||||
def test_pooling_uses_valid_tile_sizes_and_fp32_accumulation(block_size, dtype):
|
||||
sizes = torch.tensor([block_size, block_size - 1, 0], dtype=torch.int32)
|
||||
tiles = torch.arange(2 * 3 * block_size * 2 * 3).reshape(2, 3, block_size, 2, 3).to(dtype)
|
||||
tiles[:, 1, -1] = 0
|
||||
tiles[:, 2] = 0
|
||||
expected = torch.stack(
|
||||
[tiles[:, 0].float().mean(dim=1), tiles[:, 1, :-1].float().mean(dim=1), torch.zeros(2, 2, 3)], dim=2
|
||||
)
|
||||
result = mean_pool_tiles(tiles.flatten(1, 2), sizes, block_size)
|
||||
assert result.dtype == torch.float32
|
||||
torch.testing.assert_close(result, expected, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_provider_wrapper_restores_transport_rows_and_bshd_layout(monkeypatch):
|
||||
monkeypatch.delenv("FASTVIDEO_VSA_SM100A", raising=False)
|
||||
calls = []
|
||||
|
||||
def block_sparse_attn(q, k, v, block_map, sizes):
|
||||
calls.append((q.shape, block_map.shape, sizes.tolist()))
|
||||
assert q.is_contiguous() and k.is_contiguous() and v.is_contiguous()
|
||||
assert sizes.dtype == torch.int32
|
||||
return q + k + v, None
|
||||
|
||||
module = types.ModuleType("fastvideo_kernel.block_sparse_attn")
|
||||
setattr(module, "block_sparse_attn", block_sparse_attn)
|
||||
monkeypatch.setitem(sys.modules, "fastvideo_kernel", types.ModuleType("fastvideo_kernel"))
|
||||
monkeypatch.setitem(sys.modules, "fastvideo_kernel.block_sparse_attn", module)
|
||||
query = torch.randn(1, 4 * 64, 2, 8)
|
||||
block_map = torch.ones(1, 2, 4, 4, dtype=torch.bool)
|
||||
sizes = torch.tensor([64, 17, 64, 0])
|
||||
output = fastvideo_block_sparse_attn_bshd(query, query, query, block_map, sizes, 3)
|
||||
expected = torch.zeros_like(query)
|
||||
expected[:, : 3 * 64] = query[:, : 3 * 64] * 3
|
||||
torch.testing.assert_close(output, expected)
|
||||
assert calls == [(torch.Size([1, 2, 3 * 64, 8]), torch.Size([1, 2, 3, 3]), [64, 17, 64])]
|
||||
|
||||
|
||||
def test_custom_op_fake_preserves_metadata_without_loading_provider(monkeypatch):
|
||||
monkeypatch.setitem(sys.modules, "fastvideo_kernel", None)
|
||||
with FakeTensorMode():
|
||||
query = torch.empty(1, 4 * 64, 2, 8, dtype=torch.bfloat16)
|
||||
block_map = torch.empty(1, 2, 4, 4, dtype=torch.bool)
|
||||
sizes = torch.empty(4, dtype=torch.int32)
|
||||
output = fastvideo_block_sparse_attn_bshd(query, query, query, block_map, sizes, 3)
|
||||
assert output.shape == query.shape
|
||||
assert output.dtype == query.dtype
|
||||
assert output.device == query.device
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error_type", [ImportError, RuntimeError, torch.AcceleratorError])
|
||||
def test_native_provider_failure_only_recovers_without_accelerator_fault(monkeypatch, mocker, error_type):
|
||||
monkeypatch.setenv("FASTVIDEO_VSA_SM100A", "1")
|
||||
error = error_type("native provider failure")
|
||||
native = mocker.Mock()
|
||||
native.is_supported.return_value = True
|
||||
native.block_sparse_attn_sm100a.side_effect = error
|
||||
provider = types.ModuleType("fastvideo_kernel")
|
||||
setattr(provider, "block_sparse_attn_sm100a", native)
|
||||
index = types.ModuleType("fastvideo_kernel.triton_kernels.index")
|
||||
setattr(
|
||||
index,
|
||||
"map_to_index",
|
||||
mocker.Mock(return_value=(torch.zeros(1, dtype=torch.int32), torch.ones(1, dtype=torch.int32))),
|
||||
)
|
||||
triton = types.ModuleType("fastvideo_kernel.block_sparse_attn")
|
||||
fallback = mocker.Mock(side_effect=lambda q, *args: (q.clone(), None))
|
||||
setattr(triton, "block_sparse_attn", fallback)
|
||||
monkeypatch.setitem(sys.modules, "fastvideo_kernel", provider)
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "fastvideo_kernel.triton_kernels", types.ModuleType("fastvideo_kernel.triton_kernels")
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "fastvideo_kernel.triton_kernels.index", index)
|
||||
monkeypatch.setitem(sys.modules, "fastvideo_kernel.block_sparse_attn", triton)
|
||||
query = torch.arange(128 * 2 * 8, dtype=torch.float32).reshape(1, 128, 2, 8)
|
||||
block_map = torch.ones(1, 2, 2, 2, dtype=torch.bool)
|
||||
sizes = torch.tensor([64, 64], dtype=torch.int32)
|
||||
|
||||
if error_type is torch.AcceleratorError:
|
||||
with pytest.raises(torch.AcceleratorError, match="native provider failure") as raised:
|
||||
fastvideo_block_sparse_attn_bshd(query, query, query, block_map, sizes, 2)
|
||||
assert raised.value is error
|
||||
fallback.assert_not_called()
|
||||
else:
|
||||
output = fastvideo_block_sparse_attn_bshd(query, query, query, block_map, sizes, 2)
|
||||
torch.testing.assert_close(output, query, rtol=0, atol=0)
|
||||
fallback.assert_called_once()
|
||||
@@ -1,7 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import math
|
||||
import sys
|
||||
import types
|
||||
from typing import Any
|
||||
@@ -12,14 +11,10 @@ import torch
|
||||
from vllm_omni.diffusion.attention.backends.abstract import (
|
||||
AttentionMetadata,
|
||||
PackedPaddingMetadata,
|
||||
VideoTokenLayout,
|
||||
VideoTokenSpan,
|
||||
)
|
||||
from vllm_omni.diffusion.attention.backends.fastvideo_vsa import (
|
||||
FastVideoVSABackend,
|
||||
FastVideoVSAImpl,
|
||||
_build_h3_block_map,
|
||||
_get_h3_tile_metadata,
|
||||
)
|
||||
from vllm_omni.diffusion.attention.backends.registry import (
|
||||
DiffusionAttentionBackendEnum,
|
||||
@@ -218,144 +213,8 @@ def test_fastvideo_vsa_allows_topk_equal_to_num_blocks():
|
||||
assert too_many._fallback_reason(query, query, query, metadata) == "topk 3 > num_blocks 2"
|
||||
|
||||
|
||||
def _h3_metadata(prefix_segments, video_shape, *, gate=None, packed_padding=None):
|
||||
extra: dict[str, Any] = {
|
||||
"vsa_h3_prefix_segments": prefix_segments,
|
||||
}
|
||||
if gate is not None:
|
||||
extra["gate_compress"] = gate
|
||||
return AttentionMetadata(
|
||||
packed_padding=packed_padding,
|
||||
video_layout=VideoTokenLayout(
|
||||
used_len=sum(prefix_segments) + math.prod(video_shape),
|
||||
video_spans=(VideoTokenSpan(start=sum(prefix_segments), latent_grid=video_shape, role="target"),),
|
||||
),
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
def _fake_block_sparse_kernel(monkeypatch, calls):
|
||||
"""Stand in for fastvideo_kernel.block_sparse_attn with a mask-obeying SDPA."""
|
||||
|
||||
def block_sparse_attn(q, k, v, block_map, variable_block_sizes):
|
||||
calls["block_map"] = block_map.clone()
|
||||
calls["variable_block_sizes"] = variable_block_sizes.tolist()
|
||||
calls["q_shape"] = tuple(q.shape)
|
||||
blocks = block_map.shape[-1]
|
||||
assert blocks * 64 == q.shape[2]
|
||||
# Expand the per-block map to a per-token mask so the stand-in honours
|
||||
# the sparsity contract the real kernel implements, and drop the rows
|
||||
# each partial block pads with, which the real kernel skips through
|
||||
# variable_block_sizes.
|
||||
token_mask = block_map.repeat_interleave(64, dim=-1).repeat_interleave(64, dim=-2)
|
||||
within = torch.arange(64, device=q.device)[None, :] < variable_block_sizes[:, None]
|
||||
token_mask = token_mask & within.reshape(1, 1, 1, -1)
|
||||
out = torch.nn.functional.scaled_dot_product_attention(q.float(), k.float(), v.float(), attn_mask=token_mask)
|
||||
return out.to(q.dtype), None
|
||||
|
||||
module = types.ModuleType("fastvideo_kernel.block_sparse_attn")
|
||||
setattr(module, "block_sparse_attn", block_sparse_attn)
|
||||
monkeypatch.setitem(sys.modules, "fastvideo_kernel", types.ModuleType("fastvideo_kernel"))
|
||||
monkeypatch.setitem(sys.modules, "fastvideo_kernel.block_sparse_attn", module)
|
||||
|
||||
|
||||
def _h3_impl(**backend_kwargs):
|
||||
kwargs = {"topk": 1, "min_seq_len": 1, "disable_when_sp_active": False}
|
||||
kwargs.update(backend_kwargs)
|
||||
return FastVideoVSAImpl(num_heads=2, head_size=8, softmax_scale=8**-0.5, backend_kwargs=kwargs)
|
||||
|
||||
|
||||
def test_h3_forward_tiles_untiles_and_restores_the_original_row_order(monkeypatch):
|
||||
calls: dict[str, Any] = {}
|
||||
_fake_block_sparse_kernel(monkeypatch, calls)
|
||||
impl = _h3_impl(topk=64)
|
||||
prefix_segments, video_shape = (5, 70), (2, 4, 4)
|
||||
seq_len = sum(prefix_segments) + 2 * 4 * 4
|
||||
torch.manual_seed(0)
|
||||
query = torch.randn(1, seq_len, 2, 8, dtype=torch.bfloat16)
|
||||
|
||||
output = impl.forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape))
|
||||
|
||||
assert output.shape == query.shape
|
||||
# topk covers every video block, so the block map is dense and the tiled
|
||||
# kernel must reproduce plain attention over the same rows.
|
||||
reference = torch.nn.functional.scaled_dot_product_attention(
|
||||
*(x.float().transpose(1, 2) for x in (query, query, query))
|
||||
).transpose(1, 2)
|
||||
torch.testing.assert_close(output.float(), reference, atol=2e-2, rtol=2e-2)
|
||||
# 5 + 70 splits into segment-pure 5 | 64 | 6, then one (2, 4, 4) video tile.
|
||||
assert calls["variable_block_sizes"] == [5, 64, 6, 32]
|
||||
|
||||
|
||||
def test_h3_forward_keeps_prefix_dense_and_selects_top_k_video_tiles(monkeypatch):
|
||||
calls: dict[str, Any] = {}
|
||||
_fake_block_sparse_kernel(monkeypatch, calls)
|
||||
impl = _h3_impl(topk=1)
|
||||
prefix_segments, video_shape = (5,), (8, 4, 4)
|
||||
seq_len = 5 + 8 * 4 * 4
|
||||
torch.manual_seed(0)
|
||||
query = torch.randn(1, seq_len, 2, 8, dtype=torch.bfloat16)
|
||||
|
||||
impl.forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape))
|
||||
|
||||
# 1 prefix block + 2 video blocks. The odd count is padded to an even one
|
||||
# for the paired-CTA kernel contract, and the Triton route slices that
|
||||
# transport-only partner off again before launching.
|
||||
block_map = calls["block_map"]
|
||||
assert block_map.shape[-1] == 3
|
||||
assert calls["variable_block_sizes"] == [5, 64, 64]
|
||||
assert block_map[:, :, :1, :].all(), "prefix queries stay dense"
|
||||
assert block_map[..., :1].all(), "prefix keys are exempt from selection"
|
||||
assert (block_map[:, :, 1:, 1:].sum(dim=-1) == 1).all(), "one video tile per video query"
|
||||
|
||||
# The same odd geometry must still untile back onto the original rows.
|
||||
dense = _h3_impl(topk=64).forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape))
|
||||
reference = torch.nn.functional.scaled_dot_product_attention(
|
||||
*(x.float().transpose(1, 2) for x in (query, query, query))
|
||||
).transpose(1, 2)
|
||||
torch.testing.assert_close(dense.float(), reference, atol=2e-2, rtol=2e-2)
|
||||
|
||||
|
||||
def test_h3_forward_applies_the_learned_compression_gate(monkeypatch):
|
||||
_fake_block_sparse_kernel(monkeypatch, {})
|
||||
impl = _h3_impl(topk=64)
|
||||
prefix_segments, video_shape = (5,), (2, 4, 4)
|
||||
seq_len = 5 + 2 * 4 * 4
|
||||
torch.manual_seed(0)
|
||||
query = torch.randn(1, seq_len, 2, 8, dtype=torch.bfloat16)
|
||||
|
||||
without_gate = impl.forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape))
|
||||
zero_gate = torch.zeros_like(query)
|
||||
with_zero_gate = impl.forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape, gate=zero_gate))
|
||||
live_gate = torch.full_like(query, 0.5)
|
||||
with_live_gate = impl.forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape, gate=live_gate))
|
||||
|
||||
torch.testing.assert_close(with_zero_gate.float(), without_gate.float())
|
||||
assert not torch.allclose(with_live_gate.float(), without_gate.float())
|
||||
|
||||
|
||||
def test_h3_forward_restores_rows_the_packed_padding_excludes(monkeypatch):
|
||||
_fake_block_sparse_kernel(monkeypatch, {})
|
||||
impl = _h3_impl(topk=64)
|
||||
prefix_segments, video_shape = (5,), (2, 4, 4)
|
||||
valid = 5 + 2 * 4 * 4
|
||||
torch.manual_seed(0)
|
||||
query = torch.randn(1, valid + 24, 2, 8, dtype=torch.bfloat16)
|
||||
padding = PackedPaddingMetadata(
|
||||
q_length=valid,
|
||||
kv_length=valid,
|
||||
cu_seqlens_q=torch.tensor([0, valid], dtype=torch.int32),
|
||||
cu_seqlens_k=torch.tensor([0, valid], dtype=torch.int32),
|
||||
)
|
||||
|
||||
output = impl.forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape, packed_padding=padding))
|
||||
|
||||
assert output.shape == query.shape
|
||||
assert torch.count_nonzero(output[:, valid:]) == 0
|
||||
|
||||
|
||||
def test_sdpa_fallback_never_attends_the_structural_padding(monkeypatch):
|
||||
# The backend advertises supports_packed_mask_free, so MiniMax-H3 skips
|
||||
# The backend advertises supports_packed_mask_free, so a model may skip
|
||||
# building the padding mask. Every fallback out of the VSA path must honour
|
||||
# that contract itself; SDPA reads attn_mask and nothing else.
|
||||
impl = FastVideoVSAImpl(
|
||||
@@ -374,6 +233,8 @@ def test_sdpa_fallback_never_attends_the_structural_padding(monkeypatch):
|
||||
cu_seqlens_k=torch.tensor([0, valid], dtype=torch.int32),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(impl.sdpa_fallback, "forward", impl.sdpa_fallback.forward_cuda)
|
||||
|
||||
baseline = impl.forward_cuda(query, query, query, AttentionMetadata(packed_padding=padding, extra={}))
|
||||
perturbed_input = query.clone()
|
||||
perturbed_input[:, valid:] += 100.0
|
||||
@@ -384,22 +245,3 @@ def test_sdpa_fallback_never_attends_the_structural_padding(monkeypatch):
|
||||
assert baseline.shape == query.shape
|
||||
torch.testing.assert_close(baseline[:, :valid], perturbed[:, :valid])
|
||||
assert torch.count_nonzero(baseline[:, valid:]) == 0
|
||||
|
||||
|
||||
def test_h3_geometry_keeps_prefix_segments_pure_and_tiles_video_3d():
|
||||
partition, sizes, non_pad, untile, prefix_blocks, video_blocks = _get_h3_tile_metadata(
|
||||
(5, 70, 9), (5, 6, 6), torch.device("cpu")
|
||||
)
|
||||
assert sizes[:4].tolist() == [5, 64, 6, 9]
|
||||
assert prefix_blocks == 4
|
||||
assert video_blocks == 8
|
||||
assert int(sizes.sum()) == 5 + 70 + 9 + 5 * 6 * 6
|
||||
assert partition.numel() == non_pad.numel() == untile.numel() == int(sizes.sum())
|
||||
|
||||
|
||||
def test_h3_block_map_makes_prefix_queries_dense_and_prefix_keys_exempt():
|
||||
scores = torch.arange(1 * 2 * 5 * 5, dtype=torch.float32).reshape(1, 2, 5, 5)
|
||||
block_map = _build_h3_block_map(scores, num_prefix_blocks=2, num_video_blocks=3, topk=1)
|
||||
assert block_map[:, :, :2].all()
|
||||
assert block_map[..., :2].all()
|
||||
assert (block_map[:, :, 2:, 2:].sum(dim=-1) == 1).all()
|
||||
@@ -0,0 +1,136 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.diffusion.attention import layer
|
||||
from vllm_omni.diffusion.attention.backends.fastvideo_vsa import FastVideoVSABackend, FastVideoVSAImpl
|
||||
from vllm_omni.diffusion.attention.backends.sdpa import SDPABackend
|
||||
from vllm_omni.diffusion.attention.parallel.base import NoParallelAttention
|
||||
from vllm_omni.diffusion.data import AttentionSpec
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
class _SpecializedImpl(FastVideoVSAImpl):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def local_attention(monkeypatch):
|
||||
monkeypatch.setattr(layer, "get_current_diffusion_config_or_none", lambda: None)
|
||||
monkeypatch.setattr(layer, "build_parallel_attention_strategy", lambda **kwargs: NoParallelAttention())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("explicit", [False, True])
|
||||
@pytest.mark.parametrize("override", [False, True])
|
||||
def test_specialization_preserves_selected_backend_and_options(mocker, local_attention, explicit, override):
|
||||
spec = AttentionSpec(backend="FASTVIDEO_VSA", fastvideo_vsa_topk=7) if explicit else None
|
||||
select = mocker.patch.object(layer, "get_attn_backend_for_role", return_value=(FastVideoVSABackend, spec))
|
||||
attention = layer.Attention(
|
||||
num_heads=2,
|
||||
head_size=8,
|
||||
softmax_scale=8**-0.5,
|
||||
causal=False,
|
||||
role="video.self",
|
||||
role_category="self",
|
||||
qkv_layout="BSND",
|
||||
impl_overrides={"FASTVIDEO_VSA": _SpecializedImpl} if override else None,
|
||||
)
|
||||
|
||||
assert attention.attn_backend is FastVideoVSABackend
|
||||
assert type(attention.attention) is (_SpecializedImpl if override else FastVideoVSAImpl)
|
||||
assert attention.attention.topk == (7 if explicit else 64)
|
||||
assert attention.attention.qkv_layout == "BSND"
|
||||
assert attention.backend_pref == "FASTVIDEO_VSA"
|
||||
assert attention.backend_explicit is explicit
|
||||
assert attention.attn_spec is spec
|
||||
assert isinstance(attention.parallel_strategy, NoParallelAttention)
|
||||
select.assert_called_once_with(
|
||||
role="video.self", head_size=8, attention_config=None, role_category="self", allow_trtllm_default=False
|
||||
)
|
||||
|
||||
|
||||
def test_unselected_specialization_does_not_replace_dense_backend(mocker, local_attention):
|
||||
mocker.patch.object(layer, "get_attn_backend_for_role", return_value=(SDPABackend, None))
|
||||
attention = layer.Attention(
|
||||
num_heads=2,
|
||||
head_size=8,
|
||||
softmax_scale=8**-0.5,
|
||||
causal=False,
|
||||
impl_overrides={"FASTVIDEO_VSA": _SpecializedImpl},
|
||||
)
|
||||
assert attention.attn_backend is SDPABackend
|
||||
assert type(attention.attention) is SDPABackend.get_impl_cls()
|
||||
|
||||
|
||||
def test_specialization_does_not_bypass_selection_errors(mocker, local_attention):
|
||||
mocker.patch.object(layer, "get_attn_backend_for_role", side_effect=ImportError("optional kernel unavailable"))
|
||||
with pytest.raises(ImportError, match="optional kernel unavailable"):
|
||||
layer.Attention(
|
||||
num_heads=2,
|
||||
head_size=8,
|
||||
softmax_scale=8**-0.5,
|
||||
causal=False,
|
||||
impl_overrides={"FASTVIDEO_VSA": _SpecializedImpl},
|
||||
)
|
||||
|
||||
|
||||
def test_specialization_must_extend_selected_implementation(mocker, local_attention):
|
||||
mocker.patch.object(layer, "get_attn_backend_for_role", return_value=(FastVideoVSABackend, None))
|
||||
with pytest.raises(TypeError, match="must subclass the selected implementation"):
|
||||
layer.Attention(
|
||||
num_heads=2,
|
||||
head_size=8,
|
||||
softmax_scale=8**-0.5,
|
||||
causal=False,
|
||||
impl_overrides={"FASTVIDEO_VSA": SDPABackend.get_impl_cls()},
|
||||
)
|
||||
|
||||
|
||||
def test_platform_backend_capabilities_survive_model_specialization(mocker, local_attention):
|
||||
from vllm_omni.diffusion.models.minimax_h3.attention.fastvideo_h3 import MiniMaxH3VSAImpl
|
||||
|
||||
class PlatformBackend(FastVideoVSABackend):
|
||||
@classmethod
|
||||
def supports_packed_mask_free(cls):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_supported_head_sizes():
|
||||
return [128]
|
||||
|
||||
# A registry/platform may refine capabilities without changing the
|
||||
# implementation. The model must not replace those capability decisions.
|
||||
mocker.patch.object(layer, "get_attn_backend_for_role", return_value=(PlatformBackend, None))
|
||||
attention = layer.Attention(
|
||||
num_heads=2,
|
||||
head_size=128,
|
||||
softmax_scale=128**-0.5,
|
||||
causal=False,
|
||||
impl_overrides={"FASTVIDEO_VSA": MiniMaxH3VSAImpl},
|
||||
)
|
||||
assert attention.attn_backend is PlatformBackend
|
||||
assert not attention.attn_backend.supports_packed_mask_free()
|
||||
assert attention.attn_backend.get_supported_head_sizes() == [128]
|
||||
assert type(attention.attention) is MiniMaxH3VSAImpl
|
||||
|
||||
|
||||
def test_incompatible_platform_implementation_is_not_discarded(mocker, local_attention):
|
||||
class PlatformImpl(FastVideoVSAImpl):
|
||||
pass
|
||||
|
||||
class PlatformBackend(FastVideoVSABackend):
|
||||
@staticmethod
|
||||
def get_impl_cls():
|
||||
return PlatformImpl
|
||||
|
||||
mocker.patch.object(layer, "get_attn_backend_for_role", return_value=(PlatformBackend, None))
|
||||
with pytest.raises(TypeError, match="selected implementation .*PlatformImpl"):
|
||||
layer.Attention(
|
||||
num_heads=2,
|
||||
head_size=8,
|
||||
softmax_scale=8**-0.5,
|
||||
causal=False,
|
||||
impl_overrides={"FASTVIDEO_VSA": _SpecializedImpl},
|
||||
)
|
||||
+635
@@ -0,0 +1,635 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.helpers.runtime import get_distributed_init_method
|
||||
from vllm_omni.diffusion.cache.seacache import (
|
||||
SeaCacheBackend,
|
||||
SeaCacheConfig,
|
||||
SeaCacheRootHook,
|
||||
apply_sea_cache_hook,
|
||||
)
|
||||
from vllm_omni.diffusion.cache.seacache.sea_filter import (
|
||||
apply_sea_filter,
|
||||
extrapolate_residual,
|
||||
indicator_distance,
|
||||
)
|
||||
from vllm_omni.diffusion.cache.selector import get_cache_backend
|
||||
from vllm_omni.diffusion.cache.teacache.extractors import EXTRACTOR_REGISTRY, CacheContext, get_extractor
|
||||
from vllm_omni.diffusion.data import DiffusionCacheConfig
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class TinyCosmos3Transformer(torch.nn.Module):
|
||||
"""Small model that exposes a cache-neutral execution boundary."""
|
||||
|
||||
def _run_gen_layers(self, hidden_gen: torch.Tensor) -> torch.Tensor:
|
||||
return hidden_gen * 2
|
||||
|
||||
def forward(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
timestep: torch.Tensor,
|
||||
text_ids: torch.Tensor | None = None,
|
||||
text_mask: torch.Tensor | None = None,
|
||||
video_shape: tuple[int, int, int] | None = None,
|
||||
noisy_frame_mask: torch.Tensor | None = None,
|
||||
control_latents: list[torch.Tensor] | torch.Tensor | None = None,
|
||||
**kwargs,
|
||||
) -> torch.Tensor:
|
||||
del timestep, text_ids, text_mask, video_shape, noisy_frame_mask
|
||||
if kwargs:
|
||||
raise TypeError(f"Unexpected tiny transformer kwargs: {sorted(kwargs)}")
|
||||
controls = (
|
||||
[]
|
||||
if control_latents is None
|
||||
else [control_latents]
|
||||
if isinstance(control_latents, torch.Tensor)
|
||||
else list(control_latents)
|
||||
)
|
||||
inputs = [*controls, hidden_states]
|
||||
gen_input = torch.cat(
|
||||
[value.movedim(1, -1).flatten(1, 3) for value in inputs],
|
||||
dim=1,
|
||||
)
|
||||
return self._run_gen_layers(gen_input)
|
||||
|
||||
|
||||
def _extract_tiny_cosmos3_context(
|
||||
module: TinyCosmos3Transformer,
|
||||
hidden_states: torch.Tensor,
|
||||
timestep: torch.Tensor,
|
||||
text_ids: torch.Tensor | None = None,
|
||||
text_mask: torch.Tensor | None = None,
|
||||
video_shape: tuple[int, int, int] | None = None,
|
||||
noisy_frame_mask: torch.Tensor | None = None,
|
||||
control_latents: list[torch.Tensor] | torch.Tensor | None = None,
|
||||
**kwargs,
|
||||
) -> CacheContext:
|
||||
del timestep, text_ids, text_mask, video_shape
|
||||
if kwargs:
|
||||
raise TypeError(f"Unexpected tiny transformer kwargs: {sorted(kwargs)}")
|
||||
controls = (
|
||||
[]
|
||||
if control_latents is None
|
||||
else [control_latents]
|
||||
if isinstance(control_latents, torch.Tensor)
|
||||
else list(control_latents)
|
||||
)
|
||||
vision_items = [*controls, hidden_states]
|
||||
gen_input = torch.cat(
|
||||
[value.movedim(1, -1).flatten(1, 3) for value in vision_items],
|
||||
dim=1,
|
||||
)
|
||||
return CacheContext(
|
||||
modulated_input=gen_input,
|
||||
hidden_states=gen_input,
|
||||
encoder_hidden_states=None,
|
||||
temb=torch.zeros_like(gen_input[:, 0]),
|
||||
run_transformer_blocks=lambda: (module._run_gen_layers(gen_input),),
|
||||
postprocess=lambda output: output,
|
||||
extra_states={
|
||||
"sea_cache_latents": vision_items,
|
||||
"sea_cache_noisy_frame_mask": noisy_frame_mask,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Cosmos3OmniDiffusersPipeline:
|
||||
def __init__(self) -> None:
|
||||
self.transformer = TinyCosmos3Transformer()
|
||||
self._current_step_index: int | None = None
|
||||
self._current_sigma: float | None = None
|
||||
self._num_timesteps: int | None = None
|
||||
|
||||
@property
|
||||
def current_step_index(self) -> int | None:
|
||||
return self._current_step_index
|
||||
|
||||
@property
|
||||
def current_sigma(self) -> float | None:
|
||||
return self._current_sigma
|
||||
|
||||
@property
|
||||
def num_timesteps(self) -> int | None:
|
||||
return self._num_timesteps
|
||||
|
||||
|
||||
def _latent(value: float) -> torch.Tensor:
|
||||
return torch.full((1, 2, 2, 2, 2), value)
|
||||
|
||||
|
||||
def _run_step(
|
||||
transformer: TinyCosmos3Transformer,
|
||||
timestep: int,
|
||||
value: float,
|
||||
*,
|
||||
hook: SeaCacheRootHook | None = None,
|
||||
context: str = "cond",
|
||||
control: float | None = None,
|
||||
noisy_frame_mask: torch.Tensor | None = None,
|
||||
) -> torch.Tensor:
|
||||
controls = None if control is None else [_latent(control)]
|
||||
cache_context = hook.cache_context(context) if hook is not None else nullcontext()
|
||||
with torch.inference_mode(), cache_context:
|
||||
return transformer(
|
||||
hidden_states=_latent(value),
|
||||
timestep=torch.tensor([timestep]),
|
||||
noisy_frame_mask=noisy_frame_mask,
|
||||
control_latents=controls,
|
||||
)
|
||||
|
||||
|
||||
def _apply_test_hook(
|
||||
transformer: TinyCosmos3Transformer,
|
||||
metadata: SimpleNamespace,
|
||||
config: SeaCacheConfig | None = None,
|
||||
) -> SeaCacheRootHook:
|
||||
return apply_sea_cache_hook(
|
||||
transformer,
|
||||
config or SeaCacheConfig(threshold=100.0),
|
||||
current_step_callback=lambda: metadata.step,
|
||||
current_sigma_callback=lambda: metadata.sigma,
|
||||
num_inference_steps_callback=lambda: metadata.num_steps,
|
||||
extractor_fn=_extract_tiny_cosmos3_context,
|
||||
)
|
||||
|
||||
|
||||
def test_config_validation() -> None:
|
||||
assert SeaCacheConfig().threshold == 0.25
|
||||
with pytest.raises(ValueError, match="residual_order"):
|
||||
SeaCacheConfig(residual_order=-1)
|
||||
with pytest.raises(ValueError, match="max_consecutive_cached"):
|
||||
SeaCacheConfig(max_consecutive_cached=-1)
|
||||
|
||||
|
||||
def test_sea_filter_matches_reference_equation() -> None:
|
||||
hidden = torch.randn(3, 4, 5, 2, dtype=torch.float32)
|
||||
sigma = 0.4
|
||||
power_exp = 3.0
|
||||
|
||||
spectrum = torch.fft.fftn(hidden, dim=(0, 1, 2))
|
||||
gain = None
|
||||
for axis in (0, 1, 2):
|
||||
frequencies = torch.fft.fftfreq(hidden.shape[axis], dtype=torch.float32)
|
||||
clean_power = 1.0 / (frequencies.abs().pow(power_exp) + 1e-16)
|
||||
axis_gain = (1.0 - sigma) * clean_power / ((1.0 - sigma) ** 2 * clean_power + sigma**2 + 1e-16)
|
||||
shape = [1] * hidden.ndim
|
||||
shape[axis] = hidden.shape[axis]
|
||||
gain = axis_gain.reshape(shape) if gain is None else gain * axis_gain.reshape(shape)
|
||||
assert gain is not None
|
||||
gain = gain / gain.mean()
|
||||
expected = torch.fft.ifftn(spectrum * gain, dim=(0, 1, 2)).real
|
||||
|
||||
torch.testing.assert_close(
|
||||
apply_sea_filter(hidden, sigma=sigma, power_exp=power_exp),
|
||||
expected,
|
||||
)
|
||||
|
||||
|
||||
def test_indicator_distance_and_linear_extrapolation() -> None:
|
||||
previous = [torch.ones(2, 2)]
|
||||
current = [torch.full((2, 2), 1.5)]
|
||||
assert indicator_distance(current, previous) == pytest.approx(0.5)
|
||||
assert indicator_distance([torch.ones(3)], previous) == float("inf")
|
||||
|
||||
history = [
|
||||
(0, torch.full((2, 2), 2.0)),
|
||||
(2, torch.full((2, 2), 6.0)),
|
||||
]
|
||||
torch.testing.assert_close(
|
||||
extrapolate_residual(history, step=3, order=1),
|
||||
torch.full((2, 2), 8.0),
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
extrapolate_residual(history, step=3, order=0),
|
||||
torch.full((2, 2), 6.0),
|
||||
)
|
||||
quadratic_history = [
|
||||
(0, torch.zeros(2, 2)),
|
||||
(1, torch.ones(2, 2)),
|
||||
(2, torch.full((2, 2), 4.0)),
|
||||
]
|
||||
torch.testing.assert_close(
|
||||
extrapolate_residual(quadratic_history, step=3, order=2),
|
||||
torch.full((2, 2), 9.0),
|
||||
)
|
||||
|
||||
|
||||
def test_hook_skips_middle_steps_and_forces_endpoints() -> None:
|
||||
transformer = TinyCosmos3Transformer()
|
||||
metadata = SimpleNamespace(step=0, sigma=1.0, num_steps=4)
|
||||
hook = _apply_test_hook(
|
||||
transformer,
|
||||
metadata,
|
||||
SeaCacheConfig(threshold=100.0, max_consecutive_cached=2),
|
||||
)
|
||||
hook.refresh(transformer)
|
||||
|
||||
for step, timestep in enumerate((1000, 750, 500, 250)):
|
||||
metadata.step = step
|
||||
metadata.sigma = timestep / 1000
|
||||
_run_step(transformer, timestep, 1.0 - step * 0.01, hook=hook)
|
||||
|
||||
assert hook.full_count == 2
|
||||
assert hook.skip_count == 2
|
||||
assert [step for step, _ in hook.state_manager._states["cond"].history] == [0, 3]
|
||||
|
||||
|
||||
def test_parameter_sharded_hook_skips_when_all_shard_ranks_agree(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from vllm_omni.diffusion.distributed import parallel_state
|
||||
|
||||
transformer = TinyCosmos3Transformer()
|
||||
metadata = SimpleNamespace(step=0, sigma=1.0, num_steps=3)
|
||||
hook = _apply_test_hook(transformer, metadata)
|
||||
hook._parameter_sharded = True
|
||||
fs_group = object()
|
||||
reduced_groups: list[object] = []
|
||||
|
||||
monkeypatch.setattr(torch.distributed, "is_available", lambda: True)
|
||||
monkeypatch.setattr(torch.distributed, "is_initialized", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
parallel_state,
|
||||
"get_fs_group",
|
||||
lambda: SimpleNamespace(world_size=2, device_group=fs_group),
|
||||
)
|
||||
monkeypatch.setattr(parallel_state, "get_sequence_parallel_world_size", lambda: 1)
|
||||
|
||||
def all_reduce(decision, *, op, group):
|
||||
assert op == torch.distributed.ReduceOp.MAX
|
||||
reduced_groups.append(group)
|
||||
|
||||
monkeypatch.setattr(torch.distributed, "all_reduce", all_reduce)
|
||||
|
||||
_run_step(transformer, 1000, 1.0, hook=hook)
|
||||
metadata.step = 1
|
||||
metadata.sigma = 0.5
|
||||
_run_step(transformer, 500, 0.99, hook=hook)
|
||||
|
||||
assert hook.full_count == 1
|
||||
assert hook.skip_count == 1
|
||||
assert reduced_groups == [fs_group, fs_group]
|
||||
|
||||
|
||||
def test_parameter_sharded_peer_forces_full_compute(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from vllm_omni.diffusion.distributed import parallel_state
|
||||
|
||||
transformer = TinyCosmos3Transformer()
|
||||
metadata = SimpleNamespace(step=0, sigma=1.0, num_steps=3)
|
||||
hook = _apply_test_hook(transformer, metadata)
|
||||
hook._parameter_sharded = True
|
||||
|
||||
monkeypatch.setattr(torch.distributed, "is_available", lambda: True)
|
||||
monkeypatch.setattr(torch.distributed, "is_initialized", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
parallel_state,
|
||||
"get_fs_group",
|
||||
lambda: SimpleNamespace(world_size=2, device_group=object()),
|
||||
)
|
||||
monkeypatch.setattr(parallel_state, "get_sequence_parallel_world_size", lambda: 1)
|
||||
reduce_count = 0
|
||||
|
||||
def all_reduce(decision, *, op, group):
|
||||
nonlocal reduce_count
|
||||
del op, group
|
||||
reduce_count += 1
|
||||
if reduce_count == 2:
|
||||
decision.fill_(1)
|
||||
|
||||
monkeypatch.setattr(torch.distributed, "all_reduce", all_reduce)
|
||||
|
||||
_run_step(transformer, 1000, 1.0, hook=hook)
|
||||
metadata.step = 1
|
||||
metadata.sigma = 0.5
|
||||
_run_step(transformer, 500, 0.99, hook=hook)
|
||||
|
||||
assert hook.full_count == 2
|
||||
assert hook.skip_count == 0
|
||||
assert hook.state_manager._states["cond"].accumulated_distance == 0.0
|
||||
|
||||
|
||||
def _uneven_cfg_sharded_worker(
|
||||
rank: int,
|
||||
init_method: str,
|
||||
result_queue: torch.multiprocessing.Queue,
|
||||
) -> None:
|
||||
from vllm_omni.diffusion.distributed import parallel_state
|
||||
|
||||
torch.distributed.init_process_group(
|
||||
"gloo",
|
||||
init_method=init_method,
|
||||
rank=rank,
|
||||
world_size=4,
|
||||
timeout=datetime.timedelta(seconds=10),
|
||||
)
|
||||
try:
|
||||
fs_groups = [
|
||||
(ranks, torch.distributed.new_group(ranks, timeout=datetime.timedelta(seconds=10)))
|
||||
for ranks in ([0, 1], [2, 3])
|
||||
]
|
||||
sp_groups = [
|
||||
(ranks, torch.distributed.new_group(ranks, timeout=datetime.timedelta(seconds=10)))
|
||||
for ranks in ([0, 1], [2, 3])
|
||||
]
|
||||
cfg_groups = [
|
||||
(ranks, torch.distributed.new_group(ranks, timeout=datetime.timedelta(seconds=10)))
|
||||
for ranks in ([0, 2], [1, 3])
|
||||
]
|
||||
fs_group = next(group for ranks, group in fs_groups if rank in ranks)
|
||||
sp_group = next(group for ranks, group in sp_groups if rank in ranks)
|
||||
cfg_group = next(group for ranks, group in cfg_groups if rank in ranks)
|
||||
parallel_state._FS = SimpleNamespace(world_size=2, device_group=fs_group)
|
||||
parallel_state._SP = SimpleNamespace(world_size=2, device_group=sp_group)
|
||||
|
||||
hook = SeaCacheRootHook(SeaCacheConfig())
|
||||
hook._parameter_sharded = True
|
||||
hook._synchronize_compute(False, torch.device("cpu"))
|
||||
if rank in (0, 1):
|
||||
hook._synchronize_compute(False, torch.device("cpu"))
|
||||
|
||||
gathered = [torch.zeros(1) for _ in range(2)]
|
||||
torch.distributed.all_gather(
|
||||
gathered,
|
||||
torch.tensor([rank], dtype=torch.float32),
|
||||
group=cfg_group,
|
||||
)
|
||||
result_queue.put((rank, [int(value.item()) for value in gathered]))
|
||||
finally:
|
||||
parallel_state._FS = None
|
||||
parallel_state._SP = None
|
||||
torch.distributed.destroy_process_group()
|
||||
|
||||
|
||||
def test_parameter_sharded_hook_handles_uneven_cfg_branch_dispatch() -> None:
|
||||
mp_context = torch.multiprocessing.get_context("spawn")
|
||||
manager = mp_context.Manager()
|
||||
result_queue = manager.Queue()
|
||||
torch.multiprocessing.spawn(
|
||||
_uneven_cfg_sharded_worker,
|
||||
args=(get_distributed_init_method("seacache_uneven_cfg_"), result_queue),
|
||||
nprocs=4,
|
||||
)
|
||||
|
||||
results = sorted(result_queue.get(timeout=2) for _ in range(4))
|
||||
assert results == [
|
||||
(0, [0, 2]),
|
||||
(1, [1, 3]),
|
||||
(2, [0, 2]),
|
||||
(3, [1, 3]),
|
||||
]
|
||||
|
||||
|
||||
def _hybrid_sp_worker(
|
||||
rank: int,
|
||||
init_method: str,
|
||||
result_queue: torch.multiprocessing.Queue,
|
||||
) -> None:
|
||||
from vllm_omni.diffusion.distributed import parallel_state
|
||||
|
||||
torch.distributed.init_process_group(
|
||||
"gloo",
|
||||
init_method=init_method,
|
||||
rank=rank,
|
||||
world_size=4,
|
||||
timeout=datetime.timedelta(seconds=10),
|
||||
)
|
||||
try:
|
||||
fs_groups = [
|
||||
(ranks, torch.distributed.new_group(ranks, timeout=datetime.timedelta(seconds=10)))
|
||||
for ranks in ([0, 1], [2, 3])
|
||||
]
|
||||
ulysses_groups = [
|
||||
(ranks, torch.distributed.new_group(ranks, timeout=datetime.timedelta(seconds=10)))
|
||||
for ranks in ([0, 1], [2, 3])
|
||||
]
|
||||
ring_groups = [
|
||||
(ranks, torch.distributed.new_group(ranks, timeout=datetime.timedelta(seconds=10)))
|
||||
for ranks in ([0, 2], [1, 3])
|
||||
]
|
||||
sp_group = torch.distributed.new_group(
|
||||
[0, 1, 2, 3],
|
||||
timeout=datetime.timedelta(seconds=10),
|
||||
)
|
||||
fs_group = next(group for ranks, group in fs_groups if rank in ranks)
|
||||
ulysses_group = next(group for ranks, group in ulysses_groups if rank in ranks)
|
||||
ring_group = next(group for ranks, group in ring_groups if rank in ranks)
|
||||
parallel_state._FS = SimpleNamespace(world_size=2, device_group=fs_group)
|
||||
parallel_state._SP = SimpleNamespace(
|
||||
world_size=4,
|
||||
device_group=sp_group,
|
||||
ulysses_world_size=2,
|
||||
ulysses_group=ulysses_group,
|
||||
ring_world_size=2,
|
||||
ring_group=ring_group,
|
||||
)
|
||||
|
||||
compute = rank == 3
|
||||
non_sharded_hook = SeaCacheRootHook(SeaCacheConfig())
|
||||
non_sharded_result = non_sharded_hook._synchronize_compute(compute, torch.device("cpu"))
|
||||
|
||||
sharded_hook = SeaCacheRootHook(SeaCacheConfig())
|
||||
sharded_hook._parameter_sharded = True
|
||||
sharded_result = sharded_hook._synchronize_compute(compute, torch.device("cpu"))
|
||||
result_queue.put((rank, non_sharded_result, sharded_result))
|
||||
finally:
|
||||
parallel_state._FS = None
|
||||
parallel_state._SP = None
|
||||
torch.distributed.destroy_process_group()
|
||||
|
||||
|
||||
def test_hook_synchronizes_full_hybrid_sequence_parallel_group() -> None:
|
||||
mp_context = torch.multiprocessing.get_context("spawn")
|
||||
manager = mp_context.Manager()
|
||||
result_queue = manager.Queue()
|
||||
torch.multiprocessing.spawn(
|
||||
_hybrid_sp_worker,
|
||||
args=(get_distributed_init_method("seacache_hybrid_sp_"), result_queue),
|
||||
nprocs=4,
|
||||
)
|
||||
|
||||
results = sorted(result_queue.get(timeout=2) for _ in range(4))
|
||||
assert results == [
|
||||
(0, True, True),
|
||||
(1, True, True),
|
||||
(2, True, True),
|
||||
(3, True, True),
|
||||
]
|
||||
|
||||
|
||||
def test_parameter_sharded_hook_fails_open_without_distributed_state(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
hook = SeaCacheRootHook(SeaCacheConfig())
|
||||
hook._parameter_sharded = True
|
||||
monkeypatch.setattr(torch.distributed, "is_available", lambda: True)
|
||||
monkeypatch.setattr(torch.distributed, "is_initialized", lambda: False)
|
||||
|
||||
assert hook._synchronize_compute(False, torch.device("cpu")) is True
|
||||
|
||||
|
||||
def test_hook_keeps_three_transfer_branches_separate() -> None:
|
||||
transformer = TinyCosmos3Transformer()
|
||||
metadata = SimpleNamespace(step=0, sigma=1.0, num_steps=3)
|
||||
hook = _apply_test_hook(transformer, metadata)
|
||||
hook.refresh(transformer)
|
||||
|
||||
contexts = ("cond", "cond_no_control", "uncond")
|
||||
for step, (timestep, value) in enumerate(((1000, 1.0), (500, 0.9))):
|
||||
metadata.step = step
|
||||
metadata.sigma = timestep / 1000
|
||||
_run_step(transformer, timestep, value, hook=hook, context=contexts[0], control=0.5)
|
||||
_run_step(transformer, timestep, value, hook=hook, context=contexts[1])
|
||||
_run_step(transformer, timestep, value, hook=hook, context=contexts[2], control=0.5)
|
||||
|
||||
assert set(hook.state_manager._states) == set(contexts)
|
||||
assert hook.full_count == 3
|
||||
assert hook.skip_count == 3
|
||||
assert len(hook.state_manager._states["cond"].previous_indicator) == 2
|
||||
assert len(hook.state_manager._states["cond_no_control"].previous_indicator) == 1
|
||||
|
||||
|
||||
def test_hook_fails_open_without_noisy_vision() -> None:
|
||||
transformer = TinyCosmos3Transformer()
|
||||
metadata = SimpleNamespace(step=0, sigma=1.0, num_steps=3)
|
||||
hook = _apply_test_hook(transformer, metadata)
|
||||
hook.refresh(transformer)
|
||||
all_clean = torch.zeros(1, 1, 2, 1, 1)
|
||||
|
||||
_run_step(transformer, 1000, 1.0, hook=hook, noisy_frame_mask=all_clean)
|
||||
metadata.step = 1
|
||||
metadata.sigma = 0.5
|
||||
_run_step(transformer, 500, 0.9, hook=hook, noisy_frame_mask=all_clean)
|
||||
|
||||
assert hook.full_count == 0
|
||||
assert hook.skip_count == 0
|
||||
|
||||
|
||||
def test_conditioning_only_model_failure_runs_forward_once(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
transformer = TinyCosmos3Transformer()
|
||||
metadata = SimpleNamespace(step=0, sigma=1.0, num_steps=3)
|
||||
hook = _apply_test_hook(transformer, metadata)
|
||||
hook.refresh(transformer)
|
||||
forward_calls = 0
|
||||
|
||||
def fail_forward(hidden_gen: torch.Tensor) -> torch.Tensor:
|
||||
nonlocal forward_calls
|
||||
forward_calls += 1
|
||||
raise RuntimeError("model forward failed")
|
||||
|
||||
monkeypatch.setattr(transformer, "_run_gen_layers", fail_forward)
|
||||
all_clean = torch.zeros(1, 1, 2, 1, 1)
|
||||
|
||||
with pytest.raises(RuntimeError, match="model forward failed"):
|
||||
_run_step(transformer, 1000, 1.0, hook=hook, noisy_frame_mask=all_clean)
|
||||
|
||||
assert forward_calls == 1
|
||||
|
||||
|
||||
def test_hook_fails_open_without_explicit_context() -> None:
|
||||
transformer = TinyCosmos3Transformer()
|
||||
metadata = SimpleNamespace(step=0, sigma=1.0, num_steps=2)
|
||||
hook = _apply_test_hook(transformer, metadata)
|
||||
hook.refresh(transformer)
|
||||
|
||||
_run_step(transformer, 1000, 1.0)
|
||||
|
||||
assert hook.full_count == 0
|
||||
assert hook.skip_count == 0
|
||||
assert hook.state_manager._states == {}
|
||||
|
||||
|
||||
def test_hook_uses_exact_sigma_callback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from vllm_omni.diffusion.cache.seacache import hook as hook_module
|
||||
|
||||
transformer = TinyCosmos3Transformer()
|
||||
metadata = SimpleNamespace(step=0, sigma=0.37, num_steps=2)
|
||||
observed_sigmas: list[float] = []
|
||||
original_filter = hook_module.apply_sea_filter
|
||||
|
||||
def recording_filter(hidden_states: torch.Tensor, sigma: float, power_exp: float) -> torch.Tensor:
|
||||
observed_sigmas.append(sigma)
|
||||
return original_filter(hidden_states, sigma, power_exp)
|
||||
|
||||
monkeypatch.setattr(hook_module, "apply_sea_filter", recording_filter)
|
||||
hook = _apply_test_hook(transformer, metadata)
|
||||
hook.refresh(transformer)
|
||||
_run_step(transformer, timestep=999, value=1.0, hook=hook)
|
||||
|
||||
assert observed_sigmas
|
||||
assert all(sigma == pytest.approx(0.37) for sigma in observed_sigmas)
|
||||
|
||||
|
||||
def test_backend_selector_and_refresh(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setitem(EXTRACTOR_REGISTRY, "TinyCosmos3Transformer", _extract_tiny_cosmos3_context)
|
||||
|
||||
class FSDPTinyCosmos3Transformer(TinyCosmos3Transformer):
|
||||
pass
|
||||
|
||||
assert get_extractor(FSDPTinyCosmos3Transformer) is _extract_tiny_cosmos3_context
|
||||
backend = get_cache_backend(
|
||||
"sea_cache",
|
||||
{
|
||||
"sea_threshold": 0.4,
|
||||
"sea_residual_order": 0,
|
||||
},
|
||||
)
|
||||
assert isinstance(backend, SeaCacheBackend)
|
||||
assert backend.config.sea_threshold == 0.4
|
||||
|
||||
pipeline = Cosmos3OmniDiffusersPipeline()
|
||||
backend.enable(pipeline)
|
||||
hook = pipeline.transformer._hook_registry.get_hook(SeaCacheRootHook._HOOK_NAME)
|
||||
assert isinstance(hook, SeaCacheRootHook)
|
||||
assert callable(getattr(pipeline, "_cache_context_factory", None))
|
||||
for name in ("_seacache_skip", "_seacache_record", "_seacache_residual", "_seacache_last_residual"):
|
||||
assert not hasattr(pipeline.transformer, name)
|
||||
pipeline._current_step_index = 0
|
||||
pipeline._current_sigma = 1.0
|
||||
pipeline._num_timesteps = 7
|
||||
_run_step(pipeline.transformer, 1000, 1.0, hook=hook)
|
||||
assert hook.full_count == 1
|
||||
|
||||
backend.refresh(pipeline, num_inference_steps=7)
|
||||
assert hook.full_count == 0
|
||||
assert hook.skip_count == 0
|
||||
assert hook.state_manager._states == {}
|
||||
|
||||
|
||||
def test_backend_uses_resolved_pipeline_metadata_not_refresh_argument(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setitem(EXTRACTOR_REGISTRY, "TinyCosmos3Transformer", _extract_tiny_cosmos3_context)
|
||||
pipeline = Cosmos3OmniDiffusersPipeline()
|
||||
backend = SeaCacheBackend(DiffusionCacheConfig())
|
||||
backend.enable(pipeline)
|
||||
hook = pipeline.transformer._hook_registry.get_hook(SeaCacheRootHook._HOOK_NAME)
|
||||
assert isinstance(hook, SeaCacheRootHook)
|
||||
backend.refresh(pipeline, num_inference_steps=35)
|
||||
pipeline._num_timesteps = 50
|
||||
pipeline._current_step_index = 0
|
||||
pipeline._current_sigma = 1.0
|
||||
_run_step(pipeline.transformer, 1000, 1.0, hook=hook)
|
||||
assert hook.full_count == 1
|
||||
assert hook.num_inference_steps_callback is not None
|
||||
assert hook.num_inference_steps_callback() == 50
|
||||
|
||||
pipeline._current_step_index = 1
|
||||
pipeline._current_sigma = 0.98
|
||||
_run_step(pipeline.transformer, 980, 0.99, hook=hook)
|
||||
assert hook.full_count == 1
|
||||
assert hook.skip_count == 1
|
||||
|
||||
|
||||
def test_shared_config_defaults() -> None:
|
||||
config = DiffusionCacheConfig()
|
||||
assert config.sea_threshold == 0.25
|
||||
assert config.sea_residual_order == 1
|
||||
assert config.sea_max_consecutive_cached == 2
|
||||
assert config.sea_power_exp == 3.0
|
||||
+5
-5
@@ -68,12 +68,12 @@ class BaseExtractorTest(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_module(self):
|
||||
def get_module(self, module, /):
|
||||
"""Return model module instance."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_sample_inputs(self):
|
||||
def get_sample_inputs(self, sample_inputs, /):
|
||||
"""Return sample inputs for model."""
|
||||
pass
|
||||
|
||||
@@ -737,10 +737,10 @@ class TestMiniMaxH3Extractor(BaseExtractorTest):
|
||||
monkeypatch,
|
||||
):
|
||||
"""Strict SP must not mix local TeaCache state with gathered block rows."""
|
||||
from vllm_omni.diffusion.attention.ops import minimax_h3_modulation
|
||||
from vllm_omni.diffusion.cache.teacache.config import TeaCacheConfig
|
||||
from vllm_omni.diffusion.cache.teacache.hook import TeaCacheHook
|
||||
from vllm_omni.diffusion.distributed import parallel_state
|
||||
from vllm_omni.diffusion.layers import indexed_modulation
|
||||
|
||||
seq_len = sample_inputs["x"].shape[1]
|
||||
local_len = seq_len // 2
|
||||
@@ -844,13 +844,13 @@ class TestMiniMaxH3Extractor(BaseExtractorTest):
|
||||
)
|
||||
monkeypatch.setattr(parallel_state, "get_sp_group", lambda: sp_group)
|
||||
|
||||
original_indexed_scale_shift = minimax_h3_modulation.indexed_scale_shift_
|
||||
original_indexed_scale_shift = indexed_modulation.indexed_scale_shift_
|
||||
|
||||
def capture_indexed_scale_shift(hidden, shift, scale, indices):
|
||||
captured_modulation_indices.append(indices.clone())
|
||||
return original_indexed_scale_shift(hidden, shift, scale, indices)
|
||||
|
||||
monkeypatch.setattr(minimax_h3_modulation, "indexed_scale_shift_", capture_indexed_scale_shift)
|
||||
monkeypatch.setattr(indexed_modulation, "indexed_scale_shift_", capture_indexed_scale_shift)
|
||||
|
||||
local_img_pos = torch.tensor([0, 1])
|
||||
local_audio_pos = torch.tensor([2, 3])
|
||||
|
||||
@@ -1,11 +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
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
@@ -85,6 +86,7 @@ class StubScheduler:
|
||||
flow_shift: float = 1.0,
|
||||
) -> None:
|
||||
self.timesteps = torch.tensor(timesteps or [9, 3], dtype=torch.int64)
|
||||
self.sigmas = self.timesteps.float() / 10
|
||||
self.config = SimpleNamespace(
|
||||
num_train_timesteps=1000,
|
||||
flow_shift=flow_shift,
|
||||
@@ -113,6 +115,7 @@ class StubScheduler:
|
||||
else:
|
||||
assert num_inference_steps is not None
|
||||
self.timesteps = torch.arange(num_inference_steps, 0, -1, dtype=torch.int64, device=device)
|
||||
self.sigmas = self.timesteps.float()
|
||||
|
||||
def step(self, noise_pred: torch.Tensor, timestep: torch.Tensor, latents: torch.Tensor, **kwargs):
|
||||
del kwargs
|
||||
@@ -250,7 +253,7 @@ def passthrough_progress_bar(iterable):
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fake_cosmos3_guardrails(monkeypatch: pytest.MonkeyPatch):
|
||||
module = types.ModuleType("vllm_omni.diffusion.models.cosmos3.guardrails")
|
||||
module: Any = types.ModuleType("vllm_omni.diffusion.models.cosmos3.guardrails")
|
||||
module.is_guardrails_enabled = lambda od_config, sampling_params=None: False
|
||||
module.ensure_initialized = lambda od_config: None
|
||||
module.check_text_safety = lambda text: None
|
||||
@@ -283,6 +286,8 @@ def make_cosmos3_pipeline():
|
||||
pipeline.is_edge_model = False
|
||||
pipeline._guidance_scale = None
|
||||
pipeline._num_timesteps = None
|
||||
pipeline._current_step_index = None
|
||||
pipeline._current_sigma = None
|
||||
pipeline._cosmos3_branch_caches = None
|
||||
pipeline._cache_dit_requires_paired_cfg = False
|
||||
pipeline._sound_tokenizer = None
|
||||
@@ -300,7 +305,7 @@ def sequential_cfg_parallel(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
|
||||
def make_sampling_params(**overrides: Any) -> SimpleNamespace:
|
||||
values = {
|
||||
values: dict[str, Any] = {
|
||||
"height": None,
|
||||
"width": None,
|
||||
"num_frames": None,
|
||||
@@ -814,7 +819,7 @@ def test_pipeline_resolves_scheduler_class_from_checkpoint_file(
|
||||
t_list = [1.0, 0.75, 0.5, 0.25]
|
||||
scheduler_dir = tmp_path / "scheduler"
|
||||
scheduler_dir.mkdir()
|
||||
scheduler_config = {"_class_name": scheduler_class_name}
|
||||
scheduler_config: dict[str, Any] = {"_class_name": scheduler_class_name}
|
||||
if expected_distilled:
|
||||
scheduler_config["fixed_step_sampler_config"] = {"sample_type": "sde", "t_list": t_list}
|
||||
(scheduler_dir / "scheduler_config.json").write_text(json.dumps(scheduler_config))
|
||||
@@ -1531,7 +1536,7 @@ def test_ir_op_priority_hook_preserves_platform_fields(monkeypatch: pytest.Monke
|
||||
fused_add_rms_norm: list[str]
|
||||
custom_op: list[str]
|
||||
|
||||
fake_kernel = types.ModuleType("vllm.config.kernel")
|
||||
fake_kernel: Any = types.ModuleType("vllm.config.kernel")
|
||||
fake_kernel.IrOpPriorityConfig = FakeIrOpPriorityConfig
|
||||
monkeypatch.setitem(sys.modules, fake_kernel.__name__, fake_kernel)
|
||||
|
||||
@@ -2154,6 +2159,47 @@ def test_diffuse_covers_cfg_i2v_and_multimodal_steps(make_cosmos3_pipeline) -> N
|
||||
torch.testing.assert_close(action_result, torch.full((), 44.0).expand_as(action_result))
|
||||
|
||||
|
||||
def test_diffuse_publishes_exact_seacache_metadata_and_cfg_contexts(make_cosmos3_pipeline) -> None:
|
||||
pipeline = make_cosmos3_pipeline()
|
||||
pipeline.scheduler.sigmas = torch.tensor([0.91, 0.17])
|
||||
observations: list[tuple[str, int | None, float | None, int | None]] = []
|
||||
|
||||
class RecordingHook:
|
||||
@contextmanager
|
||||
def cache_context(self, name: str):
|
||||
sigma = pipeline.current_sigma
|
||||
observations.append(
|
||||
(
|
||||
name,
|
||||
pipeline.current_step_index,
|
||||
None if sigma is None else float(sigma),
|
||||
pipeline.num_timesteps,
|
||||
)
|
||||
)
|
||||
yield
|
||||
|
||||
pipeline._cache_context_factory = RecordingHook().cache_context
|
||||
pipeline.diffuse(
|
||||
latents=torch.zeros(1, 2, 1, 1, 1),
|
||||
timesteps=torch.tensor([900, 100]),
|
||||
cond_ids=_ids(2),
|
||||
cond_mask=_mask(),
|
||||
uncond_ids=_ids(1),
|
||||
uncond_mask=_mask(),
|
||||
guidance_scale=3.0,
|
||||
shared_kwargs={"video_shape": (1, 1, 1), "fps": 24.0},
|
||||
)
|
||||
|
||||
assert observations == [
|
||||
("cond", 0, pytest.approx(0.91), 2),
|
||||
("uncond", 0, pytest.approx(0.91), 2),
|
||||
("cond", 1, pytest.approx(0.17), 2),
|
||||
("uncond", 1, pytest.approx(0.17), 2),
|
||||
]
|
||||
assert pipeline.current_step_index is None
|
||||
assert pipeline.current_sigma is None
|
||||
|
||||
|
||||
def test_diffuse_drops_session_when_progress_iteration_fails(make_cosmos3_pipeline) -> None:
|
||||
pipeline = make_cosmos3_pipeline()
|
||||
pipeline._use_session_state = True
|
||||
@@ -2213,6 +2259,53 @@ def test_diffuse_transfer_applies_control_cfg(make_cosmos3_pipeline, sequential_
|
||||
torch.testing.assert_close(result, torch.full_like(latents, 254.0))
|
||||
|
||||
|
||||
def test_diffuse_transfer_uses_named_seacache_contexts(make_cosmos3_pipeline, sequential_cfg_parallel) -> None:
|
||||
pipeline = make_cosmos3_pipeline()
|
||||
pipeline.scheduler.sigmas = torch.tensor([0.42])
|
||||
contexts: list[tuple[str, int | None, float | None, int | None]] = []
|
||||
|
||||
class RecordingHook:
|
||||
@contextmanager
|
||||
def cache_context(self, name: str):
|
||||
sigma = pipeline.current_sigma
|
||||
contexts.append(
|
||||
(
|
||||
name,
|
||||
pipeline.current_step_index,
|
||||
None if sigma is None else float(sigma),
|
||||
pipeline.num_timesteps,
|
||||
)
|
||||
)
|
||||
yield
|
||||
|
||||
pipeline._cache_context_factory = RecordingHook().cache_context
|
||||
latents = torch.zeros(1, 2, 1, 1, 1)
|
||||
velocity_mask = torch.ones(1, 1, 1, 1, 1)
|
||||
pipeline.diffuse_transfer(
|
||||
latents=latents,
|
||||
timesteps=torch.tensor([7]),
|
||||
cond_ids=_ids(2),
|
||||
cond_mask=_mask(),
|
||||
uncond_ids=_ids(1),
|
||||
uncond_mask=_mask(),
|
||||
guidance_scale=3.0,
|
||||
control_guidance=1.5,
|
||||
control_guidance_interval=None,
|
||||
control_latents=[torch.zeros_like(latents)],
|
||||
shared_kwargs={"video_shape": (1, 1, 1), "fps": 24.0, "noisy_frame_mask": velocity_mask},
|
||||
velocity_mask=velocity_mask,
|
||||
condition_latents=torch.zeros_like(latents),
|
||||
)
|
||||
|
||||
assert contexts == [
|
||||
("cond", 0, pytest.approx(0.42), 1),
|
||||
("cond_no_control", 0, pytest.approx(0.42), 1),
|
||||
("uncond", 0, pytest.approx(0.42), 1),
|
||||
]
|
||||
assert pipeline.current_step_index is None
|
||||
assert pipeline.current_sigma is None
|
||||
|
||||
|
||||
def test_diffuse_transfer_rejects_session_state_manager(make_cosmos3_pipeline) -> None:
|
||||
pipeline = make_cosmos3_pipeline()
|
||||
pipeline._use_session_state = True
|
||||
@@ -2583,7 +2676,7 @@ def test_diffuse_keeps_paired_cfg_when_cache_dit_active(make_cosmos3_pipeline) -
|
||||
|
||||
class TestForwardRouting:
|
||||
def _install_forward_stubs(self, pipeline):
|
||||
captured: dict[str, object] = {"diffuse_calls": [], "prepare_calls": []}
|
||||
captured: dict[str, Any] = {"diffuse_calls": [], "prepare_calls": []}
|
||||
|
||||
def fake_format(
|
||||
prompt,
|
||||
|
||||
@@ -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
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -513,6 +513,108 @@ def test_forward_returns_video_prediction(monkeypatch: pytest.MonkeyPatch) -> No
|
||||
assert tuple(output.shape) == (1, 2, 1, 2, 2)
|
||||
|
||||
|
||||
def test_cache_execution_residual_spans_final_gen_norm(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from vllm_omni.diffusion.cache.teacache.extractors import extract_cosmos3_context
|
||||
from vllm_omni.diffusion.models.cosmos3 import transformer_cosmos3
|
||||
|
||||
class TrackingNorm(nn.Module):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.calls = 0
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
self.calls += 1
|
||||
return hidden_states + 5.0
|
||||
|
||||
monkeypatch.setattr(transformer_cosmos3, "_get_ulysses_state", lambda: (1, 0, None))
|
||||
model = transformer_cosmos3.Cosmos3VFMTransformer(
|
||||
SimpleNamespace(tf_model_config=_tiny_cosmos3_config(), dtype=torch.float32)
|
||||
)
|
||||
norm = TrackingNorm()
|
||||
model.norm_moe_gen = norm
|
||||
captured: dict[str, torch.Tensor] = {}
|
||||
|
||||
def run_gen_layers(hidden_gen: torch.Tensor, **kwargs) -> torch.Tensor:
|
||||
del kwargs
|
||||
captured["input"] = hidden_gen.detach().clone()
|
||||
return hidden_gen + 2.0
|
||||
|
||||
monkeypatch.setattr(model, "_run_gen_layers", run_gen_layers)
|
||||
forward_kwargs = {
|
||||
"hidden_states": torch.zeros(1, 2, 1, 2, 2),
|
||||
"timestep": torch.tensor([1.0]),
|
||||
"text_ids": torch.tensor([[1, 2]], dtype=torch.long),
|
||||
"text_mask": torch.ones(1, 2, dtype=torch.long),
|
||||
"video_shape": (1, 2, 2),
|
||||
"fps": 24.0,
|
||||
}
|
||||
|
||||
full_output = model(**forward_kwargs)
|
||||
|
||||
assert norm.calls == 1
|
||||
norm.calls = 0
|
||||
|
||||
ctx = extract_cosmos3_context(model, **forward_kwargs)
|
||||
execution_input = ctx.hidden_states.detach().clone()
|
||||
execution_output = ctx.run_transformer_blocks()[0]
|
||||
residual = execution_output - execution_input
|
||||
extracted_output = ctx.postprocess(execution_output)
|
||||
|
||||
assert norm.calls == 1
|
||||
expected_residual = ((captured["input"] + 2.0) + 5.0) - captured["input"]
|
||||
assert torch.equal(residual, expected_residual)
|
||||
assert torch.equal(extracted_output, full_output)
|
||||
|
||||
norm.calls = 0
|
||||
|
||||
def fail_if_gen_layers_run(*args, **kwargs):
|
||||
del args, kwargs
|
||||
raise AssertionError("SeaCache hit unexpectedly executed GEN layers")
|
||||
|
||||
monkeypatch.setattr(model, "_run_gen_layers", fail_if_gen_layers_run)
|
||||
|
||||
cached_ctx = extract_cosmos3_context(model, **forward_kwargs)
|
||||
cached_output = cached_ctx.postprocess(cached_ctx.hidden_states + residual)
|
||||
|
||||
assert norm.calls == 0
|
||||
assert torch.equal(cached_output, full_output)
|
||||
for name in ("_seacache_skip", "_seacache_record", "_seacache_residual", "_seacache_last_residual"):
|
||||
assert not hasattr(model, name)
|
||||
|
||||
|
||||
def test_no_cache_still_runs_final_gen_norm_once(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3 import transformer_cosmos3
|
||||
|
||||
class CountingNorm(nn.Module):
|
||||
def __init__(self, original: nn.Module) -> None:
|
||||
super().__init__()
|
||||
self.original = original
|
||||
self.calls = 0
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
self.calls += 1
|
||||
return self.original(hidden_states)
|
||||
|
||||
monkeypatch.setattr(transformer_cosmos3, "_get_ulysses_state", lambda: (1, 0, None))
|
||||
model = transformer_cosmos3.Cosmos3VFMTransformer(
|
||||
SimpleNamespace(tf_model_config=_tiny_cosmos3_config(), dtype=torch.float32)
|
||||
)
|
||||
norm = CountingNorm(model.norm_moe_gen)
|
||||
model.norm_moe_gen = norm
|
||||
|
||||
output = model(
|
||||
hidden_states=torch.zeros(1, 2, 1, 2, 2),
|
||||
timestep=torch.tensor([1.0]),
|
||||
text_ids=torch.tensor([[1, 2]], dtype=torch.long),
|
||||
text_mask=torch.ones(1, 2, dtype=torch.long),
|
||||
video_shape=(1, 2, 2),
|
||||
fps=24.0,
|
||||
)
|
||||
|
||||
assert norm.calls == 1
|
||||
assert tuple(output.shape) == (1, 2, 1, 2, 2)
|
||||
|
||||
|
||||
def test_qwen_gen_mlp_remains_gated() -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import Cosmos3GatedMLP, Cosmos3VFMTransformer
|
||||
|
||||
|
||||
@@ -2711,3 +2711,125 @@ def test_registry_and_model_exports_resolve_official_pipeline_class_name() -> No
|
||||
assert "from .pipeline import" in lingbot_init
|
||||
assert '"LingBotWorldCausalDMDPipeline"' in lingbot_init
|
||||
assert '"CausalLingBotWorldTransformer3DModel"' in lingbot_init
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# a prompt is encoded once, whichever path asks for it
|
||||
# ---------------------------------------------------------------------------
|
||||
def _counting_text_encoder(pipeline) -> list[str]:
|
||||
"""Give ``pipeline`` its real encode_prompt over stubs that record each encode."""
|
||||
del pipeline.encode_prompt # _pipeline() replaces it; these tests need the real one
|
||||
encoded: list[str] = []
|
||||
|
||||
def tokenizer(texts, **kwargs):
|
||||
del kwargs
|
||||
encoded.extend(texts)
|
||||
return SimpleNamespace(
|
||||
input_ids=torch.arange(512).view(1, 512),
|
||||
attention_mask=torch.ones(1, 512, dtype=torch.long),
|
||||
)
|
||||
|
||||
def text_encoder(input_ids, attention_mask):
|
||||
del input_ids, attention_mask
|
||||
return SimpleNamespace(last_hidden_state=torch.full((1, 512, 8), float(len(encoded))))
|
||||
|
||||
pipeline.tokenizer = tokenizer
|
||||
pipeline.text_encoder = text_encoder
|
||||
return encoded
|
||||
|
||||
|
||||
def test_the_text_encoder_runs_once_per_distinct_prompt() -> None:
|
||||
module = _load_pipeline_module()
|
||||
pipeline = _pipeline(module)
|
||||
encoded = _counting_text_encoder(pipeline)
|
||||
|
||||
def encode(prompt, *, length=512, dtype=torch.float32):
|
||||
return pipeline.encode_prompt(prompt, max_sequence_length=length, dtype=dtype)
|
||||
|
||||
first = encode("move through the room")
|
||||
assert encode("move through the room") is first
|
||||
# The key is the text the tokenizer actually sees, so spacing alone is not new.
|
||||
assert encode(" move through the room ") is first
|
||||
assert encoded == ["move through the room"]
|
||||
|
||||
encode("enter the snowy valley")
|
||||
# dtype and sequence length change the tensor, so each is its own entry.
|
||||
encode("move through the room", dtype=torch.float16)
|
||||
encode("move through the room", length=256)
|
||||
assert encoded == [
|
||||
"move through the room",
|
||||
"enter the snowy valley",
|
||||
"move through the room",
|
||||
"move through the room",
|
||||
]
|
||||
|
||||
|
||||
def test_the_prompt_encode_cache_is_bounded() -> None:
|
||||
module = _load_pipeline_module()
|
||||
pipeline = _pipeline(module)
|
||||
encoded = _counting_text_encoder(pipeline)
|
||||
size = module._PROMPT_EMBEDS_CACHE_SIZE
|
||||
|
||||
for index in range(size + 1):
|
||||
pipeline.encode_prompt(f"prompt {index}", max_sequence_length=512, dtype=torch.float32)
|
||||
assert len(pipeline._prompt_embeds_cache) == size
|
||||
|
||||
# The oldest entry was the one evicted, so it is the one encoded again.
|
||||
pipeline.encode_prompt("prompt 0", max_sequence_length=512, dtype=torch.float32)
|
||||
pipeline.encode_prompt(f"prompt {size}", max_sequence_length=512, dtype=torch.float32)
|
||||
assert encoded == [f"prompt {index}" for index in range(size + 1)] + ["prompt 0"]
|
||||
|
||||
|
||||
def test_realtime_ticks_reuse_the_encode_and_still_invalidate_on_a_prompt_change(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
module = _load_pipeline_module()
|
||||
pipeline = _pipeline(module)
|
||||
pipeline._ar_height = 16
|
||||
pipeline._ar_width = 16
|
||||
pipeline._ar_diffusion_kv_state = object()
|
||||
encoded = _counting_text_encoder(pipeline)
|
||||
invalidations: list[bool] = []
|
||||
|
||||
def ar_text_caches(prompt_embeds, *, invalidate):
|
||||
del prompt_embeds
|
||||
invalidations.append(invalidate)
|
||||
return [SimpleNamespace()]
|
||||
|
||||
monkeypatch.setattr(pipeline, "_ar_text_caches", ar_text_caches)
|
||||
monkeypatch.setattr(
|
||||
pipeline,
|
||||
"_generate_block",
|
||||
lambda **kwargs: torch.randn((1, 16, 3, 2, 2), generator=kwargs["generator"]),
|
||||
)
|
||||
|
||||
for chunk_index in range(3):
|
||||
pipeline(_request(sampling=_SamplingParams(extra_args=_tick_extra_args(chunk_index=chunk_index))))
|
||||
switched = _prompt()
|
||||
switched["prompt"] = "enter the snowy valley"
|
||||
pipeline(
|
||||
_request(
|
||||
sampling=_SamplingParams(extra_args=_tick_extra_args(chunk_index=3, prompt=switched["prompt"])),
|
||||
prompt=switched,
|
||||
)
|
||||
)
|
||||
|
||||
assert encoded == ["move through the room", "enter the snowy valley"]
|
||||
# A new prompt must still drop the cross-attention K/V built from the old one.
|
||||
assert invalidations == [False, False, False, True]
|
||||
|
||||
|
||||
def test_stepwise_requests_with_the_same_prompt_share_one_encode() -> None:
|
||||
module = _load_pipeline_module()
|
||||
pipeline = _pipeline(module, transformer=_RecordingTransformer())
|
||||
pipeline._ar_height = 16
|
||||
pipeline._ar_width = 16
|
||||
encoded = _counting_text_encoder(pipeline)
|
||||
|
||||
states = [_stepwise_state(request_id=request_id, num_frames=21) for request_id in ("req-a", "req-b")]
|
||||
for state in states:
|
||||
with pipeline.bind_ar_diffusion_state(state.request_id, _FakeARState(state.request_id)):
|
||||
pipeline.prepare_encode(state)
|
||||
|
||||
assert encoded == ["move through the room"]
|
||||
assert states[0].prompt_embeds is states[1].prompt_embeds
|
||||
@@ -0,0 +1,137 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.diffusion.models.magi2.test_native_packing import (
|
||||
_longer_text_tensors,
|
||||
_sampler_tensors,
|
||||
_tiny_model,
|
||||
_tiny_sampler,
|
||||
)
|
||||
from vllm_omni.diffusion.models.magi2.sampler_magi2 import CFGConfig
|
||||
|
||||
pytestmark = [pytest.mark.diffusion, pytest.mark.cpu, pytest.mark.core_model]
|
||||
|
||||
|
||||
def test_layer_regions_bracket_the_eager_kernels() -> None:
|
||||
moe_layer, dense_layer = _tiny_model().block.layers
|
||||
|
||||
assert moe_layer.region_methods == ("_attention_input", "_moe_input", "_moe_output")
|
||||
assert dense_layer.region_methods == ("_attention_input", "_dense_output")
|
||||
|
||||
|
||||
def test_compile_regions_fullgraph_matches_eager_bitwise() -> None:
|
||||
model = _tiny_model()
|
||||
sampler = _tiny_sampler(model)
|
||||
steps = [
|
||||
sampler.prepare_model_input(**_sampler_tensors(seed), t=t, cfg_config=CFGConfig())
|
||||
for seed, t in enumerate((torch.tensor([900.0]), torch.tensor([450.0])))
|
||||
]
|
||||
expected = [sampler.forward(step) for step in steps]
|
||||
|
||||
torch._dynamo.reset()
|
||||
graphs_before = torch._dynamo.utils.counters["stats"]["unique_graphs"]
|
||||
model.compile_regions(fullgraph=True, backend="eager", dynamic=True)
|
||||
first = sampler.forward(steps[0])
|
||||
graphs = torch._dynamo.utils.counters["stats"]["unique_graphs"] - graphs_before
|
||||
with torch._dynamo.config.patch(error_on_recompile=True):
|
||||
second = sampler.forward(steps[1])
|
||||
|
||||
for (video, audio), (video_ref, audio_ref) in zip((first, second), expected, strict=True):
|
||||
assert torch.equal(video, video_ref)
|
||||
assert torch.equal(audio, audio_ref)
|
||||
# pre adapter, post adapter, three MoE-layer regions, two dense-layer regions
|
||||
assert graphs == 7
|
||||
|
||||
|
||||
def test_pipeline_setup_compile_forwards_config_dynamic() -> None:
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.models.magi2.pipeline_magi2 import Magi2Pipeline
|
||||
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
class _Transformer(torch.nn.Module):
|
||||
def compile_regions(self, **compile_kwargs: object) -> None:
|
||||
calls.append(compile_kwargs)
|
||||
|
||||
pipeline = object.__new__(Magi2Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.od_config = OmniDiffusionConfig(
|
||||
model="sand-ai/MAGI-2-preview",
|
||||
model_class_name="Magi2Pipeline",
|
||||
diffusion_compile_dynamic=False,
|
||||
)
|
||||
pipeline.transformer = _Transformer()
|
||||
|
||||
pipeline.setup_compile()
|
||||
|
||||
assert calls == [{"fullgraph": True, "dynamic": False, "options": {"emulate_precision_casts": True}}]
|
||||
|
||||
|
||||
def test_pipeline_setup_compile_warns_when_granularity_is_not_regional(caplog: pytest.LogCaptureFixture) -> None:
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.models.magi2.pipeline_magi2 import Magi2Pipeline
|
||||
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
class _Transformer(torch.nn.Module):
|
||||
def compile_regions(self, **compile_kwargs: object) -> None:
|
||||
calls.append(compile_kwargs)
|
||||
|
||||
pipeline = object.__new__(Magi2Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.od_config = OmniDiffusionConfig(
|
||||
model="sand-ai/MAGI-2-preview",
|
||||
model_class_name="Magi2Pipeline",
|
||||
diffusion_compile_granularity="full",
|
||||
)
|
||||
pipeline.transformer = _Transformer()
|
||||
|
||||
pipeline.setup_compile()
|
||||
|
||||
assert calls == [{"fullgraph": True, "dynamic": True, "options": {"emulate_precision_casts": True}}]
|
||||
assert "diffusion_compile_granularity='full'" in caplog.text
|
||||
|
||||
|
||||
def test_compiled_graphs_are_shared_across_layers_of_one_kind() -> None:
|
||||
model = _tiny_model(moe_layers=2)
|
||||
sampler = _tiny_sampler(model)
|
||||
step = sampler.prepare_model_input(**_sampler_tensors(0), t=torch.tensor([900.0]), cfg_config=CFGConfig())
|
||||
expected = sampler.forward(step)
|
||||
|
||||
torch._dynamo.reset()
|
||||
graphs_before = torch._dynamo.utils.counters["stats"]["unique_graphs"]
|
||||
model.compile_regions(fullgraph=True, backend="eager", dynamic=True)
|
||||
actual = sampler.forward(step)
|
||||
|
||||
assert torch.equal(actual[0], expected[0])
|
||||
# Two MoE layers and two dense layers still compile one graph per region
|
||||
# kind: pre adapter, post adapter, three MoE regions, two dense regions.
|
||||
assert torch._dynamo.utils.counters["stats"]["unique_graphs"] - graphs_before == 7
|
||||
|
||||
|
||||
def test_compiled_regions_survive_new_requests_without_recompiling() -> None:
|
||||
model = _tiny_model()
|
||||
sampler = _tiny_sampler(model)
|
||||
t = torch.tensor([900.0])
|
||||
first_request = sampler.prepare_model_input(**_sampler_tensors(0), t=t, cfg_config=CFGConfig())
|
||||
second_request = sampler.prepare_model_input(**_sampler_tensors(1), t=t, cfg_config=CFGConfig())
|
||||
longer_request = sampler.prepare_model_input(**_longer_text_tensors(2), t=t, cfg_config=CFGConfig())
|
||||
expected = [sampler.forward(request) for request in (first_request, second_request, longer_request)]
|
||||
|
||||
torch._dynamo.reset()
|
||||
graphs_before = torch._dynamo.utils.counters["stats"]["unique_graphs"]
|
||||
model.compile_regions(fullgraph=True, backend="eager", dynamic=True)
|
||||
actual = [sampler.forward(first_request)]
|
||||
with torch._dynamo.config.patch(error_on_recompile=True):
|
||||
actual.append(sampler.forward(second_request))
|
||||
actual.append(sampler.forward(longer_request))
|
||||
|
||||
for (video, audio), (video_ref, audio_ref) in zip(actual, expected, strict=True):
|
||||
assert torch.equal(video, video_ref)
|
||||
assert torch.equal(audio, audio_ref)
|
||||
assert torch._dynamo.utils.counters["stats"]["unique_graphs"] - graphs_before == 7
|
||||
@@ -0,0 +1,77 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.diffusion.models.magi2.test_native_packing import (
|
||||
_longer_text_tensors,
|
||||
_sampler_tensors,
|
||||
_tiny_model,
|
||||
_tiny_sampler,
|
||||
)
|
||||
from tests.helpers.mark import hardware_marks
|
||||
from vllm_omni.diffusion.models.magi2.sampler_magi2 import CFGConfig
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.diffusion,
|
||||
pytest.mark.core_model,
|
||||
*hardware_marks(res={"cuda": "L4"}, num_cards=1),
|
||||
pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA"),
|
||||
]
|
||||
|
||||
|
||||
def _cuda_steps(sampler, tensors=_sampler_tensors):
|
||||
timesteps = (torch.tensor([900.0]), torch.tensor([600.0]), torch.tensor([300.0]))
|
||||
return [
|
||||
sampler.prepare_model_input(
|
||||
**{name: value.cuda() for name, value in tensors(seed).items()},
|
||||
t=t,
|
||||
cfg_config=CFGConfig(),
|
||||
)
|
||||
for seed, t in enumerate(timesteps)
|
||||
]
|
||||
|
||||
|
||||
def test_inductor_regions_match_eager_in_deterministic_mode(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("MAGI2_DETERMINISTIC", "1")
|
||||
model = _tiny_model(params_dtype=torch.bfloat16).cuda()
|
||||
sampler = _tiny_sampler(model)
|
||||
steps = _cuda_steps(sampler)
|
||||
expected = [sampler.forward(step) for step in steps]
|
||||
new_request_step = _cuda_steps(sampler)[0]
|
||||
longer_text_step = _cuda_steps(sampler, _longer_text_tensors)[0]
|
||||
expected_new_request = sampler.forward(new_request_step)
|
||||
expected_longer_text = sampler.forward(longer_text_step)
|
||||
|
||||
torch._dynamo.reset()
|
||||
graphs_before = torch._dynamo.utils.counters["stats"]["unique_graphs"]
|
||||
# The pipeline compiles with inductor emulating eager's intermediate
|
||||
# low-precision casts, so fused bf16 chains keep eager's rounding.
|
||||
model.compile_regions(fullgraph=True, dynamic=True, options={"emulate_precision_casts": True})
|
||||
first = sampler.forward(steps[0])
|
||||
with torch._dynamo.config.patch(error_on_recompile=True):
|
||||
compiled = [first, sampler.forward(steps[1]), sampler.forward(steps[2])]
|
||||
repeated = [sampler.forward(step) for step in steps]
|
||||
# A second request repacks the same shapes; a third changes the
|
||||
# text length and therefore every token count.
|
||||
compiled_new_request = sampler.forward(new_request_step)
|
||||
compiled_longer_text = sampler.forward(longer_text_step)
|
||||
|
||||
# Emulated precision casts restore eager's rounding boundaries; what is
|
||||
# left is one-ulp transcendental noise that some Triton backends carry.
|
||||
for (video, audio), (video_ref, audio_ref) in zip(compiled, expected, strict=True):
|
||||
torch.testing.assert_close(video, video_ref, atol=1e-6, rtol=0)
|
||||
torch.testing.assert_close(audio, audio_ref, atol=1e-6, rtol=0)
|
||||
for (video, audio), (video_again, audio_again) in zip(compiled, repeated, strict=True):
|
||||
assert torch.equal(video, video_again)
|
||||
assert torch.equal(audio, audio_again)
|
||||
for (video, audio), (video_ref, audio_ref) in (
|
||||
(compiled_new_request, expected_new_request),
|
||||
(compiled_longer_text, expected_longer_text),
|
||||
):
|
||||
torch.testing.assert_close(video, video_ref, atol=1e-6, rtol=0)
|
||||
torch.testing.assert_close(audio, audio_ref, atol=1e-6, rtol=0)
|
||||
assert torch._dynamo.utils.counters["stats"]["unique_graphs"] - graphs_before == 7
|
||||
@@ -0,0 +1,179 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
import torch.multiprocessing as mp
|
||||
import torch.nn as nn
|
||||
from torch.distributed import init_device_mesh
|
||||
from torch.distributed.fsdp import MixedPrecisionPolicy
|
||||
|
||||
import vllm_omni.diffusion.offloader.distributed_layerwise_backend as dlo_module
|
||||
from tests.diffusion.models.magi2.test_native_distributed_parity import (
|
||||
_current_group,
|
||||
_new_groups,
|
||||
_patched_groups,
|
||||
)
|
||||
from tests.diffusion.models.magi2.test_native_packing import _tiny_config, _tiny_model, _tiny_sampler
|
||||
from tests.diffusion.offloader.helpers import patch_offload_runtime
|
||||
from vllm_omni.diffusion.distributed.hsdp import shard_model
|
||||
from vllm_omni.diffusion.models.magi2.modeling_magi2 import Magi2PreviewTransformer
|
||||
from vllm_omni.diffusion.models.magi2.parallel import Magi2ParallelGroup
|
||||
from vllm_omni.diffusion.models.magi2.pipeline_magi2 import Magi2Pipeline
|
||||
from vllm_omni.diffusion.models.magi2.sampler_magi2 import CFGConfig
|
||||
from vllm_omni.diffusion.offloader.base import OffloadConfig
|
||||
from vllm_omni.diffusion.offloader.config import OffloadStrategy
|
||||
|
||||
_WORLD_SIZE = 4
|
||||
pytestmark = [pytest.mark.diffusion, pytest.mark.cpu, pytest.mark.core_model]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _cpu_offload_runtime():
|
||||
with pytest.MonkeyPatch.context() as monkeypatch:
|
||||
patch_offload_runtime(monkeypatch, dlo_module.current_omni_platform, synchronize=True)
|
||||
yield
|
||||
|
||||
|
||||
def _enable_dlo(
|
||||
model: Magi2PreviewTransformer,
|
||||
*,
|
||||
dp_group: dist.ProcessGroup | None,
|
||||
dp_size: int,
|
||||
dp_rank: int,
|
||||
) -> None:
|
||||
pipeline = Magi2Pipeline.__new__(Magi2Pipeline)
|
||||
nn.Module.__init__(pipeline)
|
||||
pipeline.transformer = model
|
||||
backend = dlo_module.DistributedLayerwiseOffloadBackend(
|
||||
OffloadConfig(
|
||||
strategy=OffloadStrategy.DISTRIBUTED_LAYER_WISE,
|
||||
pin_cpu_memory=False,
|
||||
dp_size=dp_size,
|
||||
dlo_use_allgather=dp_size > 1,
|
||||
),
|
||||
torch.device("cpu"),
|
||||
)
|
||||
backend.dp_group = dp_group
|
||||
backend.rank = dp_rank
|
||||
backend.enable(pipeline)
|
||||
assert backend.enabled
|
||||
|
||||
|
||||
def _enable_hsdp(model: Magi2PreviewTransformer) -> None:
|
||||
mesh = init_device_mesh("cpu", mesh_shape=(1, _WORLD_SIZE), mesh_dim_names=("replicate", "shard"))
|
||||
ignored = {
|
||||
parameter for name in model._hsdp_ignored_modules for parameter in model.get_submodule(name).parameters()
|
||||
}
|
||||
shard_model(
|
||||
model,
|
||||
mesh=mesh,
|
||||
mp_policy=MixedPrecisionPolicy(cast_forward_inputs=False),
|
||||
hsdp_shard_conditions=model._hsdp_shard_conditions,
|
||||
ignored_params=ignored,
|
||||
)
|
||||
model.requires_grad_(False)
|
||||
|
||||
|
||||
def _sampler_tensors(seed: int) -> dict[str, torch.Tensor]:
|
||||
# 14 tokens per CFG branch, so the packed sequence splits evenly across
|
||||
# two Ulysses ranks; gloo cannot gather uneven shards.
|
||||
generator = torch.Generator(device="cpu").manual_seed(seed)
|
||||
return {
|
||||
"latent": torch.randn(1, 4, 2, 1, 3, generator=generator),
|
||||
"audio_latent": torch.randn(1, 5, 4, generator=generator),
|
||||
"txt_feat": torch.randn(1, 3, 4, generator=generator),
|
||||
"null_txt_feat": torch.randn(1, 3, 4, generator=generator),
|
||||
}
|
||||
|
||||
|
||||
def _steps(sampler):
|
||||
return [
|
||||
sampler.prepare_model_input(**_sampler_tensors(seed), t=t, cfg_config=CFGConfig())
|
||||
for seed, t in enumerate((torch.tensor([900.0]), torch.tensor([450.0])))
|
||||
]
|
||||
|
||||
|
||||
def _run(model: Magi2PreviewTransformer, *, compile_regions: bool) -> list[tuple[torch.Tensor, torch.Tensor]]:
|
||||
sampler = _tiny_sampler(model)
|
||||
steps = _steps(sampler)
|
||||
if not compile_regions:
|
||||
return [sampler.forward(step) for step in steps]
|
||||
torch._dynamo.reset()
|
||||
model.compile_regions(fullgraph=True, backend="eager", dynamic=True)
|
||||
outputs = [sampler.forward(steps[0])]
|
||||
with torch._dynamo.config.patch(error_on_recompile=True):
|
||||
outputs.append(sampler.forward(steps[1]))
|
||||
return outputs
|
||||
|
||||
|
||||
def _assert_same(actual, expected, *, exact: bool, label: str) -> None:
|
||||
for (video, audio), (video_ref, audio_ref) in zip(actual, expected, strict=True):
|
||||
if exact:
|
||||
assert torch.equal(video, video_ref), label
|
||||
assert torch.equal(audio, audio_ref), label
|
||||
else:
|
||||
torch.testing.assert_close(video, video_ref, atol=2e-5, rtol=2e-4, msg=label)
|
||||
torch.testing.assert_close(audio, audio_ref, atol=2e-5, rtol=2e-4, msg=label)
|
||||
|
||||
|
||||
def _distributed_worker(rank: int, rendezvous: str) -> None:
|
||||
torch.set_num_threads(1)
|
||||
dist.init_process_group("gloo", init_method=rendezvous, rank=rank, world_size=_WORLD_SIZE)
|
||||
try:
|
||||
singleton = Magi2ParallelGroup(None, world_size=1, rank=0)
|
||||
with _patched_groups(singleton, singleton):
|
||||
oracle = _tiny_model()
|
||||
checkpoint = [(name, value.detach().clone()) for name, value in oracle.state_dict().items()]
|
||||
expected = _run(oracle, compile_regions=False)
|
||||
|
||||
sp2_groups = _new_groups(((0, 1), (2, 3)))
|
||||
dp2_groups = _new_groups(((0, 2), (1, 3)))
|
||||
sp_group = _current_group(rank, sp2_groups)
|
||||
dp_ranks, dp_process_group = next((ranks, group) for ranks, group in dp2_groups if rank in ranks)
|
||||
|
||||
def dlo_rank_local(model: Magi2PreviewTransformer) -> None:
|
||||
_enable_dlo(model, dp_group=None, dp_size=1, dp_rank=0)
|
||||
|
||||
def dlo_allgather(model: Magi2PreviewTransformer) -> None:
|
||||
_enable_dlo(model, dp_group=dp_process_group, dp_size=len(dp_ranks), dp_rank=dp_ranks.index(rank))
|
||||
|
||||
variants = (
|
||||
("dlo_rank_local_sp2", sp_group, dlo_rank_local),
|
||||
("dlo_allgather_dp2sp2", sp_group, dlo_allgather),
|
||||
("hsdp4", singleton, _enable_hsdp),
|
||||
)
|
||||
for name, ulysses_group, enable in variants:
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(_patched_groups(singleton, ulysses_group))
|
||||
stack.enter_context(_cpu_offload_runtime())
|
||||
outputs = {}
|
||||
for compile_regions in (False, True):
|
||||
model = Magi2PreviewTransformer(_tiny_config())
|
||||
assert model.load_weights(checkpoint) == set(model.state_dict())
|
||||
enable(model)
|
||||
outputs[compile_regions] = _run(model, compile_regions=compile_regions)
|
||||
_assert_same(outputs[False], expected, exact=False, label=f"{name} eager vs single-rank oracle")
|
||||
_assert_same(outputs[True], outputs[False], exact=True, label=f"{name} compiled vs eager")
|
||||
dist.barrier()
|
||||
finally:
|
||||
dist.destroy_process_group()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not dist.is_available() or not dist.is_gloo_available(),
|
||||
reason="requires torch.distributed gloo",
|
||||
)
|
||||
def test_compiled_regions_match_eager_under_dlo_and_hsdp() -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
rendezvous = f"file://{os.path.join(temp_dir, 'gloo-rendezvous')}"
|
||||
with patch.dict(os.environ, {"CUDA_VISIBLE_DEVICES": ""}):
|
||||
mp.spawn(_distributed_worker, args=(rendezvous,), nprocs=_WORLD_SIZE, join=True)
|
||||
@@ -0,0 +1,118 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.magi2.configuration_magi2 import (
|
||||
Magi2MHCConfig,
|
||||
Magi2MoEConfig,
|
||||
Magi2PreviewConfig,
|
||||
)
|
||||
from vllm_omni.diffusion.models.magi2.layers import MultiModalityRMSNorm
|
||||
from vllm_omni.diffusion.models.magi2.mh_moe import Magi2MultiHeadMoE
|
||||
from vllm_omni.diffusion.models.magi2.modeling_magi2 import Magi2PreviewTransformer
|
||||
from vllm_omni.diffusion.models.magi2.preview_data_proxy import (
|
||||
Magi2DataProxy,
|
||||
Magi2PreviewDataProxyConfig,
|
||||
)
|
||||
from vllm_omni.diffusion.models.magi2.sampler_magi2 import CFGConfig, Magi2PreviewSampler
|
||||
|
||||
pytestmark = [pytest.mark.diffusion, pytest.mark.cpu, pytest.mark.core_model]
|
||||
|
||||
|
||||
def _tiny_config(params_dtype: torch.dtype = torch.float32, *, moe_layers: int = 1) -> Magi2PreviewConfig:
|
||||
# The leading layers are multimodal with MoE, the rest single-modality
|
||||
# dense, so every layer belongs to one of two kinds.
|
||||
moe_indices = tuple(range(moe_layers))
|
||||
return Magi2PreviewConfig(
|
||||
num_layers=2 * moe_layers,
|
||||
hidden_size=16,
|
||||
head_dim=8,
|
||||
num_query_groups=2,
|
||||
video_in_channels=4,
|
||||
audio_in_channels=4,
|
||||
text_in_channels=4,
|
||||
intermediate_factor=2,
|
||||
multimodal_layers=moe_indices,
|
||||
params_dtype=params_dtype,
|
||||
mhc=Magi2MHCConfig(num_streams=2),
|
||||
moe=Magi2MoEConfig(
|
||||
num_heads=2,
|
||||
num_experts=4,
|
||||
top_k=2,
|
||||
expert_intermediate_size=8,
|
||||
shared_expert_intermediate_size=8,
|
||||
modality_shared_expert_intermediate_size=8,
|
||||
layers=moe_indices,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _tiny_model(
|
||||
seed: int = 11,
|
||||
params_dtype: torch.dtype = torch.float32,
|
||||
*,
|
||||
moe_layers: int = 1,
|
||||
) -> Magi2PreviewTransformer:
|
||||
model = Magi2PreviewTransformer(_tiny_config(params_dtype, moe_layers=moe_layers))
|
||||
generator = torch.Generator(device="cpu").manual_seed(seed)
|
||||
with torch.no_grad():
|
||||
for parameter in model.parameters():
|
||||
parameter.copy_(torch.randn(parameter.shape, generator=generator, dtype=parameter.dtype) * 0.02)
|
||||
for module in model.modules():
|
||||
if isinstance(module, MultiModalityRMSNorm):
|
||||
module.weight.zero_()
|
||||
elif isinstance(module, Magi2MultiHeadMoE):
|
||||
module.router.expert_bias.zero_()
|
||||
module.router.expert_bias_ema.zero_()
|
||||
return model
|
||||
|
||||
|
||||
def _tiny_sampler(model: torch.nn.Module) -> Magi2PreviewSampler:
|
||||
return Magi2PreviewSampler(model, Magi2DataProxy(Magi2PreviewDataProxyConfig(time_channel_dim=8)))
|
||||
|
||||
|
||||
def _sampler_tensors(seed: int) -> dict[str, torch.Tensor]:
|
||||
generator = torch.Generator(device="cpu").manual_seed(seed)
|
||||
return {
|
||||
"latent": torch.randn(1, 4, 2, 1, 3, generator=generator),
|
||||
"audio_latent": torch.randn(1, 5, 4, generator=generator),
|
||||
"txt_feat": torch.randn(1, 3, 4, generator=generator),
|
||||
"null_txt_feat": torch.randn(1, 2, 4, generator=generator),
|
||||
}
|
||||
|
||||
|
||||
def _longer_text_tensors(seed: int) -> dict[str, torch.Tensor]:
|
||||
tensors = _sampler_tensors(seed)
|
||||
generator = torch.Generator(device="cpu").manual_seed(seed + 100)
|
||||
tensors["txt_feat"] = torch.randn(1, 4, 4, generator=generator)
|
||||
return tensors
|
||||
|
||||
|
||||
def test_prepare_model_input_keeps_lengths_on_host() -> None:
|
||||
sampler = Magi2PreviewSampler(torch.nn.Identity())
|
||||
model_input = sampler.prepare_model_input(**_sampler_tensors(0), t=torch.tensor([500.0]), cfg_config=CFGConfig())
|
||||
|
||||
assert model_input.audio_feat_len == [5, 5]
|
||||
assert model_input.txt_feat_len == [3, 2]
|
||||
assert model_input.ref_audio_feat_len == [0, 0]
|
||||
assert model_input.ref_video_feat_len == [0, 0]
|
||||
|
||||
positive, negative = Magi2PreviewSampler._split_cfg_model_input(model_input)
|
||||
assert positive.txt_feat_len == [3]
|
||||
assert negative.txt_feat_len == [2]
|
||||
|
||||
|
||||
def test_pre_adapter_embeds_directly_in_checkpoint_dtype() -> None:
|
||||
model = _tiny_model(params_dtype=torch.bfloat16)
|
||||
packed = torch.randn(6, 4)
|
||||
indices = torch.tensor([0, 1]), torch.tensor([2, 3]), torch.tensor([4, 5])
|
||||
|
||||
with torch.no_grad():
|
||||
hidden = model.pre_adapter(packed, *indices)
|
||||
|
||||
assert hidden.dtype == torch.bfloat16
|
||||
assert hidden.shape == (6, model.config.virtual_width)
|
||||
@@ -6,7 +6,7 @@ import torch
|
||||
from vllm.triton_utils import HAS_TRITON
|
||||
|
||||
from tests.helpers.mark import hardware_marks
|
||||
from vllm_omni.diffusion.attention.ops.minimax_h3_modulation import (
|
||||
from vllm_omni.diffusion.layers.indexed_modulation import (
|
||||
_MAX_1D_GRID_SIZE,
|
||||
_iter_row_chunks,
|
||||
_launch_row_chunks,
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import math
|
||||
import sys
|
||||
import types
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.attention.backends.abstract import (
|
||||
AttentionMetadata,
|
||||
PackedPaddingMetadata,
|
||||
VideoTokenLayout,
|
||||
VideoTokenSpan,
|
||||
)
|
||||
from vllm_omni.diffusion.models.minimax_h3.attention.fastvideo_h3 import (
|
||||
MiniMaxH3VSAImpl,
|
||||
_get_h3_tile_metadata,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _h3_metadata(prefix_segments, video_shape, *, gate=None, packed_padding=None):
|
||||
extra: dict[str, Any] = {
|
||||
"vsa_h3_prefix_segments": prefix_segments,
|
||||
}
|
||||
if gate is not None:
|
||||
extra["gate_compress"] = gate
|
||||
return AttentionMetadata(
|
||||
packed_padding=packed_padding,
|
||||
video_layout=VideoTokenLayout(
|
||||
used_len=sum(prefix_segments) + math.prod(video_shape),
|
||||
video_spans=(VideoTokenSpan(start=sum(prefix_segments), latent_grid=video_shape, role="target"),),
|
||||
),
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
def _fake_block_sparse_kernel(monkeypatch, calls):
|
||||
"""Stand in for fastvideo_kernel.block_sparse_attn with a mask-obeying SDPA."""
|
||||
|
||||
def block_sparse_attn(q, k, v, block_map, variable_block_sizes):
|
||||
calls["block_map"] = block_map.clone()
|
||||
calls["variable_block_sizes"] = variable_block_sizes.tolist()
|
||||
calls["q_shape"] = tuple(q.shape)
|
||||
blocks = block_map.shape[-1]
|
||||
assert blocks * 64 == q.shape[2]
|
||||
# Expand the per-block map to a per-token mask so the stand-in honours
|
||||
# the sparsity contract the real kernel implements, and drop the rows
|
||||
# each partial block pads with, which the real kernel skips through
|
||||
# variable_block_sizes.
|
||||
token_mask = block_map.repeat_interleave(64, dim=-1).repeat_interleave(64, dim=-2)
|
||||
within = torch.arange(64, device=q.device)[None, :] < variable_block_sizes[:, None]
|
||||
token_mask = token_mask & within.reshape(1, 1, 1, -1)
|
||||
out = torch.nn.functional.scaled_dot_product_attention(q.float(), k.float(), v.float(), attn_mask=token_mask)
|
||||
return out.to(q.dtype), None
|
||||
|
||||
module = types.ModuleType("fastvideo_kernel.block_sparse_attn")
|
||||
setattr(module, "block_sparse_attn", block_sparse_attn)
|
||||
monkeypatch.setitem(sys.modules, "fastvideo_kernel", types.ModuleType("fastvideo_kernel"))
|
||||
monkeypatch.setitem(sys.modules, "fastvideo_kernel.block_sparse_attn", module)
|
||||
|
||||
|
||||
def _h3_impl(**backend_kwargs):
|
||||
kwargs = {"topk": 1, "min_seq_len": 1, "disable_when_sp_active": False}
|
||||
kwargs.update(backend_kwargs)
|
||||
return MiniMaxH3VSAImpl(num_heads=2, head_size=8, softmax_scale=8**-0.5, backend_kwargs=kwargs)
|
||||
|
||||
|
||||
def test_h3_forward_tiles_untiles_and_restores_the_original_row_order(monkeypatch):
|
||||
calls: dict[str, Any] = {}
|
||||
_fake_block_sparse_kernel(monkeypatch, calls)
|
||||
impl = _h3_impl(topk=64)
|
||||
prefix_segments, video_shape = (5, 70), (2, 4, 4)
|
||||
seq_len = sum(prefix_segments) + 2 * 4 * 4
|
||||
torch.manual_seed(0)
|
||||
query = torch.randn(1, seq_len, 2, 8, dtype=torch.bfloat16)
|
||||
|
||||
output = impl.forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape))
|
||||
|
||||
assert output.shape == query.shape
|
||||
# topk covers every video block, so the block map is dense and the tiled
|
||||
# kernel must reproduce plain attention over the same rows.
|
||||
reference = torch.nn.functional.scaled_dot_product_attention(
|
||||
*(x.float().transpose(1, 2) for x in (query, query, query))
|
||||
).transpose(1, 2)
|
||||
torch.testing.assert_close(output.float(), reference, atol=2e-2, rtol=2e-2)
|
||||
# 5 + 70 splits into segment-pure 5 | 64 | 6, then one (2, 4, 4) video tile.
|
||||
assert calls["variable_block_sizes"] == [5, 64, 6, 32]
|
||||
|
||||
|
||||
def test_h3_forward_keeps_prefix_dense_and_selects_top_k_video_tiles(monkeypatch):
|
||||
calls: dict[str, Any] = {}
|
||||
_fake_block_sparse_kernel(monkeypatch, calls)
|
||||
impl = _h3_impl(topk=1)
|
||||
prefix_segments, video_shape = (5,), (8, 4, 4)
|
||||
seq_len = 5 + 8 * 4 * 4
|
||||
torch.manual_seed(0)
|
||||
query = torch.randn(1, seq_len, 2, 8, dtype=torch.bfloat16)
|
||||
|
||||
impl.forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape))
|
||||
|
||||
# 1 prefix block + 2 video blocks. The odd count is padded to an even one
|
||||
# for the paired-CTA kernel contract, and the Triton route slices that
|
||||
# transport-only partner off again before launching.
|
||||
block_map = calls["block_map"]
|
||||
assert block_map.shape[-1] == 3
|
||||
assert calls["variable_block_sizes"] == [5, 64, 64]
|
||||
assert block_map[:, :, :1, :].all(), "prefix queries stay dense"
|
||||
assert block_map[..., :1].all(), "prefix keys are exempt from selection"
|
||||
assert (block_map[:, :, 1:, 1:].sum(dim=-1) == 1).all(), "one video tile per video query"
|
||||
|
||||
# The same odd geometry must still untile back onto the original rows.
|
||||
dense = _h3_impl(topk=64).forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape))
|
||||
reference = torch.nn.functional.scaled_dot_product_attention(
|
||||
*(x.float().transpose(1, 2) for x in (query, query, query))
|
||||
).transpose(1, 2)
|
||||
torch.testing.assert_close(dense.float(), reference, atol=2e-2, rtol=2e-2)
|
||||
|
||||
|
||||
def test_h3_forward_applies_the_learned_compression_gate(monkeypatch):
|
||||
_fake_block_sparse_kernel(monkeypatch, {})
|
||||
impl = _h3_impl(topk=64)
|
||||
prefix_segments, video_shape = (5,), (2, 4, 4)
|
||||
seq_len = 5 + 2 * 4 * 4
|
||||
torch.manual_seed(0)
|
||||
query = torch.randn(1, seq_len, 2, 8, dtype=torch.bfloat16)
|
||||
|
||||
without_gate = impl.forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape))
|
||||
zero_gate = torch.zeros_like(query)
|
||||
with_zero_gate = impl.forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape, gate=zero_gate))
|
||||
live_gate = torch.full_like(query, 0.5)
|
||||
with_live_gate = impl.forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape, gate=live_gate))
|
||||
|
||||
torch.testing.assert_close(with_zero_gate.float(), without_gate.float())
|
||||
assert not torch.allclose(with_live_gate.float(), without_gate.float())
|
||||
|
||||
|
||||
def test_h3_forward_restores_rows_the_packed_padding_excludes(monkeypatch):
|
||||
_fake_block_sparse_kernel(monkeypatch, {})
|
||||
impl = _h3_impl(topk=64)
|
||||
prefix_segments, video_shape = (5,), (2, 4, 4)
|
||||
valid = 5 + 2 * 4 * 4
|
||||
torch.manual_seed(0)
|
||||
query = torch.randn(1, valid + 24, 2, 8, dtype=torch.bfloat16)
|
||||
padding = PackedPaddingMetadata(
|
||||
q_length=valid,
|
||||
kv_length=valid,
|
||||
cu_seqlens_q=torch.tensor([0, valid], dtype=torch.int32),
|
||||
cu_seqlens_k=torch.tensor([0, valid], dtype=torch.int32),
|
||||
)
|
||||
|
||||
output = impl.forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape, packed_padding=padding))
|
||||
|
||||
assert output.shape == query.shape
|
||||
assert torch.count_nonzero(output[:, valid:]) == 0
|
||||
|
||||
|
||||
def test_h3_geometry_keeps_prefix_segments_pure_and_tiles_video_3d():
|
||||
partition, sizes, non_pad, untile, prefix_blocks, video_blocks = _get_h3_tile_metadata(
|
||||
(5, 70, 9), (5, 6, 6), torch.device("cpu")
|
||||
)
|
||||
assert sizes[:4].tolist() == [5, 64, 6, 9]
|
||||
assert prefix_blocks == 4
|
||||
assert video_blocks == 8
|
||||
assert int(sizes.sum()) == 5 + 70 + 9 + 5 * 6 * 6
|
||||
assert partition.numel() == non_pad.numel() == untile.numel() == int(sizes.sum())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fallback_on_error", [False, True])
|
||||
def test_h3_kernel_failure_preserves_fallback_policy(monkeypatch, fallback_on_error):
|
||||
impl = _h3_impl(fallback_on_error=fallback_on_error)
|
||||
query = torch.randn(1, 37, 2, 8)
|
||||
metadata = _h3_metadata((5,), (2, 4, 4))
|
||||
|
||||
def fail(*args, **kwargs):
|
||||
raise RuntimeError("kernel failure")
|
||||
|
||||
monkeypatch.setattr(impl, "_forward_h3", fail)
|
||||
monkeypatch.setattr(impl.sdpa_fallback, "forward", impl.sdpa_fallback.forward_cuda)
|
||||
if fallback_on_error:
|
||||
output = impl.forward_cuda(query, query, query, metadata)
|
||||
expected = impl.sdpa_fallback.forward_cuda(query, query, query, metadata)
|
||||
torch.testing.assert_close(output, expected, rtol=0, atol=0)
|
||||
else:
|
||||
with pytest.raises(RuntimeError, match="kernel failure"):
|
||||
impl.forward_cuda(query, query, query, metadata)
|
||||
|
||||
|
||||
def test_h3_accelerator_failure_is_not_swallowed(monkeypatch):
|
||||
impl = _h3_impl(fallback_on_error=True)
|
||||
query = torch.randn(1, 37, 2, 8)
|
||||
|
||||
def fail(*args, **kwargs):
|
||||
raise torch.AcceleratorError("device failure")
|
||||
|
||||
monkeypatch.setattr(impl, "_forward_h3", fail)
|
||||
with pytest.raises(torch.AcceleratorError, match="device failure"):
|
||||
impl.forward_cuda(query, query, query, _h3_metadata((5,), (2, 4, 4)))
|
||||
|
||||
|
||||
def test_h3_without_layout_delegates_to_shared_backend(monkeypatch):
|
||||
from vllm_omni.diffusion.attention.backends.fastvideo_vsa import FastVideoVSAImpl
|
||||
|
||||
impl = _h3_impl()
|
||||
query = torch.randn(1, 37, 2, 8)
|
||||
metadata = AttentionMetadata()
|
||||
expected = torch.empty_like(query)
|
||||
calls = []
|
||||
|
||||
def forward(self, q, k, v, attn_metadata):
|
||||
calls.append((self, q, k, v, attn_metadata))
|
||||
return expected
|
||||
|
||||
monkeypatch.setattr(FastVideoVSAImpl, "forward_cuda", forward)
|
||||
assert impl.forward_cuda(query, query, query, metadata) is expected
|
||||
assert len(calls) == 1
|
||||
assert all(actual is wanted for actual, wanted in zip(calls[0], (impl, query, query, query, metadata)))
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
@@ -33,7 +36,7 @@ class StubScheduler:
|
||||
self.config = SimpleNamespace(num_train_timesteps=1000)
|
||||
self.set_timesteps_calls: list[tuple[int, torch.device]] = []
|
||||
|
||||
def set_timesteps(self, num_steps: int, device: torch.device) -> None:
|
||||
def set_timesteps(self, num_steps: int, device: torch.device, shift: float | None = None) -> None:
|
||||
self.set_timesteps_calls.append((num_steps, device))
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"source_url": "https://github.com/Wan-Video/Wan2.2/blob/42bf4cfaa384bc21833865abc2f9e6c0e67233dc/wan/utils/fm_solvers_unipc.py",
|
||||
"source_sha256": "0dec8c7ed17f6f2049275c6848113314da6ccec1c8db5bdc89df43c05c6038d9",
|
||||
"git_head": "42bf4cfaa384bc21833865abc2f9e6c0e67233dc",
|
||||
"num_train_timesteps": 1000,
|
||||
"use_dynamic_shifting": false,
|
||||
"reproduction": "Load FlowUniPCMultistepScheduler from source_url; construct with num_train_timesteps=1000, shift=1.0, use_dynamic_shifting=False; call set_timesteps(40, device='cpu', shift=12.0); record timesteps.tolist() and sigmas.tolist().",
|
||||
"constructor_shift": 1.0,
|
||||
"runtime_shift": 12.0,
|
||||
"steps": 40,
|
||||
"timesteps": [
|
||||
999,
|
||||
997,
|
||||
995,
|
||||
993,
|
||||
990,
|
||||
988,
|
||||
985,
|
||||
982,
|
||||
979,
|
||||
976,
|
||||
972,
|
||||
969,
|
||||
965,
|
||||
961,
|
||||
956,
|
||||
952,
|
||||
947,
|
||||
941,
|
||||
936,
|
||||
929,
|
||||
922,
|
||||
915,
|
||||
907,
|
||||
898,
|
||||
888,
|
||||
877,
|
||||
865,
|
||||
852,
|
||||
837,
|
||||
819,
|
||||
799,
|
||||
776,
|
||||
749,
|
||||
717,
|
||||
678,
|
||||
631,
|
||||
571,
|
||||
492,
|
||||
386,
|
||||
235
|
||||
],
|
||||
"sigmas": [
|
||||
0.9999166131019592,
|
||||
0.9977826476097107,
|
||||
0.9955461621284485,
|
||||
0.9931996464729309,
|
||||
0.9907346963882446,
|
||||
0.988142192363739,
|
||||
0.9854119420051575,
|
||||
0.9825326800346375,
|
||||
0.9794917702674866,
|
||||
0.9762753844261169,
|
||||
0.9728676676750183,
|
||||
0.9692512154579163,
|
||||
0.9654061794281006,
|
||||
0.961310088634491,
|
||||
0.9569376707077026,
|
||||
0.95225989818573,
|
||||
0.9472436308860779,
|
||||
0.9418508410453796,
|
||||
0.9360373020172119,
|
||||
0.9297519326210022,
|
||||
0.9229347705841064,
|
||||
0.9155154228210449,
|
||||
0.9074103832244873,
|
||||
0.8985199332237244,
|
||||
0.888724148273468,
|
||||
0.8778772950172424,
|
||||
0.865800678730011,
|
||||
0.8522725701332092,
|
||||
0.8370144367218018,
|
||||
0.8196719288825989,
|
||||
0.7997865080833435,
|
||||
0.7767546772956848,
|
||||
0.7497654557228088,
|
||||
0.717703104019165,
|
||||
0.6789888143539429,
|
||||
0.6313128471374512,
|
||||
0.5711563229560852,
|
||||
0.49288034439086914,
|
||||
0.38684695959091187,
|
||||
0.23510953783988953,
|
||||
0.0
|
||||
],
|
||||
"implementation": "Wan-Video/Wan2.2 wan/utils/fm_solvers_unipc.py"
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from PIL import Image
|
||||
from torch import nn
|
||||
|
||||
from tests.diffusion.models.wan2_2.conftest import StubScheduler, StubTransformer, StubVAE, noop_progress_bar
|
||||
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import _WAN_TEXT_ENCODER_OFFLOAD_PLAN
|
||||
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import _WAN_TEXT_ENCODER_OFFLOAD_PLAN, build_wan_scheduler
|
||||
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_i2v import (
|
||||
Wan22I2VPipeline,
|
||||
get_wan22_i2v_post_process_func,
|
||||
@@ -50,7 +51,7 @@ def _make_i2v_pipeline(*, expand_timesteps: bool) -> Wan22I2VPipeline:
|
||||
|
||||
|
||||
def _make_i2v_sampling(**overrides):
|
||||
values = {
|
||||
values: dict[str, object] = {
|
||||
"height": 16,
|
||||
"width": 16,
|
||||
"num_frames": 5,
|
||||
@@ -275,16 +276,18 @@ def test_i2v_prepare_latents_preserves_batched_image_conditions() -> None:
|
||||
assert first_frame_mask.shape == (2, 1, 2, 2, 2)
|
||||
|
||||
|
||||
def test_i2v_forward_batches_conditions_random_inputs_and_outputs(monkeypatch) -> None:
|
||||
@pytest.mark.parametrize("solver", ["unipc", "euler"])
|
||||
@pytest.mark.parametrize("shift", [3.0, 5.0, 12.0])
|
||||
def test_i2v_forward_batches_conditions_random_inputs_and_outputs(monkeypatch, solver: str, shift: float) -> None:
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_i2v.current_omni_platform",
|
||||
SimpleNamespace(is_available=lambda: False),
|
||||
)
|
||||
pipeline = _make_i2v_pipeline(expand_timesteps=True)
|
||||
pipeline.scheduler = StubScheduler([9])
|
||||
pipeline.od_config = SimpleNamespace(flow_shift=5.0)
|
||||
pipeline._sample_solver = "unipc"
|
||||
pipeline._flow_shift = 5.0
|
||||
pipeline.scheduler = build_wan_scheduler(solver, shift)
|
||||
pipeline.od_config = SimpleNamespace(flow_shift=shift)
|
||||
pipeline._sample_solver = solver
|
||||
pipeline._flow_shift = shift
|
||||
pipeline.boundary_ratio = 0.875
|
||||
pipeline.has_image_encoder = False
|
||||
pipeline._guidance_scale = None
|
||||
@@ -327,6 +330,8 @@ def test_i2v_forward_batches_conditions_random_inputs_and_outputs(monkeypatch) -
|
||||
generator=gen_a,
|
||||
latents=latents_a,
|
||||
num_outputs_per_prompt=2,
|
||||
num_inference_steps=50,
|
||||
extra_args={"sample_solver": solver, "flow_shift": shift},
|
||||
),
|
||||
),
|
||||
SimpleNamespace(
|
||||
@@ -336,12 +341,25 @@ def test_i2v_forward_batches_conditions_random_inputs_and_outputs(monkeypatch) -
|
||||
generator=gen_b,
|
||||
latents=latents_b,
|
||||
num_outputs_per_prompt=2,
|
||||
num_inference_steps=50,
|
||||
extra_args={"sample_solver": solver, "flow_shift": shift},
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
outputs = pipeline.forward(batch)
|
||||
if solver == "unipc":
|
||||
sigmas = np.linspace(float(np.float32(0.999)), 0.0, 51)[:-1]
|
||||
sigmas = shift * sigmas / (1.0 + (shift - 1.0) * sigmas)
|
||||
torch.testing.assert_close(
|
||||
pipeline.scheduler.sigmas, torch.tensor(np.append(sigmas, 0.0), dtype=torch.float32), rtol=0, atol=0
|
||||
)
|
||||
assert pipeline.scheduler.timesteps.tolist() == (sigmas * 1000).astype(np.int64).tolist()
|
||||
else:
|
||||
reference = build_wan_scheduler("euler", shift)
|
||||
reference.set_timesteps(50, device="cpu")
|
||||
torch.testing.assert_close(pipeline.scheduler.sigmas, reference.sigmas, rtol=0, atol=0)
|
||||
|
||||
assert prepare_call["batch_size"] == 4
|
||||
assert prepare_call["generator"] == [gen_a, gen_a, gen_b, gen_b]
|
||||
|
||||
@@ -10,7 +10,7 @@ import torch
|
||||
from torch import nn
|
||||
|
||||
from vllm_omni.diffusion.media import VideoTensorEncoding, VideoTensorLayout, VideoValueRange
|
||||
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import Wan22Pipeline
|
||||
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import Wan22Pipeline, build_wan_scheduler
|
||||
from vllm_omni.diffusion.models.wan2_2.wan2_2_transformer import WanSelfAttention
|
||||
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
||||
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
|
||||
@@ -19,6 +19,12 @@ from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _cpu_platform(monkeypatch):
|
||||
module = importlib.import_module("vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2")
|
||||
monkeypatch.setattr(module.current_omni_platform, "is_available", lambda: False)
|
||||
|
||||
|
||||
class _StubTransformer(nn.Module):
|
||||
@property
|
||||
def dtype(self) -> torch.dtype:
|
||||
@@ -51,10 +57,10 @@ class _StubScheduler:
|
||||
def __init__(self, timesteps: list[int]) -> None:
|
||||
self.timesteps = torch.tensor(timesteps, dtype=torch.int64)
|
||||
self.config = SimpleNamespace(num_train_timesteps=1000)
|
||||
self.set_timesteps_calls: list[tuple[int, torch.device]] = []
|
||||
self.set_timesteps_calls: list[tuple[int, torch.device, float | None]] = []
|
||||
|
||||
def set_timesteps(self, num_steps: int, device: torch.device) -> None:
|
||||
self.set_timesteps_calls.append((num_steps, device))
|
||||
def set_timesteps(self, num_steps: int, device: torch.device, shift: float | None = None) -> None:
|
||||
self.set_timesteps_calls.append((num_steps, device, shift))
|
||||
|
||||
|
||||
@contextmanager
|
||||
@@ -184,7 +190,24 @@ def test_forward_delegates_denoising_to_diffuse(
|
||||
assert captured["boundary_timestep"] == pytest.approx(875.0)
|
||||
assert captured["latent_condition"] is None
|
||||
assert captured["first_frame_mask"] is None
|
||||
assert pipeline.scheduler.set_timesteps_calls == [(2, torch.device("cpu"))]
|
||||
assert pipeline.scheduler.set_timesteps_calls == [(2, torch.device("cpu"), 5.0)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("solver", ["unipc", "euler"])
|
||||
def test_forward_passes_request_shift_without_mutating_scheduler_config(solver: str) -> None:
|
||||
pipeline = _make_pipeline()
|
||||
pipeline.diffuse = lambda **kwargs: kwargs["latents"]
|
||||
for shift in (3.0, 12.0, 5.0):
|
||||
sampling = _make_sampling(num_inference_steps=5, extra_args={"sample_solver": solver, "flow_shift": shift})
|
||||
request = OmniDiffusionRequest(prompt="prompt", request_id="schedule", sampling_params=sampling)
|
||||
pipeline.forward(DiffusionRequestBatch(requests=[request]))
|
||||
reference = build_wan_scheduler(solver, shift)
|
||||
if solver == "unipc":
|
||||
reference.set_timesteps(5, device="cpu", shift=shift)
|
||||
assert pipeline.scheduler.config.shift == pipeline.scheduler.config["shift"] == 1.0
|
||||
else:
|
||||
reference.set_timesteps(5, device="cpu")
|
||||
torch.testing.assert_close(pipeline.scheduler.sigmas, reference.sigmas, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_forward_batches_text_generators_latents_and_splits_outputs() -> None:
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -7,6 +10,7 @@ import pytest
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
from vllm_omni.diffusion.models.schedulers import FlowUniPCMultistepScheduler
|
||||
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_s2v import (
|
||||
Wan22S2VPipeline,
|
||||
_make_clip_generators,
|
||||
@@ -14,11 +18,32 @@ from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_s2v import (
|
||||
from vllm_omni.diffusion.models.wan2_2.wan2_2_s2v_transformer import WanS2VTransformer3DModel
|
||||
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("configured_shift", [None, 12.0])
|
||||
def test_s2v_constructor_preserves_unshifted_endpoints(configured_shift: float | None) -> None:
|
||||
module = "vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_s2v"
|
||||
config = SimpleNamespace(model="unused", flow_shift=configured_shift, enable_diffusion_pipeline_profiler=False)
|
||||
|
||||
def init_components(pipeline, *args):
|
||||
pipeline.vae = SimpleNamespace(config=SimpleNamespace(scale_factor_temporal=4, scale_factor_spatial=8))
|
||||
|
||||
with (
|
||||
patch(f"{module}.get_local_device", return_value=torch.device("cpu")),
|
||||
patch(f"{module}._resolve_model_path", return_value="unused"),
|
||||
patch(f"{module}._is_diffusers_format", return_value=True),
|
||||
patch.object(Wan22S2VPipeline, "_init_diffusers_format", init_components),
|
||||
patch.object(Wan22S2VPipeline, "setup_diffusion_pipeline_profiler"),
|
||||
):
|
||||
pipeline = Wan22S2VPipeline(od_config=config)
|
||||
assert pipeline._flow_shift == (3.0 if configured_shift is None else configured_shift)
|
||||
assert pipeline.scheduler.config.shift == pipeline.scheduler.config["shift"] == 1.0
|
||||
assert pipeline.scheduler.sigma_max == float(np.float32(0.999))
|
||||
|
||||
|
||||
def _make_s2v_sampling(**overrides):
|
||||
values = {
|
||||
values: dict[str, object] = {
|
||||
"height": 16,
|
||||
"width": 16,
|
||||
"num_frames": 8,
|
||||
@@ -211,7 +236,8 @@ def test_s2v_forward_rejects_different_raw_audio_shapes() -> None:
|
||||
pipeline.forward(batch)
|
||||
|
||||
|
||||
def test_s2v_forward_batches_request_local_inputs_and_splits_outputs() -> None:
|
||||
@pytest.mark.parametrize("shift", [3.0, 12.0])
|
||||
def test_s2v_forward_batches_request_local_inputs_and_splits_outputs(shift: float) -> None:
|
||||
pipeline = object.__new__(Wan22S2VPipeline)
|
||||
nn.Module.__init__(pipeline)
|
||||
pipeline.device = torch.device("cpu")
|
||||
@@ -227,7 +253,8 @@ def test_s2v_forward_batches_request_local_inputs_and_splits_outputs() -> None:
|
||||
enable_cpu_offload=False,
|
||||
parallel_config=SimpleNamespace(use_hsdp=False),
|
||||
)
|
||||
pipeline.scheduler = MagicMock(timesteps=torch.tensor([1.0]))
|
||||
pipeline._flow_shift = shift
|
||||
pipeline.scheduler = FlowUniPCMultistepScheduler(shift=1.0)
|
||||
pipeline.vae_scale_factor_spatial = 8
|
||||
pipeline.resolution_divisor = 16
|
||||
pipeline.motion_frames = 7
|
||||
@@ -262,6 +289,7 @@ def test_s2v_forward_batches_request_local_inputs_and_splits_outputs() -> None:
|
||||
"multi_modal_data": {"image": image, "audio": audio_a},
|
||||
},
|
||||
sampling_params=_make_s2v_sampling(
|
||||
num_inference_steps=5,
|
||||
num_outputs_per_prompt=2,
|
||||
generator=generators_a,
|
||||
latents=latents_a,
|
||||
@@ -275,6 +303,7 @@ def test_s2v_forward_batches_request_local_inputs_and_splits_outputs() -> None:
|
||||
"multi_modal_data": {"image": image, "audio": audio_b},
|
||||
},
|
||||
sampling_params=_make_s2v_sampling(
|
||||
num_inference_steps=5,
|
||||
num_outputs_per_prompt=2,
|
||||
generator=generators_b,
|
||||
latents=latents_b,
|
||||
@@ -287,6 +316,12 @@ def test_s2v_forward_batches_request_local_inputs_and_splits_outputs() -> None:
|
||||
platform.is_available.return_value = False
|
||||
outputs = pipeline.forward(batch)
|
||||
|
||||
expected = np.linspace(float(np.float32(0.999)), 0.0, 6)[:-1]
|
||||
expected = shift * expected / (1.0 + (shift - 1.0) * expected)
|
||||
torch.testing.assert_close(
|
||||
pipeline.scheduler.sigmas, torch.tensor(np.append(expected, 0.0), dtype=torch.float32), rtol=0, atol=0
|
||||
)
|
||||
assert pipeline.scheduler.config.shift == pipeline.scheduler.config["shift"] == 1.0
|
||||
assert len(outputs) == 2
|
||||
assert outputs[0].output[0].shape[0] == 2
|
||||
assert outputs[1].output[0].shape[0] == 2
|
||||
@@ -519,6 +554,7 @@ def test_s2v_pipeline_hsdp_forward_complete_process():
|
||||
mock_scheduler.timesteps = torch.linspace(999, 0, 5)
|
||||
mock_scheduler.step = MagicMock(return_value=(torch.zeros(1, 16, 20, 88, 128),))
|
||||
pipeline.scheduler = mock_scheduler
|
||||
pipeline._flow_shift = 3.0
|
||||
|
||||
# -- Bind methods from the real class --
|
||||
pipeline.encode_prompt = Wan22S2VPipeline.encode_prompt.__get__(pipeline)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Compare actual Wan schedules to native Wan outputs and independent sigma arithmetic."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import (
|
||||
FASTWAN_DMD_SCHEDULER_SHIFT,
|
||||
build_wan_scheduler,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def test_wan_unipc_matches_captured_native_full_schedule() -> None:
|
||||
# The fixture pins the official Wan source and records how to regenerate it.
|
||||
golden = json.loads((Path(__file__).parent / "fixtures/native_unipc_shift12.json").read_text())
|
||||
scheduler = build_wan_scheduler("unipc", golden["runtime_shift"])
|
||||
for steps in (golden["steps"], 20, golden["steps"]):
|
||||
scheduler.set_timesteps(steps, device="cpu", shift=golden["runtime_shift"])
|
||||
if steps == golden["steps"]:
|
||||
assert scheduler.timesteps.tolist() == golden["timesteps"]
|
||||
torch.testing.assert_close(scheduler.sigmas, torch.tensor(golden["sigmas"]), rtol=0, atol=0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shift", [1.0, 3.0, 5.0, 12.0])
|
||||
@pytest.mark.parametrize("steps", [20, 40])
|
||||
def test_wan_unipc_shifts_unmodified_training_endpoints(shift: float, steps: int) -> None:
|
||||
# Native Wan: float32 training sigma_max=(1-1/1000), sigma_min=0;
|
||||
# interpolate in float64, apply the runtime shift, then cast sigma to FP32.
|
||||
unshifted = np.linspace(float(np.float32(0.999)), 0.0, steps + 1)[:-1]
|
||||
shifted = shift * unshifted / (1.0 + (shift - 1.0) * unshifted)
|
||||
expected_timesteps = (shifted * 1000).astype(np.int64)
|
||||
expected_sigmas: npt.NDArray[np.float32] = np.append(shifted, 0.0).astype(np.float32)
|
||||
scheduler = build_wan_scheduler("unipc", shift)
|
||||
scheduler.set_timesteps(steps, device="cpu", shift=shift)
|
||||
assert scheduler.config.shift == scheduler.config["shift"] == 1.0
|
||||
torch.testing.assert_close(scheduler.timesteps, torch.from_numpy(expected_timesteps), rtol=0, atol=0)
|
||||
torch.testing.assert_close(scheduler.sigmas, torch.from_numpy(expected_sigmas), rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_wan_euler_keeps_dmd_shift_semantics() -> None:
|
||||
scheduler = build_wan_scheduler("euler", FASTWAN_DMD_SCHEDULER_SHIFT)
|
||||
scheduler.set_timesteps(3, device="cpu")
|
||||
# The unchanged Euler branch starts at sigma=1, with shift=8 for DMD.
|
||||
expected = torch.tensor([1.0, 16.0 / 17.0, 0.8, 0.0])
|
||||
torch.testing.assert_close(scheduler.sigmas, expected, rtol=0, atol=1e-7)
|
||||
torch.testing.assert_close(scheduler.timesteps, expected[:-1] * 1000, rtol=0, atol=1e-4)
|
||||
@@ -1,15 +1,17 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from PIL import Image
|
||||
from torch import nn
|
||||
|
||||
from tests.diffusion.models.wan2_2.conftest import StubScheduler, StubTransformer, StubVAE, noop_progress_bar
|
||||
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import build_wan_scheduler
|
||||
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_vace import (
|
||||
Wan22VACEPipeline,
|
||||
create_vace_transformer_from_config,
|
||||
@@ -33,6 +35,8 @@ def test_vace_default_flow_shift_preserves_compact_offload_runtime_state(monkeyp
|
||||
)
|
||||
enable_layerwise_offload: bool = True
|
||||
post_init_calls: int = 0
|
||||
_resolved_diffusion_offload: object = None
|
||||
_diffusion_offload_flags_materialized: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.post_init_calls += 1
|
||||
@@ -41,7 +45,7 @@ def test_vace_default_flow_shift_preserves_compact_offload_runtime_state(monkeyp
|
||||
resolved_offload = object()
|
||||
config._resolved_diffusion_offload = resolved_offload
|
||||
config._diffusion_offload_flags_materialized = True
|
||||
captured = {}
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def capture_base_init(self, *, od_config, prefix="") -> None:
|
||||
captured.update(od_config=od_config, prefix=prefix)
|
||||
@@ -51,6 +55,7 @@ def test_vace_default_flow_shift_preserves_compact_offload_runtime_state(monkeyp
|
||||
Wan22VACEPipeline(od_config=config, prefix="stage")
|
||||
|
||||
forwarded = captured["od_config"]
|
||||
assert isinstance(forwarded, RuntimeConfig)
|
||||
assert forwarded is not config
|
||||
assert config.flow_shift is None
|
||||
assert forwarded.flow_shift == 3.0
|
||||
@@ -75,7 +80,7 @@ def _make_vace_pipeline() -> Wan22VACEPipeline:
|
||||
|
||||
|
||||
def _make_vace_sampling(**overrides):
|
||||
values = {
|
||||
values: dict[str, object] = {
|
||||
"height": 16,
|
||||
"width": 16,
|
||||
"num_frames": 5,
|
||||
@@ -123,7 +128,7 @@ def test_vace_preprocess_collects_reference_video_and_mask_inputs() -> None:
|
||||
|
||||
|
||||
def test_create_vace_transformer_from_config_maps_vace_specific_keys(monkeypatch) -> None:
|
||||
captured = {}
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeVACETransformer:
|
||||
def __init__(self, **kwargs) -> None:
|
||||
@@ -203,7 +208,9 @@ def test_vace_diffuse_passes_context_and_scale_to_cfg_branches() -> None:
|
||||
torch.testing.assert_close(result, torch.ones_like(latents))
|
||||
|
||||
|
||||
def test_vace_forward_batches_random_inputs_and_splits_outputs(monkeypatch) -> None:
|
||||
@pytest.mark.parametrize("solver", ["unipc", "euler"])
|
||||
@pytest.mark.parametrize("shift", [3.0, 5.0, 12.0])
|
||||
def test_vace_forward_batches_random_inputs_and_splits_outputs(monkeypatch, solver: str, shift: float) -> None:
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_vace.current_omni_platform",
|
||||
SimpleNamespace(is_available=lambda: False),
|
||||
@@ -211,10 +218,10 @@ def test_vace_forward_batches_random_inputs_and_splits_outputs(monkeypatch) -> N
|
||||
pipeline = _make_vace_pipeline()
|
||||
pipeline.transformer.vace_patch_embedding = None
|
||||
pipeline.transformer_2 = None
|
||||
pipeline.scheduler = StubScheduler([9])
|
||||
pipeline.od_config = SimpleNamespace(flow_shift=3.0)
|
||||
pipeline._sample_solver = "unipc"
|
||||
pipeline._flow_shift = 3.0
|
||||
pipeline.scheduler = build_wan_scheduler(solver, shift)
|
||||
pipeline.od_config = SimpleNamespace(flow_shift=shift)
|
||||
pipeline._sample_solver = solver
|
||||
pipeline._flow_shift = shift
|
||||
pipeline.boundary_ratio = None
|
||||
pipeline._guidance_scale = None
|
||||
pipeline._num_timesteps = None
|
||||
@@ -247,6 +254,8 @@ def test_vace_forward_batches_random_inputs_and_splits_outputs(monkeypatch) -> N
|
||||
generator=gen_a,
|
||||
latents=latents_a,
|
||||
num_outputs_per_prompt=2,
|
||||
num_inference_steps=50,
|
||||
extra_args={"sample_solver": solver, "flow_shift": shift},
|
||||
),
|
||||
),
|
||||
SimpleNamespace(
|
||||
@@ -256,12 +265,25 @@ def test_vace_forward_batches_random_inputs_and_splits_outputs(monkeypatch) -> N
|
||||
generator=gen_b,
|
||||
latents=latents_b,
|
||||
num_outputs_per_prompt=2,
|
||||
num_inference_steps=50,
|
||||
extra_args={"sample_solver": solver, "flow_shift": shift},
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
outputs = pipeline.forward(batch)
|
||||
if solver == "unipc":
|
||||
sigmas = np.linspace(float(np.float32(0.999)), 0.0, 51)[:-1]
|
||||
sigmas = shift * sigmas / (1.0 + (shift - 1.0) * sigmas)
|
||||
torch.testing.assert_close(
|
||||
pipeline.scheduler.sigmas, torch.tensor(np.append(sigmas, 0.0), dtype=torch.float32), rtol=0, atol=0
|
||||
)
|
||||
assert pipeline.scheduler.timesteps.tolist() == (sigmas * 1000).astype(np.int64).tolist()
|
||||
else:
|
||||
reference = build_wan_scheduler("euler", shift)
|
||||
reference.set_timesteps(50, device="cpu")
|
||||
torch.testing.assert_close(pipeline.scheduler.sigmas, reference.sigmas, rtol=0, atol=0)
|
||||
|
||||
assert prepare_call["batch_size"] == 4
|
||||
assert prepare_call["generator"] == [gen_a, gen_a, gen_b, gen_b]
|
||||
|
||||
@@ -39,7 +39,8 @@ from vllm_omni.distributed.omni_connectors.utils.serialization import (
|
||||
from vllm_omni.engine.async_omni_engine import StageRuntimeInfo
|
||||
from vllm_omni.engine.messages import ShutdownRequestMessage, StageSubmissionMessage
|
||||
from vllm_omni.engine.stage_init_utils import StageMetadata
|
||||
from vllm_omni.entrypoints.async_omni import AsyncEventResolver, AsyncOmni
|
||||
from vllm_omni.entrypoints.async_omni import AsyncOmni
|
||||
from vllm_omni.entrypoints.async_omni_base import AsyncEventResolver
|
||||
from vllm_omni.entrypoints.openai.serving_video_output_stream import OmniStreamingVideoOutputHandler
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
|
||||
@@ -42,7 +42,8 @@ from vllm_omni.engine.orchestrator import Orchestrator, OrchestratorRequestState
|
||||
from vllm_omni.engine.stage_client import StagePoolClient
|
||||
from vllm_omni.engine.stage_init_utils import StageMetadata
|
||||
from vllm_omni.engine.stage_pool import StagePool
|
||||
from vllm_omni.entrypoints.async_omni import AsyncEventResolver, AsyncOmni
|
||||
from vllm_omni.entrypoints.async_omni import AsyncOmni
|
||||
from vllm_omni.entrypoints.async_omni_base import AsyncEventResolver
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniInteractionPrompt
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
|
||||
|
||||
@@ -15,7 +15,12 @@ from pytest_mock import MockerFixture
|
||||
|
||||
import vllm_omni.diffusion.diffusion_engine as diffusion_engine_module
|
||||
from tests.helpers.mark import hardware_test
|
||||
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.data import (
|
||||
DIFFUSION_REQUEST_LIFECYCLE_KEY,
|
||||
DIFFUSION_REQUEST_STARTED,
|
||||
DiffusionOutput,
|
||||
OmniDiffusionConfig,
|
||||
)
|
||||
from vllm_omni.diffusion.diffusion_engine import (
|
||||
DiffusionEngine,
|
||||
DiffusionExecutionMode,
|
||||
@@ -183,6 +188,28 @@ def _make_request_mode_sched_output(*request_ids: str) -> RealDiffusionScheduler
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_request_started_output_is_emitted_only_for_opted_in_requests() -> None:
|
||||
sched_output = _make_request_mode_sched_output("tracked", "untracked")
|
||||
sched_output.scheduled_new_reqs[0].req.sampling_params.emit_request_lifecycle = True
|
||||
engine = object.__new__(DiffusionEngine)
|
||||
emitted = []
|
||||
engine._put_output = lambda request_id, output: emitted.append((request_id, output))
|
||||
|
||||
engine._emit_request_started_outputs(sched_output)
|
||||
|
||||
assert len(emitted) == 1
|
||||
request_id, output = emitted[0]
|
||||
assert request_id == "tracked"
|
||||
assert output.request_started is True
|
||||
assert output.finished is False
|
||||
[formatted] = engine.postprocess_output(sched_output.scheduled_new_reqs[0].req, output)
|
||||
assert formatted.custom_output == {
|
||||
DIFFUSION_REQUEST_LIFECYCLE_KEY: DIFFUSION_REQUEST_STARTED,
|
||||
}
|
||||
assert formatted.finished is False
|
||||
|
||||
|
||||
class TestRequestBatchCapability:
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from unittest.mock import Mock
|
||||
import pytest
|
||||
|
||||
from vllm_omni.diffusion import io_support
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.diffusion_engine import DiffusionEngine, DiffusionExecutionMode
|
||||
from vllm_omni.diffusion.diffusion_kv.config import DiffusionKVCacheMode
|
||||
from vllm_omni.diffusion.diffusion_kv.request import DiffusionKVRequest
|
||||
@@ -16,6 +17,22 @@ from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_class_name", ["DreamZeroPipeline", "LingBotWorldCausalDMDPipeline"])
|
||||
def test_observation_conditioned_startup_skips_generic_warmup(model_class_name: str) -> None:
|
||||
"""Observation-conditioned pipelines must not receive generic text requests."""
|
||||
engine = DiffusionEngine.__new__(DiffusionEngine)
|
||||
engine.od_config = OmniDiffusionConfig.__new__(OmniDiffusionConfig)
|
||||
engine.od_config.model_class_name = model_class_name
|
||||
engine.od_config.diffusion_load_format = "default"
|
||||
engine.add_req_and_wait_for_response = Mock(side_effect=AssertionError("generic text warmup submitted"))
|
||||
engine.close = Mock()
|
||||
|
||||
engine.run_startup_warmup()
|
||||
|
||||
engine.add_req_and_wait_for_response.assert_not_called()
|
||||
engine.close.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("step_execution", [False, True])
|
||||
def test_dummy_run_uses_enough_steps_for_execution_mode(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -90,6 +90,7 @@ def _get_function_source(source: str, class_name: str | None, func_name: str) ->
|
||||
Args:
|
||||
source: Full file source code.
|
||||
class_name: Enclosing class name, or None for module-level functions.
|
||||
The method may be inherited from a base class defined in the same file.
|
||||
func_name: Function/method name.
|
||||
|
||||
Returns:
|
||||
@@ -97,13 +98,24 @@ def _get_function_source(source: str, class_name: str | None, func_name: str) ->
|
||||
"""
|
||||
tree = ast.parse(source)
|
||||
if class_name is not None:
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ClassDef) and node.name == class_name:
|
||||
for item in node.body:
|
||||
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and item.name == func_name:
|
||||
result = ast.get_source_segment(source, item)
|
||||
assert result is not None, f"{class_name}.{func_name} source not found"
|
||||
return result
|
||||
# Follow the bases: ``Orchestrator`` inherits its loops and helpers from
|
||||
# ``OrchestratorBase`` in the same module, and the lookup stays anchored
|
||||
# on the public class rather than on where the split happens to fall.
|
||||
classes = {node.name: node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)}
|
||||
pending = [class_name]
|
||||
seen: set[str] = set()
|
||||
while pending:
|
||||
name = pending.pop(0)
|
||||
if name in seen or name not in classes:
|
||||
continue
|
||||
seen.add(name)
|
||||
node = classes[name]
|
||||
for item in node.body:
|
||||
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and item.name == func_name:
|
||||
result = ast.get_source_segment(source, item)
|
||||
assert result is not None, f"{class_name}.{func_name} source not found"
|
||||
return result
|
||||
pending.extend(base.id for base in node.bases if isinstance(base, ast.Name))
|
||||
else:
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == func_name:
|
||||
|
||||
@@ -443,3 +443,14 @@ def test_format_empty_diffusion_outputs_preserves_empty_response_shape() -> None
|
||||
assert [result.prompt for result in results] == ["prompt-0"]
|
||||
assert [result.images for result in results] == [[]]
|
||||
assert [result.metrics for result in results] == [{}]
|
||||
|
||||
|
||||
def test_format_empty_diffusion_outputs_preserves_custom_output() -> None:
|
||||
[result] = format_empty_diffusion_outputs(
|
||||
_request("prompt-0"),
|
||||
finished=False,
|
||||
custom_output={"lifecycle": "started"},
|
||||
)
|
||||
|
||||
assert result.custom_output == {"lifecycle": "started"}
|
||||
assert result.finished is False
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
@@ -7,7 +10,11 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
from vllm.v1.engine.exceptions import EngineDeadError
|
||||
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.data import (
|
||||
DIFFUSION_REQUEST_LIFECYCLE_KEY,
|
||||
DIFFUSION_REQUEST_STARTED,
|
||||
OmniDiffusionConfig,
|
||||
)
|
||||
from vllm_omni.diffusion.inline_stage_diffusion_client import InlineStageDiffusionClient
|
||||
from vllm_omni.engine.stage_init_utils import StageMetadata
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
@@ -80,6 +87,43 @@ async def test_inline_dispatch_request_success(client, mock_engine):
|
||||
assert output.request_id == "req-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inline_non_streaming_dispatches_lifecycle_before_final(client, mock_engine):
|
||||
lifecycle = OmniRequestOutput.from_diffusion(
|
||||
request_id="req-lifecycle",
|
||||
images=[],
|
||||
custom_output={DIFFUSION_REQUEST_LIFECYCLE_KEY: DIFFUSION_REQUEST_STARTED},
|
||||
finished=False,
|
||||
)
|
||||
intermediate = OmniRequestOutput.from_diffusion(
|
||||
request_id="req-lifecycle",
|
||||
images=[],
|
||||
custom_output={"chunk": 0},
|
||||
finished=False,
|
||||
)
|
||||
final = OmniRequestOutput.from_diffusion(request_id="req-lifecycle", images=[MagicMock()])
|
||||
|
||||
async def _step_streaming(_request):
|
||||
yield [lifecycle]
|
||||
yield [intermediate]
|
||||
yield [final]
|
||||
|
||||
mock_engine.step_streaming = _step_streaming
|
||||
|
||||
await client.add_request_async("req-lifecycle", "A test prompt", OmniDiffusionSamplingParams())
|
||||
|
||||
outputs = []
|
||||
for _ in range(20):
|
||||
output = client.get_diffusion_output_nowait()
|
||||
if output is not None:
|
||||
outputs.append(output)
|
||||
if output.finished:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
assert outputs == [lifecycle, final]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inline_requests_clone_shared_sampling_params(client, mock_engine):
|
||||
requests = []
|
||||
|
||||
@@ -9,6 +9,7 @@ import pytest
|
||||
|
||||
import vllm_omni.diffusion.stage_diffusion_proc as stage_diffusion_proc
|
||||
import vllm_omni.plugins as omni_plugins
|
||||
from vllm_omni.diffusion.data import DIFFUSION_REQUEST_LIFECYCLE_KEY, DIFFUSION_REQUEST_STARTED
|
||||
from vllm_omni.diffusion.stage_diffusion_proc import StageDiffusionProc
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
@@ -103,6 +104,49 @@ async def test_proc_streaming_request_yields_each_engine_chunk():
|
||||
assert captured["request"].kv_sender_info == {0: {"host": "127.0.0.1"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proc_non_streaming_forwards_lifecycle_before_final_output():
|
||||
lifecycle = OmniRequestOutput.from_diffusion(
|
||||
request_id="",
|
||||
images=[],
|
||||
custom_output={DIFFUSION_REQUEST_LIFECYCLE_KEY: DIFFUSION_REQUEST_STARTED},
|
||||
finished=False,
|
||||
)
|
||||
intermediate = OmniRequestOutput.from_diffusion(
|
||||
request_id="",
|
||||
images=[],
|
||||
custom_output={"chunk": 0},
|
||||
finished=False,
|
||||
)
|
||||
final = OmniRequestOutput.from_diffusion(request_id="", images=[], finished=True)
|
||||
|
||||
class _LifecycleEngine:
|
||||
async def step_streaming(self, request):
|
||||
del request
|
||||
yield [lifecycle]
|
||||
yield [intermediate]
|
||||
yield [final]
|
||||
|
||||
stage_proc = object.__new__(StageDiffusionProc)
|
||||
stage_proc._engine = _LifecycleEngine()
|
||||
intermediate_outputs = []
|
||||
|
||||
async def _capture(output):
|
||||
intermediate_outputs.append(output)
|
||||
|
||||
result = await stage_proc._process_request(
|
||||
request_id="req-lifecycle",
|
||||
prompt="prompt",
|
||||
sampling_params_dict=asdict(OmniDiffusionSamplingParams()),
|
||||
on_request_started=_capture,
|
||||
)
|
||||
|
||||
assert intermediate_outputs == [lifecycle]
|
||||
assert lifecycle.request_id == "req-lifecycle"
|
||||
assert result is final
|
||||
assert result.request_id == "req-lifecycle"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proc_process_request_with_batching_async_output():
|
||||
stage_proc = object.__new__(StageDiffusionProc)
|
||||
|
||||
@@ -16,9 +16,11 @@ import time
|
||||
import traceback
|
||||
from collections.abc import Iterable, Sequence
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
import av
|
||||
import pytest
|
||||
import requests
|
||||
import torch
|
||||
@@ -82,6 +84,7 @@ class SamplingKind(str, Enum):
|
||||
VIDEO_FASTH3 = "video_fasth3"
|
||||
VIDEO_REF2VA_IMAGE_AUDIO = "video_ref2va_image_audio"
|
||||
VIDEO_REF2VA_MULTI_VIDEO = "video_ref2va_multi_video"
|
||||
VIDEO_REF2VA_MIXED = "video_ref2va_mixed"
|
||||
|
||||
|
||||
# Pre-defined arguments to be used in function calls during the tests
|
||||
@@ -153,6 +156,13 @@ H3_MODEL_PARAMS = MiniMaxH3ModelSpecificParams(
|
||||
}
|
||||
)
|
||||
|
||||
H3_STAGE_CONFIG = {
|
||||
"stage_type": "diffusion",
|
||||
"final_output": True,
|
||||
"final_output_type": "video",
|
||||
"engine_args": {"model_class_name": "MiniMaxH3Pipeline"},
|
||||
}
|
||||
|
||||
LORA_PARAMS = {"local_path": "test_lora_path", "name": "test_name", "scale": 0.7, "int_id": 10}
|
||||
|
||||
|
||||
@@ -442,17 +452,30 @@ def _build_mock_outputs(outputs: Iterable[OmniRequestOutput], sampling_case: Sam
|
||||
"audio_flow_shift": 3.0,
|
||||
},
|
||||
)
|
||||
elif sampling_case.kind is SamplingKind.VIDEO_REF2VA_MULTI_VIDEO:
|
||||
elif sampling_case.kind in (SamplingKind.VIDEO_REF2VA_MULTI_VIDEO, SamplingKind.VIDEO_REF2VA_MIXED):
|
||||
assert len(received_sampling_params_list) == 1
|
||||
assert isinstance(prompt, dict)
|
||||
multi_modal_data = prompt.get("multi_modal_data")
|
||||
assert isinstance(multi_modal_data, dict)
|
||||
input_videos = multi_modal_data.get("video")
|
||||
assert isinstance(input_videos, list)
|
||||
assert len(input_videos) == 2
|
||||
assert all(isinstance(path, str) for path in input_videos)
|
||||
assert "image" not in multi_modal_data
|
||||
assert "audio" not in multi_modal_data
|
||||
if sampling_case.kind is SamplingKind.VIDEO_REF2VA_MULTI_VIDEO:
|
||||
assert len(input_videos) == 2
|
||||
assert "image" not in multi_modal_data
|
||||
assert "audio" not in multi_modal_data
|
||||
else:
|
||||
assert [Path(path).read_bytes() for path in input_videos] == [b"video-1", b"video-2", b"video-3"]
|
||||
images = multi_modal_data.get("image")
|
||||
assert isinstance(images, list)
|
||||
assert len(images) == 6
|
||||
assert [image.getpixel((0, 0)) for image in images] == [(i * 40,) * 3 for i in range(1, 7)]
|
||||
audios = multi_modal_data.get("audio")
|
||||
assert isinstance(audios, list)
|
||||
assert len(audios) == 3
|
||||
for path, sample_rate in zip(audios, (24000, 32000, 48000)):
|
||||
with av.open(path) as container:
|
||||
assert container.streams.audio[0].sample_rate == sample_rate
|
||||
_assert_sampling_param_values(
|
||||
received_sampling_params_list[0],
|
||||
{
|
||||
@@ -970,7 +993,7 @@ async def test_fast_h3_deployment_node(api_server: str, sampling_case: SamplingC
|
||||
ServerCase(
|
||||
served_model="MiniMaxAI/MiniMax-H3",
|
||||
stage_list=["diffusion"],
|
||||
stage_configs=[{"stage_type": "diffusion", "final_output": True, "final_output_type": "video"}],
|
||||
stage_configs=[H3_STAGE_CONFIG],
|
||||
outputs=[_build_diffusion_video_output()],
|
||||
),
|
||||
SamplingCase(kind=SamplingKind.VIDEO_REF2VA_IMAGE_AUDIO, sampling_params=None),
|
||||
@@ -981,13 +1004,24 @@ async def test_fast_h3_deployment_node(api_server: str, sampling_case: SamplingC
|
||||
ServerCase(
|
||||
served_model="MiniMaxAI/MiniMax-H3",
|
||||
stage_list=["diffusion"],
|
||||
stage_configs=[{"stage_type": "diffusion", "final_output": True, "final_output_type": "video"}],
|
||||
stage_configs=[H3_STAGE_CONFIG],
|
||||
outputs=[_build_diffusion_video_output()],
|
||||
),
|
||||
SamplingCase(kind=SamplingKind.VIDEO_REF2VA_MULTI_VIDEO, sampling_params=None),
|
||||
"multi_video",
|
||||
id="ref2va-multi-video",
|
||||
),
|
||||
pytest.param(
|
||||
ServerCase(
|
||||
served_model="MiniMaxAI/MiniMax-H3",
|
||||
stage_list=["diffusion"],
|
||||
stage_configs=[H3_STAGE_CONFIG],
|
||||
outputs=[_build_diffusion_video_output()],
|
||||
),
|
||||
SamplingCase(kind=SamplingKind.VIDEO_REF2VA_MIXED, sampling_params=None),
|
||||
"mixed",
|
||||
id="ref2va-mixed-12-references",
|
||||
),
|
||||
],
|
||||
indirect=["server_case", "sampling_case"],
|
||||
)
|
||||
@@ -1017,6 +1051,17 @@ async def test_video_generation_node_minimax_h3_ref2va(
|
||||
prompt = (
|
||||
"Remove the green screen background of Video 1 and replace it with the fairytale environment from Video 2."
|
||||
)
|
||||
elif ref_mode == "mixed":
|
||||
inputs = {f"image_{i}": torch.full((1, VIDEO_HEIGHT, VIDEO_WIDTH, 3), i * 40 / 255) for i in range(1, 7)}
|
||||
inputs.update({f"video_{i}": VideoInput(f"video-{i}".encode()) for i in range(1, 4)})
|
||||
inputs.update(
|
||||
{
|
||||
f"audio_{i}": {"waveform": torch.zeros(1, 1, sample_rate), "sample_rate": sample_rate}
|
||||
for i, sample_rate in enumerate((24000, 32000, 48000), start=1)
|
||||
}
|
||||
)
|
||||
(references,) = refs_node.get_references(**inputs)
|
||||
prompt = "Combine the subjects from the images with the reference videos and audio clips."
|
||||
else:
|
||||
raise AssertionError(f"Unknown ref_mode: {ref_mode}")
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import json
|
||||
from graphlib import TopologicalSorter
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from comfyui_vllm_omni import nodes as omni_nodes
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
WORKFLOW = (
|
||||
Path(__file__).resolve().parents[4]
|
||||
/ "apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni MiniMax-H3 Reference to Video.json"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workflow():
|
||||
return json.loads(WORKFLOW.read_text())
|
||||
|
||||
|
||||
def test_reference_workflow_connections(workflow):
|
||||
nodes = {node["id"]: node for node in workflow["nodes"]}
|
||||
links = {link[0]: link for link in workflow["links"]}
|
||||
assert len(nodes) == len(workflow["nodes"])
|
||||
assert len(links) == len(workflow["links"])
|
||||
graph: dict[int, set[int]] = {node_id: set() for node_id in nodes}
|
||||
for link_id, source, output_slot, target, input_slot, kind in links.values():
|
||||
output = nodes[source]["outputs"][output_slot]
|
||||
input_ = nodes[target]["inputs"][input_slot]
|
||||
assert output["type"] == input_["type"] == kind
|
||||
assert link_id in output["links"]
|
||||
assert input_["link"] == link_id
|
||||
graph[target].add(source)
|
||||
assert len(tuple(TopologicalSorter(graph).static_order())) == len(nodes)
|
||||
for node in nodes.values():
|
||||
for slot, input_ in enumerate(node.get("inputs", [])):
|
||||
if input_["link"] is not None:
|
||||
assert links[input_["link"]][3:5] == [node["id"], slot]
|
||||
for slot, output in enumerate(node.get("outputs", [])):
|
||||
for link_id in output.get("links") or []:
|
||||
assert links[link_id][1:3] == [node["id"], slot]
|
||||
|
||||
|
||||
def test_reference_workflow_matches_omni_node_interfaces(workflow):
|
||||
for node in workflow["nodes"]:
|
||||
if not node["type"].startswith("VLLMOmni"):
|
||||
continue
|
||||
cls = getattr(omni_nodes, node["type"])
|
||||
schema = cls.INPUT_TYPES()
|
||||
inputs = {**schema.get("required", {}), **schema.get("optional", {})}
|
||||
for input_ in node["inputs"]:
|
||||
assert input_["type"] == inputs[input_["name"]][0]
|
||||
assert tuple(output["type"] for output in node["outputs"]) == cls.RETURN_TYPES
|
||||
expected_widgets = len(schema.get("required", {}))
|
||||
if "seed" in schema.get("required", {}):
|
||||
expected_widgets += 1
|
||||
assert len(node["widgets_values"]) == expected_widgets
|
||||
refs = next(node for node in workflow["nodes"] if node["type"] == "VLLMOmniVideoReferences")
|
||||
assert [input_["name"] for input_ in refs["inputs"]] == list(
|
||||
omni_nodes.VLLMOmniVideoReferences.INPUT_TYPES()["optional"]
|
||||
)
|
||||
|
||||
|
||||
def test_reference_workflow_default_route_and_output(workflow):
|
||||
nodes = {node["id"]: node for node in workflow["nodes"]}
|
||||
links = {link[0]: link for link in workflow["links"]}
|
||||
generate = next(node for node in nodes.values() if node["type"] == "VLLMOmniGenerateVideo")
|
||||
inputs = {input_["name"]: input_["link"] for input_ in generate["inputs"]}
|
||||
assert inputs["frame"] is None
|
||||
assert inputs["lora"] is None
|
||||
for name, kind in (
|
||||
("references", "VLLMOmniVideoReferences"),
|
||||
("sampling_params", "VLLMOmniDiffusionSampling"),
|
||||
("model_params", "VLLMOmniMiniMaxH3Params"),
|
||||
):
|
||||
assert nodes[links[inputs[name]][1]]["type"] == kind
|
||||
save = next(node for node in nodes.values() if node["type"] == "SaveVideo")
|
||||
assert links[save["inputs"][0]["link"]][1] == generate["id"]
|
||||
assert save["widgets_values"] == ["video/MiniMax-H3-Ref2VA", "mp4", "h264"]
|
||||
|
||||
|
||||
def test_reference_workflow_defaults_and_portable_assets(workflow):
|
||||
by_type = {node["type"]: node for node in workflow["nodes"]}
|
||||
values = by_type["VLLMOmniGenerateVideo"]["widgets_values"]
|
||||
assert values[0] == "http://localhost:8000/v1"
|
||||
assert values[1] == "MiniMaxAI/MiniMax-H3"
|
||||
assert "<Picture 1>" in values[2]
|
||||
assert values[4:8] == [1344, 768, 24, 5.167]
|
||||
num_frames = round(values[7] * values[6])
|
||||
assert num_frames == 124
|
||||
assert values[3] == ""
|
||||
assert (num_frames - 5) % 17 == 0
|
||||
assert 4 <= values[7] <= 15
|
||||
assert by_type["VLLMOmniDiffusionSampling"]["widgets_values"] == [1, 50, 1.0, 1.0, False, False, 42, "fixed"]
|
||||
assert by_type["VLLMOmniMiniMaxH3Params"]["widgets_values"] == [3.0, 12.0]
|
||||
assert by_type["VLLMOmniRemoteLoRA"]["widgets_values"][0] == ""
|
||||
for kind in ("LoadImage", "LoadVideo", "LoadAudio"):
|
||||
filename = by_type[kind]["widgets_values"][0]
|
||||
assert filename and Path(filename).name == filename
|
||||
assert ":" not in filename and "\\" not in filename
|
||||
assert set(by_type) == {
|
||||
"LoadImage",
|
||||
"LoadVideo",
|
||||
"LoadAudio",
|
||||
"SaveVideo",
|
||||
"MarkdownNote",
|
||||
"VLLMOmniVideoReferences",
|
||||
"VLLMOmniGenerateVideo",
|
||||
"VLLMOmniDiffusionSampling",
|
||||
"VLLMOmniMiniMaxH3Params",
|
||||
"VLLMOmniRemoteLoRA",
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from io import BytesIO
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import av
|
||||
import pytest
|
||||
import torch
|
||||
from comfy_api.input import VideoInput
|
||||
from comfyui_vllm_omni.nodes import VLLMOmniVideoReferences
|
||||
from comfyui_vllm_omni.utils import api_client
|
||||
from PIL import Image
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_calls(monkeypatch):
|
||||
calls = AsyncMock(return_value={"id": "test-video", "status": "completed"})
|
||||
monkeypatch.setattr(api_client, "url_json", calls)
|
||||
monkeypatch.setattr(api_client, "url_bytes", AsyncMock(return_value=b"generated-video"))
|
||||
monkeypatch.setattr(api_client, "bytes_to_video", lambda data: data)
|
||||
return calls
|
||||
|
||||
|
||||
async def _generate(references, **kwargs):
|
||||
return await api_client.VLLMOmniClient("http://localhost/v1").generate_video(
|
||||
model="MiniMaxAI/MiniMax-H3",
|
||||
prompt="Use the reference subjects and sounds.",
|
||||
width=896,
|
||||
height=512,
|
||||
num_frames=90,
|
||||
fps=24,
|
||||
references=references,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _references(image_count, video_count, audio_count):
|
||||
refs = {f"image_{i}": torch.full((1, 16, 16, 3), i / 10) for i in range(1, image_count + 1)}
|
||||
refs.update({f"video_{i}": VideoInput(f"video-{i}".encode()) for i in range(1, video_count + 1)})
|
||||
refs.update(
|
||||
{f"audio_{i}": {"waveform": torch.zeros(1, 1, 24000), "sample_rate": 24000} for i in range(1, audio_count + 1)}
|
||||
)
|
||||
return refs
|
||||
|
||||
|
||||
def test_reference_node_preserves_existing_ports_and_exposes_h3_limits():
|
||||
ports = VLLMOmniVideoReferences.INPUT_TYPES()["optional"]
|
||||
assert list(ports)[:6] == ["image_1", "image_2", "audio_1", "audio_2", "video_1", "video_2"]
|
||||
assert {name for name, kind in ports.items() if kind == ("IMAGE",)} == {f"image_{i}" for i in range(1, 10)}
|
||||
assert {name for name, kind in ports.items() if kind == ("VIDEO",)} == {"video_1", "video_2", "video_3"}
|
||||
assert {name for name, kind in ports.items() if kind == ("AUDIO",)} == {"audio_1", "audio_2", "audio_3"}
|
||||
inputs = _references(6, 3, 3)
|
||||
(refs,) = VLLMOmniVideoReferences().get_references(**inputs)
|
||||
assert refs.keys() == inputs.keys()
|
||||
assert all(refs[name] is value for name, value in inputs.items())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"counts", [(1, 0, 0), (9, 0, 0), (0, 3, 0), (1, 0, 3), (0, 1, 3), (6, 3, 3), (9, 3, 0), (9, 0, 3)]
|
||||
)
|
||||
async def test_reference_uploads_keep_numeric_order_and_media_types(api_calls, counts):
|
||||
refs = _references(*counts)
|
||||
result = await _generate(dict(reversed(list(refs.items()))))
|
||||
assert result == b"generated-video"
|
||||
fields = api_calls.call_args_list[0].kwargs["data"]._fields
|
||||
uploads = [
|
||||
(options["filename"], headers["Content-Type"], value)
|
||||
for options, headers, value in fields
|
||||
if options["name"] == "input_references"
|
||||
]
|
||||
expected = [
|
||||
(f"{kind}_{i}.{extension}", content_type)
|
||||
for kind, count, extension, content_type in (
|
||||
("image", counts[0], "png", "image/png"),
|
||||
("video", counts[1], "mp4", "video/mp4"),
|
||||
("audio", counts[2], "mp3", "audio/mpeg"),
|
||||
)
|
||||
for i in range(1, count + 1)
|
||||
]
|
||||
assert [(name, content_type) for name, content_type, _ in uploads] == expected
|
||||
for name, content_type, payload in uploads:
|
||||
data = payload.getvalue()
|
||||
if content_type == "image/png":
|
||||
index = int(name.removeprefix("image_").removesuffix(".png"))
|
||||
assert Image.open(BytesIO(data)).getpixel((0, 0)) == (int(index / 10 * 255),) * 3
|
||||
elif content_type == "video/mp4":
|
||||
assert data == name.removesuffix(".mp4").replace("_", "-").encode()
|
||||
else:
|
||||
with av.open(BytesIO(data)) as container:
|
||||
assert container.streams.audio[0].sample_rate == 24000
|
||||
assert sum(frame.samples for frame in container.decode(audio=0)) > 0
|
||||
|
||||
|
||||
async def test_sparse_reference_slots_are_not_dropped(api_calls):
|
||||
inputs = _references(9, 3, 3)
|
||||
inputs = {name: inputs[name] for name in ("audio_3", "image_9", "video_3", "image_2")}
|
||||
(refs,) = VLLMOmniVideoReferences().get_references(image_1=None, **inputs)
|
||||
await _generate(refs)
|
||||
fields = api_calls.call_args_list[0].kwargs["data"]._fields
|
||||
assert [options["filename"] for options, _, _ in fields if options["name"] == "input_references"] == [
|
||||
"image_2.png",
|
||||
"image_9.png",
|
||||
"video_3.mp4",
|
||||
"audio_3.mp3",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"counts, message",
|
||||
[
|
||||
((0, 0, 0), "at least one image or video"),
|
||||
((0, 0, 3), "at least one image or video"),
|
||||
((9, 3, 1), "at most 12"),
|
||||
((9, 3, 3), "at most 12"),
|
||||
],
|
||||
)
|
||||
async def test_invalid_reference_combinations_fail_before_upload(api_calls, counts, message):
|
||||
with pytest.raises(ValueError, match=message):
|
||||
await _generate(_references(*counts))
|
||||
api_calls.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["image_10", "video_4", "audio_4", "image_0"])
|
||||
async def test_unsupported_reference_slots_are_not_silently_ignored(api_calls, name):
|
||||
refs = _references(1, 0, 0)
|
||||
refs[name] = object()
|
||||
with pytest.raises(ValueError, match="Unsupported reference input"):
|
||||
await _generate(refs)
|
||||
api_calls.assert_not_called()
|
||||
|
||||
|
||||
async def test_frame_and_references_are_mutually_exclusive(api_calls):
|
||||
with pytest.raises(ValueError, match="only one of frame or references"):
|
||||
await _generate(_references(1, 0, 0), frame=torch.zeros(1, 16, 16, 3))
|
||||
api_calls.assert_not_called()
|
||||
@@ -14,7 +14,7 @@ import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.engine.duplex.messages import DuplexFence
|
||||
from vllm_omni.engine.duplex.contracts import DuplexFence
|
||||
from vllm_omni.experimental.fullduplex.core import protocol as ev
|
||||
from vllm_omni.experimental.fullduplex.core.adapter import (
|
||||
DuplexAdapter,
|
||||
@@ -32,12 +32,15 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_fence_is_frozen_value_identity():
|
||||
# The fence carries (session_id, epoch, turn_id). response_seq was
|
||||
# dropped with the incarnation concept: response identity is the session's
|
||||
# active_response_id, not a field of the fence.
|
||||
f = DuplexFence(session_id="s1")
|
||||
assert (f.epoch, f.turn_id, f.response_seq) == (0, 0, 0)
|
||||
assert (f.epoch, f.turn_id) == (0, 0)
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
f.epoch = 1 # type: ignore[misc]
|
||||
assert DuplexFence("s1", 1, 2, 3) == DuplexFence("s1", 1, 2, 3)
|
||||
assert DuplexFence("s1", 1, 2, 3) != DuplexFence("s1", 2, 2, 3)
|
||||
assert DuplexFence("s1", 1, 2) == DuplexFence("s1", 1, 2)
|
||||
assert DuplexFence("s1", 1, 2) != DuplexFence("s1", 2, 2)
|
||||
|
||||
|
||||
def test_session_identity_rules():
|
||||
|
||||
@@ -71,15 +71,8 @@ from vllm_omni.experimental.fullduplex.video_stacking import ( # noqa: E402
|
||||
from vllm_omni.metrics.definitions import compute_audio_rtf # noqa: E402
|
||||
|
||||
|
||||
def _url_with_model(url, model, *, autostart=None, session_id=None):
|
||||
# This driver speaks the MiniCPM-o native duplex wire contract.
|
||||
return build_realtime_url(
|
||||
url,
|
||||
model,
|
||||
autostart=autostart,
|
||||
session_id=session_id,
|
||||
extra_query={"native_duplex": "1"},
|
||||
)
|
||||
def _url_with_model(url, model, *, autostart=None):
|
||||
return build_realtime_url(url, model, autostart=autostart)
|
||||
|
||||
|
||||
_read_wav_pcm16 = read_pcm16_wav
|
||||
@@ -630,7 +623,6 @@ def _session_update_event(args: DemoArgs) -> dict[str, object]:
|
||||
"playback_commit_policy": "ack_only",
|
||||
"extra_body": {
|
||||
"auto_response": True,
|
||||
"native_duplex": True,
|
||||
"force_listen_count": 0,
|
||||
},
|
||||
}
|
||||
@@ -646,9 +638,6 @@ def _session_update_event(args: DemoArgs) -> dict[str, object]:
|
||||
"type": "session.update",
|
||||
"session": session_payload,
|
||||
}
|
||||
session_id = getattr(args, "session_id", None)
|
||||
if session_id:
|
||||
session_payload["session_id"] = session_id
|
||||
return event
|
||||
|
||||
|
||||
@@ -1462,7 +1451,6 @@ async def run_demo(args: DemoArgs) -> dict[str, object]:
|
||||
args.url,
|
||||
args.model,
|
||||
autostart=False if getattr(args, "ref_audio", None) else None,
|
||||
session_id=getattr(args, "session_id", None),
|
||||
)
|
||||
state = DemoState()
|
||||
stop = asyncio.Event()
|
||||
@@ -1674,6 +1662,10 @@ async def run_demo(args: DemoArgs) -> dict[str, object]:
|
||||
)
|
||||
unexpected_error_events = _unexpected_error_events(state)
|
||||
continuous_input_ok = not continuous_input or state.count("input_audio_buffer.committed") == 0
|
||||
# The server allocates every session id; label metrics with the one it announced.
|
||||
created = state.events[0] if state.events else {}
|
||||
created_session = created.get("session") if isinstance(created, dict) else None
|
||||
server_session_id = created_session.get("id") if isinstance(created_session, dict) else None
|
||||
result = {
|
||||
"ok": terminal_activity_ok
|
||||
and state.count("session.closed") > 0
|
||||
@@ -1711,8 +1703,8 @@ async def run_demo(args: DemoArgs) -> dict[str, object]:
|
||||
"input_transcription_ok": input_transcription_ok,
|
||||
"completed_response_ids": completed_response_ids,
|
||||
"response_timings": state.response_timing_summaries(),
|
||||
"request_metrics": state.session_request_metrics(session_id=args.session_id),
|
||||
"session_metrics": state.session_metric_summary(session_id=args.session_id),
|
||||
"request_metrics": state.session_request_metrics(session_id=server_session_id),
|
||||
"session_metrics": state.session_metric_summary(session_id=server_session_id),
|
||||
"lifecycle_counts_ok": lifecycle_counts_ok,
|
||||
"validation_mode": validation_mode,
|
||||
"scenario": scenario,
|
||||
@@ -1773,7 +1765,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", help="Use an explicit public session ID, including for close/reopen tests.")
|
||||
parser.add_argument("--input-wav", required=True)
|
||||
parser.add_argument("--ref-audio", help="Optional WAV used as the MiniCPM-o voice prompt.")
|
||||
parser.add_argument(
|
||||
|
||||
@@ -17,7 +17,6 @@ import hashlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
@@ -266,8 +265,6 @@ async def _run_demo_process(
|
||||
str(args.chunk_ms),
|
||||
"--timeout-s",
|
||||
str(args.timeout_s),
|
||||
"--session-id",
|
||||
f"duplex-pair-{label}-{uuid.uuid4().hex}",
|
||||
]
|
||||
if args.no_realtime_pacing:
|
||||
command.append("--no-realtime-pacing")
|
||||
|
||||
@@ -21,7 +21,7 @@ from websockets.asyncio.client import ClientConnection
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = SCRIPT_DIR.parents[3]
|
||||
REPO_ROOT = SCRIPT_DIR.parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
@@ -42,6 +42,15 @@ def _url_with_model(*args, **kwargs) -> str:
|
||||
return _scenario_module()._url_with_model(*args, **kwargs)
|
||||
|
||||
|
||||
def _created_session_id(created: dict[str, object]) -> str:
|
||||
"""The server-allocated session id announced by ``session.created``."""
|
||||
session = created.get("session")
|
||||
session_id = session.get("id") if isinstance(session, dict) else None
|
||||
if not isinstance(session_id, str) or not session_id:
|
||||
raise RuntimeError("session.created omitted the server-allocated session id")
|
||||
return session_id
|
||||
|
||||
|
||||
async def run_demo(args):
|
||||
return await _scenario_module().run_demo(args)
|
||||
|
||||
@@ -217,7 +226,6 @@ async def _open_admission_session(
|
||||
args.url,
|
||||
args.model,
|
||||
autostart=False if getattr(args, "ref_audio", None) else None,
|
||||
session_id=session_id,
|
||||
)
|
||||
ws = await websockets.connect(url, max_size=64 * 1024 * 1024)
|
||||
await ws.send(
|
||||
@@ -225,10 +233,8 @@ async def _open_admission_session(
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": {
|
||||
"session_id": session_id,
|
||||
"model": args.model,
|
||||
"modalities": ["audio", "text"],
|
||||
"extra_body": {"native_duplex": True},
|
||||
**({"ref_audio": _ref_audio_data_url(args.ref_audio)} if getattr(args, "ref_audio", None) else {}),
|
||||
},
|
||||
}
|
||||
@@ -255,12 +261,10 @@ async def _admission_probe(args: argparse.Namespace, *, limit: int) -> dict[str,
|
||||
for index in range(limit):
|
||||
accepted.append(await _open_admission_session(args, f"{prefix}-accepted-{index}"))
|
||||
|
||||
overflow_id = f"{prefix}-overflow"
|
||||
overflow_url = _url_with_model(
|
||||
args.url,
|
||||
args.model,
|
||||
autostart=False if getattr(args, "ref_audio", None) else None,
|
||||
session_id=overflow_id,
|
||||
)
|
||||
async with websockets.connect(overflow_url, max_size=64 * 1024 * 1024) as overflow:
|
||||
await overflow.send(
|
||||
@@ -268,10 +272,8 @@ async def _admission_probe(args: argparse.Namespace, *, limit: int) -> dict[str,
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": {
|
||||
"session_id": overflow_id,
|
||||
"model": args.model,
|
||||
"modalities": ["audio", "text"],
|
||||
"extra_body": {"native_duplex": True},
|
||||
**(
|
||||
{"ref_audio": _ref_audio_data_url(args.ref_audio)}
|
||||
if getattr(args, "ref_audio", None)
|
||||
@@ -326,7 +328,6 @@ async def _resume_probe(
|
||||
args.url,
|
||||
args.model,
|
||||
autostart=False if getattr(args, "ref_audio", None) else None,
|
||||
session_id=session_id,
|
||||
)
|
||||
async with websockets.connect(url, max_size=64 * 1024 * 1024) as first:
|
||||
await first.send(
|
||||
@@ -334,10 +335,8 @@ async def _resume_probe(
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": {
|
||||
"session_id": session_id,
|
||||
"model": args.model,
|
||||
"modalities": ["audio", "text"],
|
||||
"extra_body": {"native_duplex": True},
|
||||
**(
|
||||
{"ref_audio": _ref_audio_data_url(args.ref_audio)}
|
||||
if getattr(args, "ref_audio", None)
|
||||
@@ -348,10 +347,10 @@ async def _resume_probe(
|
||||
)
|
||||
)
|
||||
created, first_events = await _receive_until(first, "session.created", timeout_s=args.timeout_s)
|
||||
session_id = _created_session_id(created)
|
||||
token = created.get("resume_token")
|
||||
incarnation = created.get("incarnation")
|
||||
generation = created.get("attachment_generation")
|
||||
if not isinstance(token, str) or not isinstance(incarnation, int):
|
||||
if not isinstance(token, str):
|
||||
raise RuntimeError("session.created omitted resumable credentials")
|
||||
last_seq = max(_server_event_sequences(first_events), default=0)
|
||||
|
||||
@@ -366,7 +365,6 @@ async def _resume_probe(
|
||||
{
|
||||
"type": "session.resume",
|
||||
"session_id": session_id,
|
||||
"incarnation": incarnation,
|
||||
"resume_token": token,
|
||||
"last_received_server_event_seq": last_seq,
|
||||
}
|
||||
@@ -429,7 +427,6 @@ async def _takeover_probe(
|
||||
args.url,
|
||||
args.model,
|
||||
autostart=False if getattr(args, "ref_audio", None) else None,
|
||||
session_id=session_id,
|
||||
)
|
||||
first = await websockets.connect(url, max_size=64 * 1024 * 1024)
|
||||
second = None
|
||||
@@ -439,10 +436,8 @@ async def _takeover_probe(
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": {
|
||||
"session_id": session_id,
|
||||
"model": args.model,
|
||||
"modalities": ["audio", "text"],
|
||||
"extra_body": {"native_duplex": True},
|
||||
**(
|
||||
{"ref_audio": _ref_audio_data_url(args.ref_audio)}
|
||||
if getattr(args, "ref_audio", None)
|
||||
@@ -453,10 +448,10 @@ async def _takeover_probe(
|
||||
)
|
||||
)
|
||||
created, first_events = await _receive_until(first, "session.created", timeout_s=args.timeout_s)
|
||||
session_id = _created_session_id(created)
|
||||
token = created.get("resume_token")
|
||||
incarnation = created.get("incarnation")
|
||||
generation = created.get("attachment_generation")
|
||||
if not isinstance(token, str) or not isinstance(incarnation, int) or not isinstance(generation, int):
|
||||
if not isinstance(token, str) or not isinstance(generation, int):
|
||||
raise RuntimeError("session.created omitted takeover credentials")
|
||||
last_seq = max(_server_event_sequences(first_events), default=0)
|
||||
|
||||
@@ -466,7 +461,6 @@ async def _takeover_probe(
|
||||
{
|
||||
"type": "session.resume",
|
||||
"session_id": session_id,
|
||||
"incarnation": incarnation,
|
||||
"resume_token": token,
|
||||
"last_received_server_event_seq": last_seq,
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import hashlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
@@ -412,8 +411,6 @@ async def run_soft_interrupt(args: argparse.Namespace) -> dict[str, object]:
|
||||
str(args.chunk_ms),
|
||||
"--timeout-s",
|
||||
str(args.timeout_s),
|
||||
"--session-id",
|
||||
f"duplex-soft-interrupt-{uuid.uuid4().hex}",
|
||||
]
|
||||
command.extend(["--ref-audio", str(_canonical_path(args.ref_audio))])
|
||||
temperature = getattr(args, "temperature", None)
|
||||
|
||||
@@ -12,7 +12,6 @@ incremental playback acks, a forced transport drop recovered by automatic
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -47,7 +46,6 @@ async def _run_live_session(*, url: str, model: str, ref_audio: Path, input_wav:
|
||||
url,
|
||||
model=model,
|
||||
config=config,
|
||||
session_id=f"duplex-client-live-{uuid.uuid4().hex}",
|
||||
reconnect=ReconnectPolicy(max_attempts=5, backoff_s=(0.25, 1.0)),
|
||||
# Short interval so the periodic heartbeat path runs during the
|
||||
# session — including across the forced transport drop below.
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""E2E online serving tests for Gepard-1.0 via ``/v1/audio/speech``.
|
||||
|
||||
Zero-shot default voice. Needs a GPU and the NeMo NanoCodec; the weekly
|
||||
``TTS · Gepard-1.0 · L4`` step installs NeMo then runs this file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import array
|
||||
import os
|
||||
import statistics
|
||||
import struct
|
||||
import time
|
||||
import wave
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from io import BytesIO
|
||||
|
||||
os.environ.setdefault("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
import soundfile as sf
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.media import convert_audio_bytes_to_text
|
||||
from tests.helpers.runtime import OmniServerParams
|
||||
from tests.helpers.stage_config import get_deploy_config_path
|
||||
|
||||
pytest.importorskip("nemo.collections.tts.models")
|
||||
|
||||
pytestmark = [pytest.mark.slow, pytest.mark.tts]
|
||||
|
||||
MODEL = "nineninesix/gepard-1.0"
|
||||
SAMPLE_RATE = 22050
|
||||
SAMPLES_PER_FRAME = 1024
|
||||
PCM16_BYTES_PER_FRAME = SAMPLES_PER_FRAME * 2
|
||||
DEFAULT_TIMEOUT_S = 180.0
|
||||
|
||||
_DISTINGUISHABLE_PROMPTS = {
|
||||
"He drinks coffee every morning.": "coffee",
|
||||
"Machine learning is interesting.": "learning",
|
||||
"Please close the window before leaving.": "window",
|
||||
"My favorite color is purple.": "purple",
|
||||
}
|
||||
|
||||
tts_server_params = [
|
||||
pytest.param(
|
||||
OmniServerParams(
|
||||
model=MODEL,
|
||||
stage_config_path=get_deploy_config_path("gepard.yaml"),
|
||||
server_args=["--trust-remote-code", "--disable-log-stats"],
|
||||
stage_init_timeout=900,
|
||||
),
|
||||
id="gepard",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _base_config(omni_server, text: str, **extra) -> dict:
|
||||
cfg = {
|
||||
"model": omni_server.model,
|
||||
"input": text,
|
||||
"voice": "default",
|
||||
"timeout": DEFAULT_TIMEOUT_S,
|
||||
"seed": 7,
|
||||
# NanoCodec is quieter than the 24 kHz models the PCM HNR helper was
|
||||
# calibrated on; keep the catastrophic-failure check, not a quality gate.
|
||||
"min_hnr_db": -2.0,
|
||||
"transcript_escalation_model": "large-v3",
|
||||
}
|
||||
cfg.update(extra)
|
||||
return cfg
|
||||
|
||||
|
||||
def _wav_sample_rate(wav_bytes: bytes) -> int:
|
||||
return struct.unpack_from("<I", wav_bytes, 24)[0]
|
||||
|
||||
|
||||
def _wav_pcm_payload_len(wav_bytes: bytes) -> int:
|
||||
data, _sr = sf.read(BytesIO(wav_bytes), dtype="int16")
|
||||
return int(data.size) * 2
|
||||
|
||||
|
||||
def _pcm16le_to_wav(pcm: bytes, sample_rate: int = SAMPLE_RATE) -> bytes:
|
||||
"""Whisper helpers expect a container; streamed speech is raw s16le."""
|
||||
buf = BytesIO()
|
||||
with wave.open(buf, "wb") as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(sample_rate)
|
||||
wf.writeframes(pcm)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _pcm16_samples(pcm: bytes) -> array.array:
|
||||
samples = array.array("h")
|
||||
samples.frombytes(pcm)
|
||||
return samples
|
||||
|
||||
|
||||
def _pcm16_diag(label: str, left: bytes, right: bytes) -> None:
|
||||
extra_frames = (len(left) - len(right)) // PCM16_BYTES_PER_FRAME
|
||||
overlap = min(len(left), len(right))
|
||||
first_frame_match = (
|
||||
overlap >= PCM16_BYTES_PER_FRAME and left[:PCM16_BYTES_PER_FRAME] == right[:PCM16_BYTES_PER_FRAME]
|
||||
)
|
||||
worst = None
|
||||
corr = None
|
||||
first_diff_sample = None
|
||||
if overlap >= 2 and overlap % 2 == 0:
|
||||
a = _pcm16_samples(left[:overlap])
|
||||
b = _pcm16_samples(right[:overlap])
|
||||
worst = max(abs(x - y) for x, y in zip(a, b, strict=True))
|
||||
for i, (x, y) in enumerate(zip(a, b, strict=True)):
|
||||
if x != y:
|
||||
first_diff_sample = i
|
||||
break
|
||||
try:
|
||||
corr = round(statistics.correlation(a, b), 6)
|
||||
except statistics.StatisticsError:
|
||||
corr = None
|
||||
print(
|
||||
f"[Gepard pcm-diag] {label} left={len(left)} right={len(right)} "
|
||||
f"extra_frames={extra_frames} first_frame_match={first_frame_match} "
|
||||
f"first_diff_sample={first_diff_sample} max_abs={worst} corr={corr}"
|
||||
)
|
||||
|
||||
|
||||
def _scan_logs_for_preemption(omni_server) -> None:
|
||||
log_paths = getattr(omni_server, "_stage_log_paths", {}) or {}
|
||||
hits: list[str] = []
|
||||
for path in log_paths.values():
|
||||
if path is None or not path.exists():
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="replace").lower()
|
||||
if "preempt" in text:
|
||||
hits.append(str(path))
|
||||
assert not hits, f"preemption mentioned in stage logs: {hits}"
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
@pytest.mark.parametrize("response_format", ["wav", "pcm"])
|
||||
def test_text_to_audio_basic(omni_server, online_client, response_format: str, run_level: str) -> None:
|
||||
text = "Hello, this is Gepard speaking."
|
||||
# whisper-small/large-v3 mishear 22.05 kHz WAV of this prompt as
|
||||
# "Jeb Ard" / "Jeff Bard" (cosine 0.80 / 0.75). Offline uses keyword
|
||||
# containment; the PCM twin below still goes through the 0.9 ASR gate.
|
||||
if response_format == "wav":
|
||||
url = f"http://{omni_server.host}:{omni_server.port}/v1/audio/speech"
|
||||
payload = {
|
||||
"model": omni_server.model,
|
||||
"input": text,
|
||||
"voice": "default",
|
||||
"seed": 7,
|
||||
"stream": False,
|
||||
"response_format": "wav",
|
||||
}
|
||||
r = requests.post(url, json=payload, timeout=DEFAULT_TIMEOUT_S)
|
||||
r.raise_for_status()
|
||||
audio = r.content
|
||||
assert _wav_sample_rate(audio) == SAMPLE_RATE
|
||||
pcm_len = _wav_pcm_payload_len(audio)
|
||||
else:
|
||||
[resp] = online_client.send_audio_speech_request(
|
||||
_base_config(omni_server, text, stream=False, response_format=response_format)
|
||||
)
|
||||
assert resp.audio_bytes
|
||||
pcm_len = len(resp.audio_bytes)
|
||||
assert pcm_len % PCM16_BYTES_PER_FRAME == 0
|
||||
if run_level in {"advanced_model", "full_model"}:
|
||||
duration = pcm_len / 2 / SAMPLE_RATE
|
||||
assert 0.5 < duration < 30.0, f"implausible duration {duration:.2f}s"
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
def test_seed_determinism(omni_server, online_client) -> None:
|
||||
text = "Hello, this is Gepard speaking."
|
||||
[a] = online_client.send_audio_speech_request(_base_config(omni_server, text, seed=7, response_format="pcm"))
|
||||
[b] = online_client.send_audio_speech_request(_base_config(omni_server, text, seed=7, response_format="pcm"))
|
||||
[c] = online_client.send_audio_speech_request(_base_config(omni_server, text, seed=11, response_format="pcm"))
|
||||
assert a.audio_bytes == b.audio_bytes
|
||||
assert a.audio_bytes != c.audio_bytes
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
def test_streaming_pcm_incremental(omni_server, online_client) -> None:
|
||||
text = "Hello, this is Gepard speaking."
|
||||
url = f"http://{omni_server.host}:{omni_server.port}/v1/audio/speech"
|
||||
payload = {
|
||||
"model": omni_server.model,
|
||||
"input": text,
|
||||
"voice": "default",
|
||||
"seed": 7,
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"response_format": "pcm",
|
||||
}
|
||||
start = time.perf_counter()
|
||||
arrivals: list[tuple[float, int]] = []
|
||||
streamed = bytearray()
|
||||
with requests.post(url, json=payload, stream=True, timeout=DEFAULT_TIMEOUT_S) as resp:
|
||||
resp.raise_for_status()
|
||||
content_type = resp.headers.get("content-type") or ""
|
||||
assert "pcm" in content_type or "octet-stream" in content_type
|
||||
for chunk in resp.iter_content(chunk_size=None):
|
||||
if not chunk:
|
||||
continue
|
||||
arrivals.append((time.perf_counter(), len(chunk)))
|
||||
streamed.extend(chunk)
|
||||
assert len(arrivals) >= 2, f"expected incremental chunks, got {len(arrivals)}"
|
||||
assert arrivals[1][0] > arrivals[0][0]
|
||||
ttfa_ms = (arrivals[0][0] - start) * 1000.0
|
||||
cadence_ms = [(arrivals[i][0] - arrivals[i - 1][0]) * 1000.0 for i in range(1, len(arrivals))]
|
||||
print(f"[Gepard streaming] TTFA_ms={ttfa_ms:.1f} chunk_cadence_ms={cadence_ms}")
|
||||
|
||||
[non_stream] = online_client.send_audio_speech_request(
|
||||
_base_config(omni_server, text, seed=7, stream=False, response_format="pcm")
|
||||
)
|
||||
assert bytes(streamed) == non_stream.audio_bytes
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
def test_seeded_streaming_stops(omni_server) -> None:
|
||||
url = f"http://{omni_server.host}:{omni_server.port}/v1/audio/speech"
|
||||
payload = {
|
||||
"model": omni_server.model,
|
||||
"input": "Hello, this is Gepard speaking.",
|
||||
"voice": "default",
|
||||
"seed": 7,
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"response_format": "pcm",
|
||||
}
|
||||
with requests.post(url, json=payload, stream=True, timeout=DEFAULT_TIMEOUT_S) as resp:
|
||||
resp.raise_for_status()
|
||||
audio = b"".join(chunk for chunk in resp.iter_content(chunk_size=None) if chunk)
|
||||
duration = len(audio) / 2 / SAMPLE_RATE
|
||||
assert duration < 30.0, f"seeded stream ran {duration:.1f}s — stop constraint dropped?"
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
def test_sse_stream_emits_delta_then_done(omni_server) -> None:
|
||||
url = f"http://{omni_server.host}:{omni_server.port}/v1/audio/speech"
|
||||
payload = {
|
||||
"model": omni_server.model,
|
||||
"input": "Hello, this is Gepard speaking.",
|
||||
"voice": "default",
|
||||
"seed": 7,
|
||||
"stream": True,
|
||||
"stream_format": "sse",
|
||||
"response_format": "pcm",
|
||||
}
|
||||
with requests.post(url, json=payload, stream=True, timeout=DEFAULT_TIMEOUT_S) as resp:
|
||||
resp.raise_for_status()
|
||||
assert resp.headers.get("content-type", "").startswith("text/event-stream")
|
||||
body = b"".join(resp.iter_content(chunk_size=None)).decode("utf-8", errors="replace")
|
||||
assert "speech.audio.delta" in body
|
||||
assert "speech.audio.done" in body
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
def test_concurrent_requests_stay_isolated(omni_server, online_client, run_level: str) -> None:
|
||||
def _one(text: str):
|
||||
return online_client.send_audio_speech_request(_base_config(omni_server, text, seed=7, response_format="wav"))[
|
||||
0
|
||||
]
|
||||
|
||||
texts = list(_DISTINGUISHABLE_PROMPTS)
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
responses = list(pool.map(_one, texts))
|
||||
|
||||
assert all(r.success for r in responses)
|
||||
for i in range(len(responses)):
|
||||
for j in range(i + 1, len(responses)):
|
||||
assert responses[i].audio_bytes != responses[j].audio_bytes
|
||||
|
||||
if run_level in {"advanced_model", "full_model"}:
|
||||
for text, resp in zip(texts, responses, strict=True):
|
||||
keyword = _DISTINGUISHABLE_PROMPTS[text]
|
||||
transcript = convert_audio_bytes_to_text(resp.audio_bytes, language="en").lower()
|
||||
assert keyword in transcript, f"expected {keyword!r} in {transcript!r} for {text!r}"
|
||||
|
||||
_scan_logs_for_preemption(omni_server)
|
||||
|
||||
|
||||
def _speech_url(omni_server) -> str:
|
||||
return f"http://{omni_server.host}:{omni_server.port}/v1/audio/speech"
|
||||
|
||||
|
||||
def _stream_payload(omni_server, text: str, **extra) -> dict:
|
||||
payload = {
|
||||
"model": omni_server.model,
|
||||
"input": text,
|
||||
"voice": "default",
|
||||
"seed": 7,
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"response_format": "pcm",
|
||||
}
|
||||
payload.update(extra)
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StreamCapture:
|
||||
pcm: bytes
|
||||
arrivals: list[tuple[float, int]]
|
||||
|
||||
@property
|
||||
def duration_s(self) -> float:
|
||||
return len(self.pcm) / 2 / SAMPLE_RATE
|
||||
|
||||
|
||||
def _capture_stream(
|
||||
url: str,
|
||||
payload: dict,
|
||||
timeout_s: float,
|
||||
*,
|
||||
sleep_after_first_s: float = 0.0,
|
||||
) -> _StreamCapture:
|
||||
start = time.perf_counter()
|
||||
arrivals: list[tuple[float, int]] = []
|
||||
pcm = bytearray()
|
||||
with requests.post(url, json=payload, stream=True, timeout=timeout_s) as resp:
|
||||
resp.raise_for_status()
|
||||
first = True
|
||||
for chunk in resp.iter_content(chunk_size=None):
|
||||
if not chunk:
|
||||
continue
|
||||
arrivals.append((time.perf_counter() - start, len(chunk)))
|
||||
pcm.extend(chunk)
|
||||
if first and sleep_after_first_s > 0:
|
||||
time.sleep(sleep_after_first_s)
|
||||
first = False
|
||||
return _StreamCapture(bytes(pcm), arrivals)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
def test_concurrent_streaming_stays_isolated(omni_server, run_level: str) -> None:
|
||||
url = _speech_url(omni_server)
|
||||
texts = list(_DISTINGUISHABLE_PROMPTS)
|
||||
|
||||
def _one(text: str) -> tuple[str, _StreamCapture]:
|
||||
return text, _capture_stream(url, _stream_payload(omni_server, text), DEFAULT_TIMEOUT_S)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
results = list(pool.map(_one, texts))
|
||||
|
||||
captures = [cap for _, cap in results]
|
||||
assert all(len(c.arrivals) >= 2 for c in captures)
|
||||
for i in range(len(captures)):
|
||||
for j in range(i + 1, len(captures)):
|
||||
assert captures[i].pcm != captures[j].pcm
|
||||
|
||||
if run_level in {"advanced_model", "full_model"}:
|
||||
for text, cap in results:
|
||||
keyword = _DISTINGUISHABLE_PROMPTS[text]
|
||||
transcript = convert_audio_bytes_to_text(_pcm16le_to_wav(cap.pcm), language="en").lower()
|
||||
assert keyword in transcript, f"expected {keyword!r} in {transcript!r} for {text!r}"
|
||||
|
||||
_scan_logs_for_preemption(omni_server)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
def test_slow_client_still_completes(omni_server, online_client) -> None:
|
||||
"""Slow reader must finish, and seed=7 must match a prior non-stream.
|
||||
|
||||
Runs before ``test_client_disconnect_releases_capacity`` so an abort
|
||||
storm cannot contaminate this comparison. Prints overlap diagnostics
|
||||
before the bit-exact asserts.
|
||||
"""
|
||||
text = "Hello, this is Gepard speaking."
|
||||
[baseline] = online_client.send_audio_speech_request(
|
||||
_base_config(omni_server, text, seed=7, stream=False, response_format="pcm")
|
||||
)
|
||||
cap = _capture_stream(
|
||||
url=_speech_url(omni_server),
|
||||
payload=_stream_payload(omni_server, text),
|
||||
timeout_s=DEFAULT_TIMEOUT_S,
|
||||
sleep_after_first_s=3.0,
|
||||
)
|
||||
assert len(cap.arrivals) >= 2
|
||||
assert len(cap.pcm) % PCM16_BYTES_PER_FRAME == 0
|
||||
assert 0.5 < cap.duration_s < 30.0
|
||||
print(f"[Gepard slow-client] chunks={len(cap.arrivals)} duration_s={cap.duration_s:.3f}")
|
||||
_pcm16_diag("slow-vs-prior-nonstream", cap.pcm, baseline.audio_bytes)
|
||||
[after] = online_client.send_audio_speech_request(
|
||||
_base_config(omni_server, text, seed=7, stream=False, response_format="pcm")
|
||||
)
|
||||
_pcm16_diag("after-nonstream-vs-prior", after.audio_bytes, baseline.audio_bytes)
|
||||
assert cap.pcm == baseline.audio_bytes
|
||||
assert after.audio_bytes == baseline.audio_bytes
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
def test_client_disconnect_releases_capacity(omni_server, online_client) -> None:
|
||||
"""A follow-up request must complete after in-flight streams are closed.
|
||||
|
||||
Logs PCM diagnostics against a pre-abort ``seed=7`` baseline. Bit-exact
|
||||
equality after abort is not asserted here: a dying stream can still share
|
||||
a GPU batch with the next generate after ``abort()`` returns, and Gepard's
|
||||
in-model stop head / bf16 GEMM are not row-stable in that batch.
|
||||
"""
|
||||
url = _speech_url(omni_server)
|
||||
text = "Hello, this is Gepard speaking."
|
||||
[baseline] = online_client.send_audio_speech_request(
|
||||
_base_config(omni_server, text, seed=7, stream=False, response_format="pcm")
|
||||
)
|
||||
long_payload = _stream_payload(
|
||||
omni_server,
|
||||
"Please keep talking about the weather, the window, coffee, and purple flowers.",
|
||||
max_new_tokens=80,
|
||||
)
|
||||
for _ in range(4):
|
||||
resp = requests.post(url, json=long_payload, stream=True, timeout=DEFAULT_TIMEOUT_S)
|
||||
resp.raise_for_status()
|
||||
try:
|
||||
chunk = next(c for c in resp.iter_content(chunk_size=None) if c)
|
||||
assert chunk
|
||||
finally:
|
||||
resp.close()
|
||||
|
||||
t0 = time.perf_counter()
|
||||
follow = requests.post(
|
||||
url,
|
||||
json={
|
||||
"model": omni_server.model,
|
||||
"input": text,
|
||||
"voice": "default",
|
||||
"seed": 7,
|
||||
"stream": False,
|
||||
"response_format": "pcm",
|
||||
},
|
||||
timeout=DEFAULT_TIMEOUT_S,
|
||||
)
|
||||
follow.raise_for_status()
|
||||
elapsed = time.perf_counter() - t0
|
||||
pcm_len = len(follow.content)
|
||||
assert pcm_len % PCM16_BYTES_PER_FRAME == 0
|
||||
print(f"[Gepard disconnect] followup_s={elapsed:.2f} pcm_bytes={pcm_len}")
|
||||
_pcm16_diag("disconnect-followup-vs-prior", follow.content, baseline.audio_bytes)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
def test_mixed_stream_and_nonstream_isolated(omni_server, online_client, run_level: str) -> None:
|
||||
url = _speech_url(omni_server)
|
||||
stream_text = "He drinks coffee every morning."
|
||||
nonstream_text = "My favorite color is purple."
|
||||
|
||||
def _stream():
|
||||
return _capture_stream(url, _stream_payload(omni_server, stream_text), DEFAULT_TIMEOUT_S)
|
||||
|
||||
def _nonstream():
|
||||
return online_client.send_audio_speech_request(
|
||||
_base_config(omni_server, nonstream_text, seed=7, response_format="wav")
|
||||
)[0]
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
stream_future = pool.submit(_stream)
|
||||
nonstream_future = pool.submit(_nonstream)
|
||||
streamed = stream_future.result()
|
||||
nonstream = nonstream_future.result()
|
||||
|
||||
assert streamed.pcm
|
||||
assert nonstream.success
|
||||
assert streamed.pcm != nonstream.audio_bytes
|
||||
if run_level in {"advanced_model", "full_model"}:
|
||||
stream_tr = convert_audio_bytes_to_text(_pcm16le_to_wav(streamed.pcm), language="en").lower()
|
||||
nonstream_tr = convert_audio_bytes_to_text(nonstream.audio_bytes, language="en").lower()
|
||||
assert "coffee" in stream_tr, stream_tr
|
||||
assert "purple" in nonstream_tr, nonstream_tr
|
||||
_scan_logs_for_preemption(omni_server)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
def test_max_new_tokens_caps_whole_frames(omni_server) -> None:
|
||||
frames = 16
|
||||
url = _speech_url(omni_server)
|
||||
r = requests.post(
|
||||
url,
|
||||
json={
|
||||
"model": omni_server.model,
|
||||
"input": "Hello, this is Gepard speaking.",
|
||||
"voice": "default",
|
||||
"seed": 7,
|
||||
"stream": False,
|
||||
"response_format": "pcm",
|
||||
"max_new_tokens": frames,
|
||||
},
|
||||
timeout=DEFAULT_TIMEOUT_S,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
pcm_len = len(r.content)
|
||||
assert pcm_len % PCM16_BYTES_PER_FRAME == 0
|
||||
assert pcm_len == frames * PCM16_BYTES_PER_FRAME, f"got {pcm_len} bytes, want {frames} frames"
|
||||
duration = pcm_len / 2 / SAMPLE_RATE
|
||||
assert duration < 1.0, f"budget cap should stop near 16 frames, got {duration:.2f}s"
|
||||
|
||||
over = requests.post(
|
||||
url,
|
||||
json={
|
||||
"model": omni_server.model,
|
||||
"input": "Hello, this is Gepard speaking.",
|
||||
"voice": "default",
|
||||
"max_new_tokens": 4097,
|
||||
},
|
||||
timeout=DEFAULT_TIMEOUT_S,
|
||||
)
|
||||
assert over.status_code == 400
|
||||
assert "max_new_tokens" in over.text
|
||||
@@ -6,8 +6,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -90,25 +90,16 @@ async def _receive_protocol_events(ws, required_types: set[str], *, timeout_s: f
|
||||
|
||||
|
||||
async def _run_protocol_smoke(*, url: str, model: str, ref_audio: Path) -> list[dict[str, object]]:
|
||||
session_id = f"duplex-ci-protocol-{uuid.uuid4().hex}"
|
||||
websocket_url = build_realtime_url(
|
||||
url,
|
||||
model,
|
||||
autostart=False,
|
||||
session_id=session_id,
|
||||
extra_query={"native_duplex": "1"},
|
||||
)
|
||||
websocket_url = build_realtime_url(url, model, autostart=False)
|
||||
async with websockets.connect(websocket_url, max_size=64 * 1024 * 1024) as ws:
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": {
|
||||
"session_id": session_id,
|
||||
"model": model,
|
||||
"modalities": ["audio", "text"],
|
||||
"ref_audio": _ref_audio_data_url(str(ref_audio)),
|
||||
"extra_body": {"native_duplex": True},
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -123,6 +114,170 @@ async def _run_protocol_smoke(*, url: str, model: str, ref_audio: Path) -> list[
|
||||
return events
|
||||
|
||||
|
||||
async def _run_text_only_response_create(
|
||||
*,
|
||||
url: str,
|
||||
model: str,
|
||||
ref_audio: Path,
|
||||
text: str,
|
||||
timeout_s: float = 120.0,
|
||||
) -> dict[str, object]:
|
||||
"""Drive a text prompt with no audio and return the server's answer.
|
||||
|
||||
A Realtime client asks for speech from text with a user message item
|
||||
followed by ``response.create``. A model-native duplex session generates
|
||||
from audio units, so this returns whatever the server answers rather than
|
||||
assuming a response.
|
||||
"""
|
||||
websocket_url = build_realtime_url(url, model, autostart=False)
|
||||
async with websockets.connect(websocket_url, max_size=64 * 1024 * 1024) as ws:
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": {
|
||||
"model": model,
|
||||
"modalities": ["audio", "text"],
|
||||
"output_audio_format": "pcm16",
|
||||
"turn_detection": None,
|
||||
"temperature": 0.0,
|
||||
"ref_audio": _ref_audio_data_url(str(ref_audio)),
|
||||
"extra_body": {"auto_response": False},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
await _receive_protocol_events(ws, {"session.created", "session.updated"}, timeout_s=60)
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": text}],
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
await ws.send(json.dumps({"type": "response.create"}))
|
||||
|
||||
async def receive_outcome() -> dict[str, object]:
|
||||
while True:
|
||||
raw = await ws.recv()
|
||||
if not isinstance(raw, str):
|
||||
continue
|
||||
event = json.loads(raw)
|
||||
if isinstance(event, dict) and event.get("type") in {"error", "response.done"}:
|
||||
return event
|
||||
|
||||
return await asyncio.wait_for(receive_outcome(), timeout=timeout_s)
|
||||
|
||||
|
||||
async def _run_seeded_text_to_audio(
|
||||
*,
|
||||
url: str,
|
||||
model: str,
|
||||
ref_audio: Path | None,
|
||||
text: str,
|
||||
modalities: tuple[str, ...] = ("audio", "text"),
|
||||
silence_seconds: float = 12.0,
|
||||
timeout_s: float = 180.0,
|
||||
) -> dict[str, object]:
|
||||
"""Speak a seeded text: the duplex route's text-to-speech shape.
|
||||
|
||||
A model-native session takes its text once, in the session context
|
||||
(``duplex_initial_user_text``), and then generates per audio unit. Silence
|
||||
carries no content of its own, so it only advances the clock and lets the
|
||||
model answer the seeded turn.
|
||||
"""
|
||||
websocket_url = build_realtime_url(url, model, autostart=False)
|
||||
audio_bytes = 0
|
||||
transcript: list[str] = []
|
||||
output_text: list[str] = []
|
||||
seen: list[str] = []
|
||||
session_payload: dict[str, object] = {
|
||||
"model": model,
|
||||
"modalities": list(modalities),
|
||||
"input_audio_format": "pcm16",
|
||||
"output_audio_format": "pcm16",
|
||||
"turn_detection": None,
|
||||
"temperature": 0.0,
|
||||
"extra_body": {
|
||||
"auto_response": True,
|
||||
"force_listen_count": 0,
|
||||
"duplex_initial_user_text": text,
|
||||
},
|
||||
}
|
||||
if ref_audio is not None:
|
||||
session_payload["ref_audio"] = _ref_audio_data_url(str(ref_audio))
|
||||
async with websockets.connect(websocket_url, max_size=64 * 1024 * 1024) as ws:
|
||||
await ws.send(json.dumps({"type": "session.update", "session": session_payload}))
|
||||
|
||||
async def reader() -> None:
|
||||
nonlocal audio_bytes
|
||||
while True:
|
||||
raw = await ws.recv()
|
||||
if not isinstance(raw, str):
|
||||
continue
|
||||
event = json.loads(raw)
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
event_type = event.get("type")
|
||||
if isinstance(event_type, str):
|
||||
seen.append(event_type)
|
||||
delta = event.get("delta")
|
||||
if event_type == "response.output_audio.delta" and isinstance(delta, str):
|
||||
audio_bytes += len(base64.b64decode(delta))
|
||||
elif event_type == "response.output_audio_transcript.delta" and isinstance(delta, str):
|
||||
transcript.append(delta)
|
||||
elif event_type == "response.output_text.delta" and isinstance(delta, str):
|
||||
output_text.append(delta)
|
||||
elif event_type == "error":
|
||||
raise AssertionError(f"seeded text turn received an error: {event}")
|
||||
|
||||
reader_task = asyncio.create_task(reader())
|
||||
silence = bytes(2 * 16_000 * 200 // 1000)
|
||||
sent_ms = 0
|
||||
try:
|
||||
while sent_ms < silence_seconds * 1000 and "response.done" not in seen:
|
||||
sent_ms += 200
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "input_audio_buffer.append",
|
||||
"audio": base64.b64encode(silence).decode("ascii"),
|
||||
"input_audio_format": "pcm16",
|
||||
"sample_rate_hz": 16_000,
|
||||
"duration_ms": 200,
|
||||
"audio_end_ms": sent_ms,
|
||||
}
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0.2)
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout_s
|
||||
while loop.time() < deadline and "response.done" not in seen:
|
||||
await asyncio.sleep(0.5)
|
||||
if reader_task.done():
|
||||
await reader_task
|
||||
# Close explicitly. Dropping the socket parks the session in its
|
||||
# disconnect grace, where it keeps holding an admission slot and
|
||||
# starves the tests that follow.
|
||||
await ws.send(json.dumps({"type": "session.close"}))
|
||||
deadline = loop.time() + 30
|
||||
while loop.time() < deadline and "session.closed" not in seen:
|
||||
await asyncio.sleep(0.25)
|
||||
finally:
|
||||
reader_task.cancel()
|
||||
return {
|
||||
"audio_bytes": audio_bytes,
|
||||
"transcript": "".join(transcript),
|
||||
"output_text": "".join(output_text),
|
||||
"event_types": seen,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
|
||||
@@ -205,6 +360,215 @@ def test_duplex_single_session_video_input(omni_server, tmp_path: Path) -> None:
|
||||
_assert_session_metrics(result["session_metrics"], expected_count=2)
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
|
||||
@pytest.mark.parametrize(
|
||||
("locale", "text"),
|
||||
[
|
||||
("en", "Please say exactly: the quick brown fox jumps over the lazy dog."),
|
||||
("zh", "请朗读:今天天气很好,我们一起去公园散步。"),
|
||||
],
|
||||
)
|
||||
def test_duplex_seeded_text_to_audio(omni_server, locale: str, text: str) -> None:
|
||||
"""text -> audio over the duplex route, in both locales.
|
||||
|
||||
This is the case the deleted turn-based ``text -> audio`` tests covered.
|
||||
It is also the shape the Seed-TTS Realtime backend uses, so it guards the
|
||||
benchmark path as well as the modality.
|
||||
"""
|
||||
result = asyncio.run(
|
||||
_run_seeded_text_to_audio(
|
||||
url=realtime_url(omni_server),
|
||||
model=omni_server.model,
|
||||
ref_audio=resolve_ref_audio(),
|
||||
text=text,
|
||||
)
|
||||
)
|
||||
|
||||
assert "response.done" in result["event_types"], result["event_types"]
|
||||
assert int(result["audio_bytes"]) > 0, f"{locale} seeded text produced no audio"
|
||||
assert str(result["transcript"]).strip(), f"{locale} seeded text produced audio with no transcript"
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
|
||||
def test_duplex_seeded_text_to_text_needs_no_reference_voice(omni_server) -> None:
|
||||
"""text -> text, the one duplex session that opens without a reference voice.
|
||||
|
||||
``ref_audio`` is required only when the session asks for audio output, so a
|
||||
``modalities: ["text"]`` session is the duplex equivalent of the deleted
|
||||
turn-based ``text -> text`` case. The model is model-native and still
|
||||
speaks its answer, so this asserts the text side and the absence of the
|
||||
``ref_audio_required`` rejection, not the absence of audio.
|
||||
"""
|
||||
result = asyncio.run(
|
||||
_run_seeded_text_to_audio(
|
||||
url=realtime_url(omni_server),
|
||||
model=omni_server.model,
|
||||
ref_audio=None,
|
||||
text="What is the capital of France? Answer in one short sentence.",
|
||||
modalities=("text",),
|
||||
)
|
||||
)
|
||||
|
||||
assert "response.done" in result["event_types"], result["event_types"]
|
||||
produced_text = str(result["output_text"]) or str(result["transcript"])
|
||||
assert produced_text.strip(), f"text-only session produced nothing: {result['event_types']}"
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
|
||||
def test_duplex_seeded_text_to_long_audio_output(omni_server) -> None:
|
||||
"""A longer answer, so Code2Wav runs across many frames rather than one.
|
||||
|
||||
Replaces the deleted ``text_to_audio_long_output`` case. The assertion is
|
||||
on the amount of audio rather than its content: the point is that a long
|
||||
answer streams to completion, not that the model says any given word.
|
||||
"""
|
||||
result = asyncio.run(
|
||||
_run_seeded_text_to_audio(
|
||||
url=realtime_url(omni_server),
|
||||
model=omni_server.model,
|
||||
ref_audio=resolve_ref_audio(),
|
||||
text="What is the capital of China? Answer in about 40 words.",
|
||||
silence_seconds=30.0,
|
||||
timeout_s=240.0,
|
||||
)
|
||||
)
|
||||
|
||||
assert "response.done" in result["event_types"], result["event_types"]
|
||||
# 24 kHz mono pcm16: 2 s of speech is 96000 bytes, comfortably more than a
|
||||
# single Code2Wav frame and well under a 40-word answer.
|
||||
assert int(result["audio_bytes"]) > 96_000, f"expected a long answer, got {result['audio_bytes']} bytes"
|
||||
assert str(result["transcript"]).strip()
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
|
||||
def test_duplex_seeded_long_form_generation(omni_server) -> None:
|
||||
"""Long-form Chinese generation, replacing the deleted long-form case."""
|
||||
result = asyncio.run(
|
||||
_run_seeded_text_to_audio(
|
||||
url=realtime_url(omni_server),
|
||||
model=omni_server.model,
|
||||
ref_audio=resolve_ref_audio(),
|
||||
text="帮我讲一个100字的故事。",
|
||||
silence_seconds=30.0,
|
||||
timeout_s=240.0,
|
||||
)
|
||||
)
|
||||
|
||||
assert "response.done" in result["event_types"], result["event_types"]
|
||||
assert int(result["audio_bytes"]) > 96_000, f"expected long-form audio, got {result['audio_bytes']} bytes"
|
||||
assert str(result["transcript"]).strip()
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
|
||||
def test_duplex_sequential_sessions_are_independent(omni_server) -> None:
|
||||
"""A second session must answer its own prompt, not replay the first one.
|
||||
|
||||
Replaces the deleted ``sequential_requests_independent`` case. Sessions are
|
||||
engine-resident now, so this is the check that one session's context does
|
||||
not leak into the next one on the same stages.
|
||||
"""
|
||||
first = asyncio.run(
|
||||
_run_seeded_text_to_audio(
|
||||
url=realtime_url(omni_server),
|
||||
model=omni_server.model,
|
||||
ref_audio=resolve_ref_audio(),
|
||||
text="What is the capital of France? Answer in one short sentence.",
|
||||
)
|
||||
)
|
||||
second = asyncio.run(
|
||||
_run_seeded_text_to_audio(
|
||||
url=realtime_url(omni_server),
|
||||
model=omni_server.model,
|
||||
ref_audio=resolve_ref_audio(),
|
||||
text="What is the capital of China? Answer in one short sentence.",
|
||||
)
|
||||
)
|
||||
|
||||
for result in (first, second):
|
||||
assert "response.done" in result["event_types"], result["event_types"]
|
||||
assert int(result["audio_bytes"]) > 0
|
||||
assert str(result["transcript"]).strip()
|
||||
|
||||
# Different prompts must not produce the same answer: that would mean the
|
||||
# second session inherited the first one's context.
|
||||
assert str(first["transcript"]).strip() != str(second["transcript"]).strip()
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
|
||||
def test_duplex_text_only_response_create_is_rejected(omni_server) -> None:
|
||||
"""A duplex session generates from audio: a text-only prompt is refused, not ignored.
|
||||
|
||||
Every session on this route is model-native, and such a model decides per
|
||||
audio unit whether to speak: conversation items are context, not a turn.
|
||||
Refusing is the point -- opening a response the model never fills costs the
|
||||
caller the session's whole idle timeout to discover. A text prompt reaches
|
||||
the model through the session's seeded opening turn
|
||||
(``initial_user_text``) instead, which is what ``/v1/chat/completions``
|
||||
uses and what the Seed-TTS Realtime backend has to be ported to.
|
||||
"""
|
||||
outcome = asyncio.run(
|
||||
_run_text_only_response_create(
|
||||
url=realtime_url(omni_server),
|
||||
model=omni_server.model,
|
||||
ref_audio=resolve_ref_audio(),
|
||||
text="Please say: the quick brown fox jumps over the lazy dog.",
|
||||
)
|
||||
)
|
||||
|
||||
assert outcome.get("type") == "error", outcome
|
||||
error = outcome.get("error")
|
||||
assert isinstance(error, dict), outcome
|
||||
assert error.get("code") == "text_only_turn_unsupported", outcome
|
||||
assert error.get("type") == "invalid_request_error", outcome
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
|
||||
def test_duplex_single_session_still_image_input(omni_server, tmp_path: Path) -> None:
|
||||
"""A spoken turn with one still image attached.
|
||||
|
||||
The still-image case is a one-element frame list, which the client repeats
|
||||
on every unit of the turn: unlike the camera track it never advances, so
|
||||
this covers the image path the turn-based ``image -> text + audio`` request
|
||||
used to cover.
|
||||
"""
|
||||
args = demo_args(
|
||||
omni_server=omni_server,
|
||||
input_wav=validated_input_wav(),
|
||||
ref_audio=resolve_ref_audio(),
|
||||
output_dir=tmp_path / "still_image",
|
||||
)
|
||||
args.turns = 2
|
||||
args.turn_duration_ms = [args.first_turn_ms] * args.turns
|
||||
args.video_frames_b64 = duplex_camera_frames(seconds=1, cache_dir=tmp_path / "still")[:1]
|
||||
|
||||
result = asyncio.run(run_demo(args))
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["video_frame_count"] == 1
|
||||
assert result["video_stacked_frame_count"] == 0
|
||||
_assert_positive_int(result["audio_delta_count"])
|
||||
assert result["done_count"] == 2
|
||||
assert result["error_count"] == 0
|
||||
assert result["all_audio_responses_have_transcript"] is True
|
||||
assert result["transcript_delta_done_ok"] is True
|
||||
_assert_request_metrics(result["request_metrics"], expected_count=2)
|
||||
_assert_session_metrics(result["session_metrics"], expected_count=2)
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
|
||||
|
||||
@@ -30,7 +30,7 @@ from tests.e2e.online_serving.run_minicpmo_realtime_duplex_soft_interrupt import
|
||||
run_soft_interrupt,
|
||||
)
|
||||
from tests.helpers.mark import hardware_test
|
||||
from vllm_omni.entrypoints.duplex.server_vad import (
|
||||
from vllm_omni.engine.duplex.vad import (
|
||||
SILERO_VAD_FILENAME,
|
||||
SILERO_VAD_REPO_ID,
|
||||
SILERO_VAD_REVISION,
|
||||
|
||||
@@ -152,7 +152,6 @@ def test_realtime_duplex_demo_pair_launches_demo_processes_concurrently(tmp_path
|
||||
"parser.add_argument('--output-dir')",
|
||||
"parser.add_argument('--chunk-ms')",
|
||||
"parser.add_argument('--timeout-s')",
|
||||
"parser.add_argument('--session-id')",
|
||||
"parser.add_argument('--ref-audio')",
|
||||
"parser.add_argument('--require-audio', action='store_true')",
|
||||
"parser.add_argument('--no-realtime-pacing', action='store_true')",
|
||||
@@ -1027,7 +1026,7 @@ def test_realtime_duplex_demo_resolves_distinct_turn_inputs():
|
||||
demo._turn_input_paths(primary, ["second.wav"], turns=3)
|
||||
|
||||
|
||||
def test_realtime_duplex_demo_explicitly_enables_native_runtime_before_connect():
|
||||
def test_realtime_duplex_demo_url_carries_no_model_opt_in_or_session_id():
|
||||
demo = _load_demo_module()
|
||||
|
||||
url = demo._url_with_model(
|
||||
@@ -1036,23 +1035,12 @@ def test_realtime_duplex_demo_explicitly_enables_native_runtime_before_connect()
|
||||
)
|
||||
|
||||
query = parse_qs(urlsplit(url).query)
|
||||
assert query["native_duplex"] == ["1"]
|
||||
assert query["duplex"] == ["1"]
|
||||
assert "native_duplex" not in query
|
||||
assert "session_id" not in query
|
||||
|
||||
|
||||
def test_realtime_duplex_demo_explicit_session_id_reaches_autostart_query():
|
||||
demo = _load_demo_module()
|
||||
|
||||
url = demo._url_with_model(
|
||||
"ws://localhost:8099/v1/realtime?duplex=1",
|
||||
"openbmb/MiniCPM-o-4_5",
|
||||
session_id="reopen-e2e",
|
||||
)
|
||||
|
||||
query = parse_qs(urlsplit(url).query)
|
||||
assert query["session_id"] == ["reopen-e2e"]
|
||||
|
||||
|
||||
def test_realtime_duplex_demo_session_update_uses_explicit_session_id():
|
||||
def test_realtime_duplex_demo_session_update_leaves_session_id_to_the_server():
|
||||
demo = _load_demo_module()
|
||||
|
||||
event = demo._session_update_event(
|
||||
@@ -1066,8 +1054,8 @@ def test_realtime_duplex_demo_session_update_uses_explicit_session_id():
|
||||
|
||||
assert event["type"] == "session.update"
|
||||
assert "session_id" not in event
|
||||
assert event["session"]["session_id"] == "reopen-e2e"
|
||||
assert event["session"]["extra_body"]["native_duplex"] is True
|
||||
assert "session_id" not in event["session"]
|
||||
assert "native_duplex" not in event["session"]["extra_body"]
|
||||
|
||||
|
||||
def test_realtime_duplex_demo_response_required_uses_deterministic_sampling():
|
||||
|
||||
Loaded 100 of 329 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user