Compare commits

...
Author SHA1 Message Date
Boao Shi 995c71bef0 OmniRequestOutput inherits RequestOutput
Signed-off-by: Boao Shi <aoibosh@connect.hku.hk>
2026-07-16 16:28:45 +08:00
Canlin Guoandsunnyxyli 5c3900960b [Feature] Support MOSS-TTS-Local-v1.5 vocoder graph (#4929)
Signed-off-by: gcanlin <canlinguosdu@gmail.com>
Signed-off-by: sunnyxyli <1550693033@qq.com>
Co-authored-by: sunnyxyli <1550693033@qq.com>
2026-07-16 15:02:15 +08:00
Ricardo Noriega 68a992060b [Bugfix] Fix audio.format parameter silently ignored in chat completions (#4718)
Signed-off-by: Ricardo Noriega De Soto <rnoriega@redhat.com>
2026-07-16 00:17:02 -06:00
zhumingjue138 238fc0a609 [Bugfix] fix issue 5065 (#5118) 2026-07-15 19:39:44 -07:00
17 changed files with 728 additions and 198 deletions

No files matched your search

+2 -3
View File
@@ -66,7 +66,7 @@ Content-Type: application/json
|-----------|------|---------|-------------|
| `input` | string | **required** | Text prompt describing the audio to generate |
| `model` | string | server's model | Model to use (optional, should match server if specified) |
| `response_format` | string | "wav" | Audio format: wav, mp3, flac, pcm, aac, opus |
| `response_format` | string | "wav" | Audio format: wav, mp3, flac, pcm, opus |
| `speed` | float | 1.0 | Playback speed (0.25 - 4.0) |
#### Diffusion Parameters
@@ -90,7 +90,6 @@ Returns binary audio data with the appropriate `Content-Type` header:
| `mp3` | `audio/mpeg` |
| `flac` | `audio/flac` |
| `pcm` | `audio/pcm` |
| `aac` | `audio/aac` |
| `opus` | `audio/opus` |
## Examples
@@ -285,7 +284,7 @@ Pydantic validation failure (e.g. invalid `response_format`, `speed` out of rang
"detail": [
{
"type": "literal_error",
"msg": "Input should be 'wav', 'pcm', 'flac', 'mp3', 'aac' or 'opus'",
"msg": "Input should be 'wav', 'pcm', 'flac', 'mp3' or 'opus'",
...
}
]
+1 -1
View File
@@ -109,7 +109,7 @@ Content-Type: application/json
| `input` | string | **required** | The text to synthesize into speech |
| `model` | string | server's model | Model to use (optional, should match server if specified) |
| `voice` | string | "vivian" | Speaker name (e.g., vivian, ryan, aiden) |
| `response_format` | string | "wav" | Audio format: wav, mp3, flac, pcm, aac, opus |
| `response_format` | string | "wav" | Audio format: wav, mp3, flac, pcm, opus |
| `speed` | float | 1.0 | Playback speed (0.25-4.0) |
#### vLLM-Omni Extension Parameters
@@ -139,7 +139,7 @@ Content-Type: application/json
|-----------|------|---------|-------------|
| `input` | string | **required** | Text prompt describing the audio to generate |
| `model` | string | server's model | Model to use (optional, should match server if specified) |
| `response_format` | string | "wav" | Audio format: wav, mp3, flac, pcm, aac, opus |
| `response_format` | string | "wav" | Audio format: wav, mp3, flac, pcm, opus |
| `speed` | float | 1.0 | Playback speed (0.25 - 4.0) |
| `audio_length` | float | null | Audio duration in seconds (max ~47s for `stable-audio-open-1.0`) |
| `audio_start` | float | 0.0 | Audio start time in seconds |
@@ -7,6 +7,7 @@ E2E Online tests for Qwen3-Omni model.
import os
import pytest
from openai import BadRequestError
from tests.helpers.mark import hardware_test
from tests.helpers.media import generate_synthetic_audio, generate_synthetic_image, generate_synthetic_video
@@ -610,3 +611,21 @@ def test_text_to_audio_long_output_001(omni_server, openai_client) -> None:
text = responses[0].text_content if responses else ""
word_count = len(text.split())
assert word_count >= 200, f"Expected at least 200 words in long output, got {word_count}"
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params[:1], indirect=True)
def test_invalid_audio_format_rejected(omni_server, openai_client) -> None:
"""Ensure invalid audio format is rejected with 400 before streaming starts."""
messages = dummy_messages_from_mix_data(
system_prompt=get_system_prompt(),
content_text=get_prompt(),
)
with pytest.raises(BadRequestError, match="audio format"):
openai_client.client.chat.completions.create(
model=omni_server.model,
messages=messages,
modalities=["text", "audio"],
audio={"voice": "alloy", "format": "aac"},
stream=True,
)
@@ -0,0 +1,175 @@
# SPDX-License-Identifier: Apache-2.0
"""Tests for audio output format handling in chat completions.
Covers:
- #4716: audio.format parameter must be respected, not hardcoded to WAV
- Format validation rejects unsupported formats
- pcm16 is mapped to pcm for soundfile compatibility
- Default format is WAV when not specified
- create_audio encodes correctly for each supported format
"""
from __future__ import annotations
import numpy as np
import pytest
from vllm_omni.entrypoints.openai.audio_utils_mixin import AudioMixin
from vllm_omni.entrypoints.openai.protocol.audio import (
DEFAULT_AUDIO_FORMAT,
SUPPORTED_AUDIO_FORMATS,
SUPPORTED_CHAT_AUDIO_FORMATS,
CreateAudio,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class TestAudioFormatConstants:
def test_default_format_is_wav(self):
assert DEFAULT_AUDIO_FORMAT == "wav"
def test_supported_formats_no_aac(self):
assert "aac" not in SUPPORTED_AUDIO_FORMATS
def test_chat_formats_include_pcm16(self):
assert "pcm16" in SUPPORTED_CHAT_AUDIO_FORMATS
def test_chat_formats_superset_of_audio_formats(self):
assert SUPPORTED_AUDIO_FORMATS <= SUPPORTED_CHAT_AUDIO_FORMATS
class TestCreateAudio:
@pytest.fixture
def mixin(self):
return AudioMixin()
@pytest.fixture
def audio_tensor(self):
return np.sin(np.linspace(0, 2 * np.pi, 24000)).astype(np.float32)
@pytest.mark.parametrize("fmt", ["wav", "mp3", "flac", "opus", "pcm"])
def test_create_audio_supported_formats(self, mixin, audio_tensor, fmt):
audio_obj = CreateAudio(
audio_tensor=audio_tensor,
sample_rate=24000,
response_format=fmt,
speed=1.0,
base64_encode=False,
)
response = mixin.create_audio(audio_obj)
assert len(response.audio_data) > 0
def test_wav_magic_bytes(self, mixin, audio_tensor):
audio_obj = CreateAudio(
audio_tensor=audio_tensor,
sample_rate=24000,
response_format="wav",
speed=1.0,
base64_encode=False,
)
response = mixin.create_audio(audio_obj)
assert response.audio_data[:4] == b"RIFF"
assert response.media_type == "audio/wav"
def test_mp3_encoding(self, mixin, audio_tensor):
audio_obj = CreateAudio(
audio_tensor=audio_tensor,
sample_rate=24000,
response_format="mp3",
speed=1.0,
base64_encode=False,
)
response = mixin.create_audio(audio_obj)
assert response.media_type == "audio/mpeg"
assert response.audio_data[:4] != b"RIFF"
def test_flac_magic_bytes(self, mixin, audio_tensor):
audio_obj = CreateAudio(
audio_tensor=audio_tensor,
sample_rate=24000,
response_format="flac",
speed=1.0,
base64_encode=False,
)
response = mixin.create_audio(audio_obj)
assert response.audio_data[:4] == b"fLaC"
assert response.media_type == "audio/flac"
def test_unsupported_format_falls_back_to_default(self, mixin, audio_tensor):
audio_obj = CreateAudio(
audio_tensor=audio_tensor,
sample_rate=24000,
response_format="aac",
speed=1.0,
base64_encode=False,
)
response = mixin.create_audio(audio_obj)
assert response.audio_data[:4] == b"RIFF"
assert response.media_type == "audio/wav"
def test_base64_encoding(self, mixin, audio_tensor):
import base64
audio_obj = CreateAudio(
audio_tensor=audio_tensor,
sample_rate=24000,
response_format="wav",
speed=1.0,
base64_encode=True,
)
response = mixin.create_audio(audio_obj)
decoded = base64.b64decode(response.audio_data)
assert decoded[:4] == b"RIFF"
class TestResolveAudioFormat:
"""Test _resolve_audio_format via the serving chat class."""
@pytest.fixture
def serving_chat(self):
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
return object.__new__(OmniOpenAIServingChat)
def _make_request(self, audio_params=None):
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
req = ChatCompletionRequest(
model="test-model",
messages=[{"role": "user", "content": "hello"}],
)
if audio_params is not None:
req.audio = audio_params
return req
def test_default_format_when_no_audio_params(self, serving_chat):
request = self._make_request()
result = serving_chat._resolve_audio_format(request)
assert result == "wav"
def test_extracts_mp3_format(self, serving_chat):
request = self._make_request({"format": "mp3", "voice": "alloy"})
result = serving_chat._resolve_audio_format(request)
assert result == "mp3"
def test_pcm16_mapped_to_pcm(self, serving_chat):
request = self._make_request({"format": "pcm16", "voice": "alloy"})
result = serving_chat._resolve_audio_format(request)
assert result == "pcm"
def test_invalid_format_returns_error(self, serving_chat):
from vllm.entrypoints.openai.engine.protocol import ErrorResponse
request = self._make_request({"format": "aac", "voice": "alloy"})
result = serving_chat._resolve_audio_format(request)
assert isinstance(result, ErrorResponse)
assert "aac" in result.error.message
def test_all_supported_formats_accepted(self, serving_chat):
from vllm.entrypoints.openai.engine.protocol import ErrorResponse
for fmt in SUPPORTED_CHAT_AUDIO_FORMATS:
request = self._make_request({"format": fmt, "voice": "alloy"})
result = serving_chat._resolve_audio_format(request)
assert not isinstance(result, ErrorResponse), f"Format {fmt} should be accepted"
+1 -1
View File
@@ -22,7 +22,7 @@ def tiny_model_paths(request, run_level):
if not hasattr(item, "callspec"):
continue
model_name = item.callspec.params.get("model_name")
if model_name is None:
if model_name is None or model_name not in DIFFUSION_TEST_SETTINGS:
continue
if model_name not in model_paths:
settings = DIFFUSION_TEST_SETTINGS[model_name]
@@ -613,7 +613,12 @@ def compute_seed_tts_wer_metrics(
e,
)
wav_16k = _pcm_s16le_to_f32_16k(pcm)
# Request functions normalize ``tts_output_pcm_bytes`` to Seed-TTS WER
# format before it reaches this evaluator: 24 kHz mono int16 PCM.
# Do not apply the streamed-response env format here again; for MOSS
# local that would reinterpret already-normalized 24 kHz mono audio as
# 48 kHz stereo and corrupt ASR/WER.
wav_16k = _pcm_s16le_to_f32_16k(pcm, pcm_sample_rate=24000, channels=1)
if len(wav_16k) == 0:
asr_failed += 1
if include_per_item:
@@ -2,7 +2,7 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from collections.abc import Mapping
from dataclasses import asdict, is_dataclass
from dataclasses import asdict, fields, is_dataclass
from typing import Any
import msgspec
@@ -59,7 +59,10 @@ class OmniMsgpackEncoder:
if isinstance(obj, Image.Image):
return self._encode_pil_image(obj)
# RequestOutput (not a dataclass, needs special handling)
# RequestOutput (not a dataclass, needs special handling).
# Note: OmniRequestOutput subclasses RequestOutput but is a dataclass,
# so msgspec encodes it natively (field map) and it never reaches
# this hook.
if isinstance(obj, RequestOutput):
return self._encode_request_output(obj)
@@ -252,23 +255,30 @@ class OmniMsgpackDecoder:
def _decode_omni_request_output(self, obj: dict[str, Any]) -> Any:
"""Decode dict to OmniRequestOutput.
OmniRequestOutput is a dataclass, so we can use msgspec.convert
or construct it directly.
By the time this runs, ``_post_process`` has already reconstructed
nested values bottom-up (CompletionOutput, tensors, images, ...), so
the object is built directly from the known dataclass fields.
"""
from vllm_omni.outputs import OmniRequestOutput
data = dict(obj)
# Legacy wire format (pre-inheritance): the stage output was nested
# under "request_output". Merge its content via the compat setter.
legacy_inner = data.pop("request_output", None)
multi_modal_placeholders = data.pop("multi_modal_placeholders", None)
field_names = {f.name for f in fields(OmniRequestOutput)}
known = {k: v for k, v in data.items() if k in field_names}
try:
# Use msgspec.convert for dataclass reconstruction
return msgspec.convert(obj, OmniRequestOutput)
out = OmniRequestOutput(**known)
except Exception:
try:
# Fallback: construct directly if msgspec.convert fails
# (e.g., if some fields are missing or have wrong types)
return OmniRequestOutput(**obj)
except Exception:
# If both attempts fail, return dict as-is (defensive fallback)
# This should rarely happen if _is_omni_request_output is correct
return obj
# Defensive fallback; should rarely happen if
# _is_omni_request_output is correct.
return obj
if legacy_inner is not None and not isinstance(legacy_inner, dict):
out.request_output = legacy_inner
if multi_modal_placeholders is not None:
setattr(out, "multi_modal_placeholders", multi_modal_placeholders)
return out
def _decode_tensor(self, obj: dict[str, Any]) -> torch.Tensor:
"""Decode dict to torch.Tensor."""
+3 -7
View File
@@ -577,7 +577,6 @@ class OmniBase(PDDisaggregationMixin):
self.prom_metrics.set_running(running)
self.prom_metrics.set_waiting(max(0, total - running))
images = getattr(engine_outputs, "images", []) if output_type == "image" else []
response_metrics: dict[str, Any] = {}
stage_metrics: dict[str, dict[str, Any]] = {}
rid_key = str(req_id)
@@ -602,17 +601,14 @@ class OmniBase(PDDisaggregationMixin):
response_metrics["final_output_type"] = current_stage_metrics["final_output_type"]
response_metrics["num_tokens_in"] = current_stage_metrics["num_tokens_in"]
response_metrics["num_tokens_out"] = current_stage_metrics["num_tokens_out"]
# Generation content (outputs, prompt, images, trajectory_*, ...) is
# copied from engine_outputs onto the returned object by
# OmniRequestOutput.__post_init__.
return OmniRequestOutput(
request_id=req_id or "",
stage_id=stage_id,
final_output_type=output_type,
request_output=engine_outputs,
images=images,
trajectory_latents=getattr(engine_outputs, "trajectory_latents", None),
trajectory_timesteps=getattr(engine_outputs, "trajectory_timesteps", None),
trajectory_log_probs=getattr(engine_outputs, "trajectory_log_probs", None),
trajectory_decoded=getattr(engine_outputs, "trajectory_decoded", None),
_custom_output=getattr(engine_outputs, "_custom_output", {}),
metrics=response_metrics,
stage_durations=stage_durations,
peak_memory_mb=peak_memory_mb,
@@ -5,7 +5,7 @@ import torch
import torchaudio
from vllm.logger import init_logger
from vllm_omni.entrypoints.openai.protocol.audio import AudioResponse, CreateAudio
from vllm_omni.entrypoints.openai.protocol.audio import DEFAULT_AUDIO_FORMAT, AudioResponse, CreateAudio
try:
import soundfile
@@ -49,13 +49,12 @@ class AudioMixin:
"pcm": ("RAW", "audio/pcm", {"subtype": "PCM_16"}),
"flac": ("FLAC", "audio/flac", {}),
"mp3": ("MP3", "audio/mpeg", {}),
"aac": ("AAC", "audio/aac", {}),
"opus": ("OGG", "audio/ogg", {"subtype": "OPUS"}),
}
if response_format not in supported_formats:
logger.warning(f"Unsupported response format '{response_format}', defaulting to 'wav'.")
response_format = "wav"
logger.warning(f"Unsupported response format '{response_format}', defaulting to '{DEFAULT_AUDIO_FORMAT}'.")
response_format = DEFAULT_AUDIO_FORMAT
soundfile_format, media_type, kwargs = supported_formats[response_format]
@@ -6,6 +6,10 @@ from pydantic import AliasChoices, BaseModel, Field, field_validator, model_vali
_MAX_EMBEDDING_DIM = 8192
SUPPORTED_AUDIO_FORMATS: frozenset[str] = frozenset({"wav", "pcm", "flac", "mp3", "opus"})
SUPPORTED_CHAT_AUDIO_FORMATS: frozenset[str] = SUPPORTED_AUDIO_FORMATS | {"pcm16"}
DEFAULT_AUDIO_FORMAT: str = "wav"
def _normalize_ref_audio_value(value):
if value is None:
@@ -59,7 +63,7 @@ class OpenAICreateSpeechRequest(BaseModel):
default=None,
description="Instructions for voice style/emotion (maps to 'instruct' for Qwen3-TTS)",
)
response_format: Literal["wav", "pcm", "flac", "mp3", "aac", "opus"] = "wav"
response_format: Literal["wav", "pcm", "flac", "mp3", "opus"] = DEFAULT_AUDIO_FORMAT
speed: float | None = Field(
default=1.0,
ge=0.25,
@@ -325,7 +329,7 @@ class OpenAICreateAudioGenerateRequest(BaseModel):
description="Text prompt describing the audio to generate",
)
model: str | None = None
response_format: Literal["wav", "pcm", "flac", "mp3", "aac", "opus"] = "wav"
response_format: Literal["wav", "pcm", "flac", "mp3", "opus"] = DEFAULT_AUDIO_FORMAT
speed: float | None = Field(
default=1.0,
ge=0.25,
@@ -391,7 +395,7 @@ class SpeechBatchItem(BaseModel):
input: str
voice: str | None = Field(default=None, validation_alias=AliasChoices("voice", "speaker"))
instructions: str | None = None
response_format: Literal["wav", "pcm", "flac", "mp3", "aac", "opus"] | None = None
response_format: Literal["wav", "pcm", "flac", "mp3", "opus"] | None = None
speed: float | None = Field(default=None, ge=0.25, le=4.0)
task_type: Literal["CustomVoice", "VoiceDesign", "Base"] | None = None
language: str | None = None
@@ -411,7 +415,7 @@ class BatchSpeechRequest(BaseModel):
items: list[SpeechBatchItem] = Field(..., min_length=1)
voice: str | None = Field(default=None, validation_alias=AliasChoices("voice", "speaker"))
instructions: str | None = None
response_format: Literal["wav", "pcm", "flac", "mp3", "aac", "opus"] = "wav"
response_format: Literal["wav", "pcm", "flac", "mp3", "opus"] = DEFAULT_AUDIO_FORMAT
speed: float | None = Field(default=1.0, ge=0.25, le=4.0)
task_type: Literal["CustomVoice", "VoiceDesign", "Base"] | None = None
language: str | None = None
@@ -500,7 +504,7 @@ class StreamingSpeechSessionConfig(BaseModel):
task_type: Literal["CustomVoice", "VoiceDesign", "Base"] | None = None
language: str | None = None
instructions: str | None = None
response_format: Literal["wav", "pcm", "flac", "mp3", "aac", "opus"] = "wav"
response_format: Literal["wav", "pcm", "flac", "mp3", "opus"] = DEFAULT_AUDIO_FORMAT
speed: float | None = Field(default=1.0, ge=0.25, le=4.0)
max_new_tokens: int | None = Field(default=None, ge=1)
initial_codec_chunk_frames: int | None = Field(
+39 -7
View File
@@ -97,7 +97,12 @@ from vllm.v1.engine.exceptions import EngineDeadError
from vllm_omni.entrypoints.openai.audio_utils_mixin import AudioMixin
from vllm_omni.entrypoints.openai.image_api_utils import encode_image_base64_with_compression, validate_layered_layers
from vllm_omni.entrypoints.openai.protocol import OmniChatCompletionStreamResponse
from vllm_omni.entrypoints.openai.protocol.audio import AudioResponse, CreateAudio
from vllm_omni.entrypoints.openai.protocol.audio import (
DEFAULT_AUDIO_FORMAT,
SUPPORTED_CHAT_AUDIO_FORMATS,
AudioResponse,
CreateAudio,
)
from vllm_omni.entrypoints.openai.protocol.images import (
ImageData,
ImageEditARDeltaChunk,
@@ -450,6 +455,11 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
output_modalities = getattr(request, "modalities", engine_output_modalities)
request.modalities = output_modalities if output_modalities is not None else engine_output_modalities
if request.modalities and "audio" in request.modalities:
audio_format_check = self._resolve_audio_format(request)
if isinstance(audio_format_check, ErrorResponse):
return audio_format_check
num_inference_steps = None
extra_body: dict[str, Any] = {}
# Omni multistage image generation: Stage-0 (AR) should receive a clean
@@ -2561,10 +2571,14 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
else:
sample_rate = int(sr_raw)
audio_format = self._resolve_audio_format(request)
if isinstance(audio_format, ErrorResponse):
return audio_format
audio_obj = CreateAudio(
audio_tensor=audio_tensor,
sample_rate=sample_rate,
response_format="wav",
response_format=audio_format,
speed=1.0,
base64_encode=True,
)
@@ -3099,7 +3113,7 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
request_id=request_id,
)
images = getattr(result.request_output, "images", [])
images = getattr(result, "images", [])
stage_durations = result.stage_durations
peak_memory_mb = result.peak_memory_mb
cot_output = None
@@ -3170,7 +3184,7 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
)
yield f"data: {chunk.model_dump_json()}\n\n"
elif final_output_type == "image":
images = self._flatten_diffusion_images(getattr(output.request_output, "images", []))
images = self._flatten_diffusion_images(getattr(output, "images", []))
if not images:
raise RuntimeError("Streaming image edit produced an empty final image output.")
image_data = [
@@ -3474,8 +3488,7 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
# Image output path (text2img / img2img)
final_output_type = getattr(result, "final_output_type", "image")
# Handle nested OmniRequestOutput structure where images might be in request_output
images = getattr(result.request_output, "images", [])
images = getattr(result, "images", [])
multimodal_output = getattr(result, "multimodal_output", {}) or {}
stage_durations = result.stage_durations
peak_memory_mb = result.peak_memory_mb
@@ -3515,10 +3528,14 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
raise ValueError(f"Unexpected audio tensor rank {audio_tensor.ndim}; expected 1-3 dims.")
audio_array = audio_tensor.numpy()
audio_format = self._resolve_audio_format(request)
if isinstance(audio_format, ErrorResponse):
return audio_format
audio_obj = CreateAudio(
audio_tensor=audio_array,
sample_rate=sample_rate,
response_format="wav",
response_format=audio_format,
speed=1.0,
base64_encode=True,
)
@@ -3734,6 +3751,21 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
return height, width
def _resolve_audio_format(self, request: ChatCompletionRequest) -> str | ErrorResponse:
"""Extract and validate the audio output format from a chat completion request."""
audio_params = getattr(request, "audio", None)
if isinstance(audio_params, dict):
audio_format = audio_params.get("format", DEFAULT_AUDIO_FORMAT)
else:
audio_format = DEFAULT_AUDIO_FORMAT
if audio_format not in SUPPORTED_CHAT_AUDIO_FORMATS:
return self._create_error_response(
f"Invalid audio format '{audio_format}'. Supported formats: {sorted(SUPPORTED_CHAT_AUDIO_FORMATS)}",
)
if audio_format == "pcm16":
audio_format = "pcm"
return audio_format
def _create_error_response(
self,
message: str,
@@ -0,0 +1,164 @@
# Copyright 2026 OpenMOSS and the vLLM-Omni team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License").
"""CUDA graph wrapper for MOSS-TTS Local streaming codec decode."""
from __future__ import annotations
import time
from collections.abc import Callable
from dataclasses import dataclass
import torch
import torch.nn as nn
from torch.cuda import CUDAGraph
from vllm.logger import init_logger
from vllm.platforms import current_platform
logger = init_logger(__name__)
@dataclass
class _CapturedStreamingDecodeGraph:
graph: CUDAGraph
static_codes: torch.Tensor
static_lengths: torch.Tensor
static_audio: torch.Tensor
static_audio_lengths: torch.Tensor
class CUDAGraphStreamingDecoderWrapper:
"""Replay fixed-size streaming ``_decode_frame`` steps with CUDA graphs.
The wrapped codec must already be inside ``codec.streaming(batch_size)``.
Graphs are keyed by the streaming step frame count ``T``. The batch width is
fixed to the codec stream slot count.
"""
def __init__(
self,
codec: nn.Module,
*,
batch_size: int,
num_quantizers: int,
reset_streaming_state: Callable[[], None] | None = None,
) -> None:
self.codec = codec
self.batch_size = int(batch_size)
self.num_quantizers = int(num_quantizers)
self.reset_streaming_state = reset_streaming_state
self.graphs: dict[int, _CapturedStreamingDecodeGraph] = {}
self._pool = None
self._warmed_up = False
@property
def is_ready(self) -> bool:
return bool(self.graphs)
@property
def capture_sizes(self) -> list[int]:
return sorted(self.graphs)
@torch.no_grad()
def warmup(self, device: torch.device, capture_sizes: list[int]) -> None:
if self._warmed_up:
return
self._warmed_up = True
if device.type != "cuda":
return
sizes = sorted({int(size) for size in capture_sizes if int(size) > 0}, reverse=True)
if not sizes:
return
logger.info(
"MOSS-TTS streaming decoder CUDA graph warmup: batch_size=%d n_vq=%d capture_sizes=%s",
self.batch_size,
self.num_quantizers,
list(reversed(sizes)),
)
start_s = time.perf_counter()
with torch.cuda.device(device):
for size in sizes:
try:
self._capture(size, device)
logger.info(" Captured MOSS-TTS streaming decoder CUDA graph for size=%d", size)
except Exception:
self.graphs.pop(size, None)
logger.warning(
" Failed to capture MOSS-TTS streaming decoder CUDA graph for size=%d; "
"this size will use eager decode",
size,
exc_info=True,
)
logger.info(
"MOSS-TTS streaming decoder CUDA graph warmup complete: %d/%d captured in %.1f ms",
len(self.graphs),
len(sizes),
(time.perf_counter() - start_s) * 1000.0,
)
@torch.no_grad()
def _capture(self, size: int, device: torch.device) -> None:
codes = torch.zeros(
self.num_quantizers,
self.batch_size,
size,
dtype=torch.long,
device=device,
)
lengths = torch.full((self.batch_size,), size, dtype=torch.long, device=device)
exec_mask = torch.ones(self.batch_size, dtype=torch.bool, device=device)
self.codec._set_streaming_exec_mask(exec_mask)
stream = torch.cuda.Stream()
stream.wait_stream(torch.cuda.current_stream())
with torch.cuda.stream(stream):
_ = self.codec._decode_frame(codes, lengths)
torch.cuda.current_stream().wait_stream(stream)
torch.accelerator.synchronize(device)
if self.reset_streaming_state is not None:
self.reset_streaming_state()
self.codec._set_streaming_exec_mask(exec_mask)
if self._pool is None:
self._pool = current_platform.get_global_graph_pool()
graph = CUDAGraph()
with torch.cuda.graph(graph, pool=self._pool, capture_error_mode="thread_local"):
output = self.codec._decode_frame(codes, lengths)
if output.audio is None or output.audio_lengths is None:
raise RuntimeError("MOSS-TTS streaming decoder CUDA graph capture produced empty audio.")
self.graphs[size] = _CapturedStreamingDecodeGraph(
graph=graph,
static_codes=codes,
static_lengths=lengths,
static_audio=output.audio,
static_audio_lengths=output.audio_lengths,
)
@torch.no_grad()
def decode(
self,
codes_step: torch.Tensor,
exec_mask: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor] | None:
if not codes_step.is_cuda or torch.cuda.is_current_stream_capturing():
return None
n_vq, batch_size, step_t = codes_step.shape
if int(n_vq) != self.num_quantizers or int(batch_size) != self.batch_size:
return None
# The streaming codec advances its internal KV offsets by tensor T, so
# bucket-padding a smaller step into a larger captured graph would write
# padded frames into state. Capture exact step sizes instead.
entry = self.graphs.get(int(step_t))
if entry is None:
return None
self.codec._set_streaming_exec_mask(exec_mask)
entry.static_codes.copy_(codes_step)
entry.graph.replay()
return entry.static_audio, entry.static_audio_lengths
__all__ = ["CUDAGraphStreamingDecoderWrapper"]
@@ -26,6 +26,9 @@ from vllm_omni.model_executor.models.moss_tts.audio_tokenizer_v2 import (
from vllm_omni.model_executor.models.moss_tts.configuration_moss_audio_tokenizer_v2 import (
MossAudioTokenizerConfig as MossAudioTokenizerV2Config,
)
from vllm_omni.model_executor.models.moss_tts.cuda_graph_streaming_decoder_wrapper import (
CUDAGraphStreamingDecoderWrapper,
)
from vllm_omni.model_executor.models.moss_tts.moss_codec_cudagraph import (
MossTTSCUDAGraphCodecWrapper,
)
@@ -43,6 +46,7 @@ class _MossCodecStreamSession:
*,
stream_slots: int,
n_vq: int,
cudagraph_capture_sizes: list[int] | None = None,
) -> None:
self._codec = codec
self._stream_slots = int(stream_slots)
@@ -54,6 +58,19 @@ class _MossCodecStreamSession:
self._closed = False
with torch.no_grad():
self._exit_stack.enter_context(codec.streaming(self._batch_size))
self._cudagraph_wrapper: CUDAGraphStreamingDecoderWrapper | None = None
capture_sizes = sorted({int(size) for size in (cudagraph_capture_sizes or []) if int(size) > 0})
if capture_sizes and self._device.type == "cuda":
self._cudagraph_wrapper = CUDAGraphStreamingDecoderWrapper(
codec,
batch_size=self._batch_size,
num_quantizers=self._n_vq,
reset_streaming_state=lambda: self.reset_slots(list(range(self._batch_size))),
)
self._cudagraph_wrapper.warmup(self._device, capture_sizes)
self.reset_slots(list(range(self._batch_size)))
if not self._cudagraph_wrapper.is_ready:
self._cudagraph_wrapper = None
def acquire(self) -> int | None:
if not self._free_stream_slots:
@@ -109,12 +126,23 @@ class _MossCodecStreamSession:
codes_lengths[slot] = int(codes.shape[1])
exec_mask[slot] = True
self._codec._set_streaming_exec_mask(exec_mask)
result = self._codec._decode_frame(codes_step, codes_lengths)
if result.audio is None:
return {}
audio = result.audio.detach().to("cpu", torch.float32)
lengths = result.audio_lengths.detach().to("cpu") if result.audio_lengths is not None else None
graph_output: tuple[torch.Tensor, torch.Tensor] | None = None
if self._cudagraph_wrapper is not None:
graph_output = self._cudagraph_wrapper.decode(codes_step, exec_mask)
used_cudagraph = graph_output is not None
if used_cudagraph:
audio_tensor, lengths_tensor = graph_output
else:
self._codec._set_streaming_exec_mask(exec_mask)
result = self._codec._decode_frame(codes_step, codes_lengths)
if result.audio is None:
return {}
audio_tensor = result.audio
lengths_tensor = result.audio_lengths
audio = audio_tensor.detach().to("cpu", torch.float32)
lengths = lengths_tensor.detach().to("cpu") if lengths_tensor is not None else None
out: dict[int, torch.Tensor] = {}
for slot in slot_codes:
wav = audio[slot]
@@ -174,10 +202,17 @@ class MossTTSCodecDecoder(nn.Module):
self._sr_tensor = torch.tensor(self._OUTPUT_SAMPLE_RATE, dtype=torch.int32)
self._stream_session: _MossCodecStreamSession | None = None
self._stream_slots: int = self._connector_int("codec_stream_slots", default=0)
self._stream_max_step_frames: int = self._connector_int("codec_max_step_frames", default=100)
self._stream_chunk_frames: int = self._connector_int("codec_chunk_frames", default=0)
self._stream_max_step_frames: int = self._stream_chunk_frames or 100
self._stream_req_slots: dict[str, int] = {}
self._stream_pending_codes: dict[str, list[torch.Tensor]] = {}
self._stream_starved_reqs: set[str] = set()
self._codec_streaming: bool = self._connector_bool("codec_streaming", default=False)
default_graph_capture_max = self._stream_chunk_frames or min(self._stream_max_step_frames, 32)
default_graph_capture_sizes = list(range(1, max(1, default_graph_capture_max) + 1))
self._streaming_cudagraph_capture_sizes = self._streaming_cudagraph_capture_sizes_from_compilation_config(
default_graph_capture_sizes
)
# ------------------------------------------------------------------
# vLLM-Omni stubs (codec has no AR loop)
@@ -240,6 +275,15 @@ class MossTTSCodecDecoder(nn.Module):
},
)
if self._codec_streaming and runtime_additional_information is None:
return OmniOutput(
text_hidden_states=None,
multimodal_outputs={
"model_outputs": [empty] * num_req,
"sr": [sr_tensor] * num_req,
},
)
audios: list[torch.Tensor] = [empty] * num_req
srs: list[torch.Tensor] = [sr_tensor] * num_req
device = next(self._codec.parameters()).device
@@ -293,7 +337,7 @@ class MossTTSCodecDecoder(nn.Module):
continue
meta = (info.get("meta", {}) if isinstance(info, dict) else {}) or {}
finished = bool(meta.get("stream_finished", meta.get("finished", False)))
streaming_enabled = bool(meta.get("codec_streaming", False))
streaming_enabled = bool(meta.get("codec_streaming", self._codec_streaming))
code_flat_numel = meta.get("code_flat_numel")
if streaming_enabled and finished and code_flat_numel is not None and int(code_flat_numel) == 0:
for _, wav in self._finish_empty_streaming_requests([info]).items():
@@ -382,7 +426,7 @@ class MossTTSCodecDecoder(nn.Module):
if not isinstance(info, dict):
continue
meta = (info.get("meta", {}) or {}) if isinstance(info.get("meta", {}), dict) else {}
if not bool(meta.get("codec_streaming", False)):
if not bool(meta.get("codec_streaming", self._codec_streaming)):
continue
finished = bool(meta.get("stream_finished", meta.get("finished", False)))
if not finished:
@@ -446,6 +490,7 @@ class MossTTSCodecDecoder(nn.Module):
self._codec,
stream_slots=max(1, slots),
n_vq=self._n_vq,
cudagraph_capture_sizes=self._streaming_cudagraph_capture_sizes,
)
return self._stream_session
@@ -597,6 +642,30 @@ class MossTTSCodecDecoder(nn.Module):
return int(extra_cfg[name])
return default
def _connector_bool(self, name: str, default: bool = False) -> bool:
model_cfg = getattr(self.vllm_config, "model_config", None)
connector_cfg = getattr(model_cfg, "stage_connector_config", None)
if isinstance(connector_cfg, dict):
extra_cfg: dict | None = connector_cfg.get("extra", connector_cfg)
else:
extra_cfg = getattr(connector_cfg, "extra", None)
if isinstance(extra_cfg, dict) and name in extra_cfg:
return bool(extra_cfg[name])
return default
def _streaming_cudagraph_capture_sizes_from_compilation_config(
self,
default: list[int],
) -> list[int]:
if getattr(self.vllm_config.model_config, "enforce_eager", True):
return []
compilation_config = getattr(self.vllm_config, "compilation_config", None)
capture_sizes = getattr(compilation_config, "cudagraph_capture_sizes", None)
if capture_sizes:
max_step_frames = max(1, int(self._stream_max_step_frames))
return sorted({int(size) for size in capture_sizes if 0 < int(size) <= max_step_frames})
return list(default)
# ------------------------------------------------------------------
# Weight loading
# ------------------------------------------------------------------
@@ -716,7 +785,9 @@ class MossTTSCodecDecoder(nn.Module):
self._n_channels,
)
self._maybe_enable_decoder_cudagraph(device)
self._configure_decoder_cudagraph(device)
if self._codec_streaming and self._streaming_cudagraph_capture_sizes:
self._ensure_stream_session()
# vLLM's track_weights_loading() compares the returned set against
# ``self.named_parameters()``. After ``self._codec = codec`` above,
@@ -739,12 +810,30 @@ class MossTTSCodecDecoder(nn.Module):
codec = MossAudioTokenizerModel(codec_cfg)
return codec_cfg, codec
def _maybe_enable_decoder_cudagraph(self, device: torch.device) -> None:
"""Capture CUDA Graphs for the codec decoder if enforce_eager is False."""
def _configure_decoder_cudagraph(self, device: torch.device) -> None:
"""Select the codec CUDA Graph path.
``enforce_eager`` is the single graph on/off switch. If graphing is
enabled, ``codec_streaming`` decides whether decode uses the persistent
streaming-state wrapper or the offline full-chunk wrapper.
"""
if getattr(self.vllm_config.model_config, "enforce_eager", True):
self._streaming_cudagraph_capture_sizes = []
return
if self._codec is None:
return
if self._codec_streaming:
logger.info(
"MOSS-TTS codec CUDA Graph selected streaming wrapper: capture_sizes=%s",
self._streaming_cudagraph_capture_sizes,
)
return
self._enable_non_streaming_decoder_cudagraph(device)
def _enable_non_streaming_decoder_cudagraph(self, device: torch.device) -> None:
if self._codec is None:
return
# Read capture sizes from the connector's extra config (same convention
# as Qwen3-TTS), falling back to a sensible default covering common
@@ -1,6 +1,6 @@
"""CUDA Graph acceleration for the MOSS Audio Tokenizer codec decoder.
Captures the codec decode method (_decode_frame, or _decode on the vendored codec) for a set of fixed frame-count
Captures MossAudioTokenizerModel._decode_frame for a set of fixed frame-count
bucket sizes, then replays the captured graph at inference time to eliminate
kernel-launch overhead. Inputs that exceed all captured sizes fall back to
eager execution transparently.
@@ -24,7 +24,7 @@ logger = init_logger(__name__)
class MossTTSCUDAGraphCodecWrapper:
"""CUDA Graph wrapper for the codec decode method (_decode_frame, or _decode on the vendored codec).
"""CUDA Graph wrapper for MossAudioTokenizerModel._decode_frame.
Graphs are keyed by padded_T (int). On each call the actual T is
bucket-matched to the smallest pre-captured size >= T. The static code
@@ -51,15 +51,6 @@ class MossTTSCUDAGraphCodecWrapper:
enabled: bool = True,
) -> None:
self.model = model
# Upstream remote-code tokenizer exposes _decode_frame; the vendored
# MossAudioTokenizerModel still exposes _decode. Resolve once here so
# both codec implementations work.
decode_fn = getattr(model, "_decode_frame", None) or getattr(model, "_decode", None)
if decode_fn is None:
raise AttributeError(
f"{type(model).__module__}.{type(model).__name__} exposes neither _decode_frame nor _decode"
)
self._decode_fn = decode_fn
self.capture_sizes: list[int] = sorted(capture_sizes)
self.num_quantizers = num_quantizers
self.enabled = enabled
@@ -108,7 +99,7 @@ class MossTTSCUDAGraphCodecWrapper:
dummy_codes = torch.zeros(nq, 1, size, dtype=torch.long, device=device)
dummy_lengths = torch.tensor([size], dtype=torch.long, device=device)
with torch.no_grad():
_ = self._decode_fn(dummy_codes, dummy_lengths)
_ = self.model._decode_frame(dummy_codes, dummy_lengths)
torch.accelerator.synchronize(device)
@@ -141,13 +132,13 @@ class MossTTSCUDAGraphCodecWrapper:
# Extra eager warmup inside capture to ensure all kernels are compiled.
with torch.no_grad():
_ = self._decode_fn(static_codes, static_lengths)
_ = self.model._decode_frame(static_codes, static_lengths)
torch.accelerator.synchronize(device)
graph = CUDAGraph()
with torch.no_grad():
with torch.cuda.graph(graph, pool=current_platform.get_global_graph_pool()):
static_out = self._decode_fn(static_codes, static_lengths)
static_out = self.model._decode_frame(static_codes, static_lengths)
self.graphs[size] = graph
self.static_codes[size] = static_codes
+168 -124
View File
@@ -3,7 +3,7 @@ from typing import Any
import torch
from PIL import Image
from vllm.outputs import RequestOutput
from vllm.outputs import CompletionOutput, RequestOutput
from vllm.v1.outputs import ModelRunnerOutput
from vllm_omni.inputs.data import OmniPromptType
@@ -59,12 +59,58 @@ class OmniModelRunnerOutput(ModelRunnerOutput):
omni_connector_output: OmniConnectorOutput | None = None
# RequestOutput attributes that hold generation content. When wrapping a
# stage output, these are copied onto the OmniRequestOutput itself so that
# the object IS the RequestOutput instead of holding one.
_REQUEST_OUTPUT_CONTENT_ATTRS = (
"prompt",
"prompt_token_ids",
"prompt_logprobs",
"outputs",
"finished",
"lora_request",
"encoder_prompt",
"encoder_prompt_token_ids",
"num_cached_tokens",
"kv_transfer_params",
)
# Omni-specific content copied when the wrapped stage output is itself an
# OmniRequestOutput (e.g. a diffusion stage inside a multi-stage pipeline).
_OMNI_CONTENT_ATTRS = (
"images",
"latents",
"trajectory_latents",
"trajectory_timesteps",
"trajectory_log_probs",
"trajectory_decoded",
"_multimodal_output",
"_custom_output",
)
# Dynamic attributes that must not be copied verbatim onto the wrapper:
# they collide with properties defined on OmniRequestOutput.
_COPY_SKIP_ATTRS = frozenset({"request_output", "multimodal_output", "custom_output"})
@dataclass
class OmniRequestOutput:
class OmniRequestOutput(RequestOutput):
"""Unified request output for both pipeline stages and diffusion models.
Extends vLLM's ``RequestOutput`` so that omni outputs can flow directly
through vLLM serving codepaths (which expect ``prompt_token_ids``,
``outputs``, etc. as real attributes). The inherited fields store the
LLM generation content; omni-specific fields store pipeline/diffusion
extras.
Note: ``RequestOutput`` is a plain class (not a dataclass), so all of its
attributes are redeclared below as dataclass fields with defaults — the
dataclass-generated ``__init__`` replaces ``RequestOutput.__init__`` and
must set them itself.
This class handles outputs from:
1. Multi-stage LLM pipelines (with stage_id, final_output_type, request_output)
1. Multi-stage LLM pipelines (with stage_id, final_output_type, and the
inherited RequestOutput fields carrying the stage's generation content)
2. Diffusion models (with images, prompt, metrics)
Attributes:
@@ -72,30 +118,38 @@ class OmniRequestOutput:
finished: Whether generation is complete
stage_id: Identifier of the stage that produced this output (pipeline mode)
final_output_type: Type of output ("text", "image", "audio", "latents")
request_output: The underlying RequestOutput from the stage (pipeline mode)
images: List of generated PIL images (diffusion mode)
prompt: The prompt used for generation (diffusion mode)
prompt: The prompt used for generation
latents: Optional tensor of latent representations (diffusion mode)
metrics: Optional dictionary of generation metrics
metrics: Generation metrics. A plain dict for omni outputs; may carry
vLLM's request stats object when copied from a raw RequestOutput.
"""
# --- Inherited RequestOutput attributes (redeclared as fields) ---
request_id: str = ""
prompt: OmniPromptType | None = None
prompt_token_ids: list[int] | None = None
prompt_logprobs: Any = None
outputs: list[CompletionOutput] = field(default_factory=list)
finished: bool = True
metrics: Any = field(default_factory=dict)
lora_request: Any = None
encoder_prompt: str | None = None
encoder_prompt_token_ids: list[int] | None = None
num_cached_tokens: int | None = None
kv_transfer_params: dict[str, Any] | None = None
# Pipeline stage fields
# --- Pipeline stage fields ---
stage_id: int | None = None
final_output_type: str = "text"
request_output: RequestOutput | None = None
# Diffusion model fields
# --- Diffusion model fields ---
images: list[Image.Image] = field(default_factory=list)
prompt: OmniPromptType | None = None
latents: torch.Tensor | None = None
trajectory_latents: torch.Tensor | None = None
trajectory_timesteps: torch.Tensor | None = None
trajectory_log_probs: torch.Tensor | None = None
trajectory_decoded: list | None = None
metrics: dict[str, Any] = field(default_factory=dict)
_multimodal_output: dict[str, Any] = field(default_factory=dict)
_custom_output: dict[str, Any] = field(default_factory=dict)
@@ -110,6 +164,44 @@ class OmniRequestOutput:
error_status_code: int | None = None
error_type: str | None = None
def _copy_content_from(self, source: Any) -> None:
"""Copy generation content from a stage output into this object.
``source`` may be a vLLM ``RequestOutput``, another
``OmniRequestOutput``, or a duck-typed stand-in (tests use mocks and
SimpleNamespace). Attribute presence is checked against the source's
instance ``__dict__`` so that auto-created Mock attributes are not
copied. Control fields set by the wrapper (stage_id,
final_output_type, metrics, stage_durations, ...) are intentionally
not copied.
"""
source_vars = getattr(source, "__dict__", None)
def _has(name: str) -> bool:
if source_vars is not None:
return name in source_vars
return hasattr(source, name)
for name in _REQUEST_OUTPUT_CONTENT_ATTRS + _OMNI_CONTENT_ATTRS:
if _has(name):
setattr(self, name, getattr(source, name))
if not self.request_id:
self.request_id = getattr(source, "request_id", "") or ""
# Dynamic attribute that vLLM code attaches to RequestOutput; it is a
# read-only property on this class, so store it in _multimodal_output.
if not isinstance(source, OmniRequestOutput) and _has("multimodal_output"):
mm = source.multimodal_output
if mm is not None:
self._multimodal_output = mm
# Preserve remaining dynamic attributes (e.g. dynamically annotated
# stage outputs) so they stay reachable through `request_output`.
if source_vars:
known = type(self).__dataclass_fields__.keys()
for name, value in source_vars.items():
if name.startswith("_") or name in known or name in _COPY_SKIP_ATTRS:
continue
setattr(self, name, value)
@classmethod
def from_error(
cls,
@@ -143,7 +235,10 @@ class OmniRequestOutput:
final_output_type: str,
request_output: RequestOutput,
) -> "OmniRequestOutput":
"""Create output from pipeline stage.
"""Create output from a pipeline stage.
The stage output's content is copied into the new object's own
(inherited) fields.
Args:
stage_id: Stage identifier
@@ -153,13 +248,13 @@ class OmniRequestOutput:
Returns:
OmniRequestOutput configured for pipeline mode
"""
return cls(
request_id=getattr(request_output, "request_id", ""),
out = cls(
stage_id=stage_id,
final_output_type=final_output_type,
request_output=request_output,
finished=True,
)
out.finished = True
return out
@classmethod
def from_diffusion(
@@ -220,35 +315,21 @@ class OmniRequestOutput:
@property
def multimodal_output(self) -> Any:
"""Return multimodal output from the underlying request output or local field.
"""Return the multimodal output payload.
For pipeline outputs, this checks completion outputs first, then request_output.
For diffusion outputs, this returns the local _multimodal_output field.
Checks completion outputs first (where multimodal_output is attached
by AR stages), then the local _multimodal_output field.
Returns either a MultimodalPayload (Phase 3+) or a plain dict (legacy).
"""
if self.request_output is None:
return self._multimodal_output
# Check completion outputs first (where multimodal_output is attached)
for output in getattr(self.request_output, "outputs", []):
for output in self.outputs:
if mm := getattr(output, "multimodal_output", None):
return mm
if mm := getattr(self.request_output, "multimodal_output", None):
return mm
return self._multimodal_output
@property
def custom_output(self) -> dict[str, Any]:
"""Return custom output data from diffusion pipelines.
For diffusion outputs, returns the local _custom_output field.
For pipeline outputs with an inner OmniRequestOutput, forwards
the custom_output from the inner request output.
"""
if self.request_output is not None:
if isinstance(self.request_output, OmniRequestOutput):
return self.request_output._custom_output
"""Return custom output data from diffusion pipelines."""
return self._custom_output
@custom_output.setter
@@ -260,58 +341,6 @@ class OmniRequestOutput:
"""Return the number of generated images."""
return len(self.images)
# Pass-through properties keep vLLM serving codepaths compatible with
# OmniRequestOutput for pipeline outputs (Issue #345).
@property
def prompt_token_ids(self) -> list[int] | None:
"""Return prompt token IDs from the underlying request output.
This property is required for compatibility with vLLM's streaming
chat completion generator which checks res.prompt_token_ids.
"""
if self.request_output is not None:
return getattr(self.request_output, "prompt_token_ids", None)
return None
@property
def outputs(self) -> list[Any]:
"""Return outputs from the underlying request output.
This property is required for compatibility with vLLM's streaming
and non-streaming chat completion generators.
"""
if self.request_output is not None:
return getattr(self.request_output, "outputs", [])
return []
@property
def encoder_prompt_token_ids(self) -> list[int] | None:
"""Return encoder prompt token IDs from the underlying request output."""
if self.request_output is not None:
return getattr(self.request_output, "encoder_prompt_token_ids", None)
return None
@property
def prompt_logprobs(self) -> Any:
"""Return prompt logprobs from the underlying request output."""
if self.request_output is not None:
return getattr(self.request_output, "prompt_logprobs", None)
return None
@property
def num_cached_tokens(self) -> int | None:
"""Return number of cached tokens from the underlying request output."""
if self.request_output is not None:
return getattr(self.request_output, "num_cached_tokens", None)
return None
@property
def kv_transfer_params(self) -> Any:
"""Return KV transfer params from the underlying request output."""
if self.request_output is not None:
return getattr(self.request_output, "kv_transfer_params", None)
return None
@property
def is_diffusion_output(self) -> bool:
"""Check if this is a diffusion model output."""
@@ -320,60 +349,33 @@ class OmniRequestOutput:
@property
def is_pipeline_output(self) -> bool:
"""Check if this is a pipeline stage output."""
return self.stage_id is not None and self.request_output is not None
return self.stage_id is not None
def unwrap(self) -> "OmniRequestOutput":
"""Unwrap nested OmniRequestOutput to get the innermost result.
"""Return the output itself.
This helper handles the common pattern where pipeline outputs may wrap
other OmniRequestOutput instances. It recursively unwraps until it reaches
the final output with actual content (images, text, etc.).
Returns:
The innermost OmniRequestOutput containing the actual generation results.
Example:
```python
result = omni.generate(...)
output = OmniRequestOutput.unwrap_result(result)
if output.images:
# Access images directly
video_frames = output.images
```
Kept for backward compatibility: outputs used to nest a stage's
OmniRequestOutput inside ``request_output``; content is now flattened
onto the object itself, so there is nothing to unwrap.
"""
current = self
# Unwrap nested pipeline outputs
while current.is_pipeline_output and current.request_output is not None:
if isinstance(current.request_output, OmniRequestOutput):
current = current.request_output
else:
break
return current
return self
@staticmethod
def unwrap_result(result: Any) -> "OmniRequestOutput":
"""Unwrap result from omni.generate() to get the final OmniRequestOutput.
"""Extract the final OmniRequestOutput from omni.generate() results.
This static helper handles the full unwrapping pattern including:
Handles:
1. Extracting from list if needed
2. Type validation
3. Recursive unwrapping of nested pipeline outputs
Args:
result: The result from omni.generate() - may be a list or OmniRequestOutput
Returns:
The innermost OmniRequestOutput with actual content
The OmniRequestOutput with actual content
Raises:
ValueError: If result is not an OmniRequestOutput or list containing one
Example:
```python
result = omni.generate(...)
output = OmniRequestOutput.unwrap_result(result)
# output is guaranteed to be the final OmniRequestOutput
```
"""
# Handle list wrapper
if isinstance(result, list):
@@ -385,7 +387,6 @@ class OmniRequestOutput:
if not isinstance(result, OmniRequestOutput):
raise ValueError(f"Expected OmniRequestOutput, got {type(result)}")
# Unwrap nested outputs
return result.unwrap()
def to_dict(self) -> dict[str, Any]:
@@ -425,7 +426,8 @@ class OmniRequestOutput:
f"finished={self.finished}",
f"stage_id={self.stage_id}",
f"final_output_type={self.final_output_type!r}",
f"request_output={self.request_output}",
f"prompt_token_ids={self.prompt_token_ids}",
f"outputs={self.outputs}",
f"images={images_repr}",
f"prompt={self.prompt!r}",
f"latents={self.latents}",
@@ -437,3 +439,45 @@ class OmniRequestOutput:
]
return f"OmniRequestOutput({', '.join(parts)})"
def _request_output_get(self: OmniRequestOutput) -> RequestOutput | None:
"""Backward-compat view of the wrapped stage output.
The omni output now IS the RequestOutput, so pipeline/LLM outputs return
``self``; pure diffusion or error outputs return None (matching the old
composition semantics where ``request_output`` was unset for them).
"""
if self.stage_id is not None or self.outputs:
return self
return None
def _request_output_set(self: OmniRequestOutput, value: Any) -> None:
if value is not None:
self._copy_content_from(value)
# `request_output` is attached after class creation, NOT declared as a field
# or InitVar: msgspec serializes dataclasses natively via __dataclass_fields__
# (which includes InitVar pseudo-fields), so a request_output entry would be
# read through the property during encoding — returning `self` and recursing
# forever. As a plain property it is invisible to both dataclasses and
# msgspec.
OmniRequestOutput.request_output = property( # type: ignore[assignment]
_request_output_get, _request_output_set
)
# Keep `request_output=` working as a constructor keyword for
# backward compatibility: the wrapped stage output's content is copied onto
# the new object (see _copy_content_from).
_dataclass_init = OmniRequestOutput.__init__
def _omni_request_output_init(self: OmniRequestOutput, *args: Any, request_output: Any = None, **kwargs: Any) -> None:
_dataclass_init(self, *args, **kwargs)
if request_output is not None:
self._copy_content_from(request_output)
OmniRequestOutput.__init__ = _omni_request_output_init # type: ignore[method-assign]
@@ -76,10 +76,6 @@ class GPUGenerationModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin
def _update_request_states(self, scheduler_output: SchedulerOutput):
# remove requests
# Some stateful vocoder model may need to clean the state
# to avoid the leak of slots when the requests have been aborted.
if scheduler_output.finished_req_ids and hasattr(self.model, "on_requests_finished"):
self.model.on_requests_finished(scheduler_output.finished_req_ids)
for req_id in scheduler_output.finished_req_ids:
self.input_batch.remove_request(req_id)
scheduled_req_ids = scheduler_output.num_scheduled_tokens.keys()
@@ -157,6 +153,13 @@ class GPUGenerationModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin
if self.model_config.async_chunk and num_scheduled_tokens:
self._update_request_states(scheduler_output)
deferred_state_corrections_fn = self._update_states(scheduler_output)
# Notify stateful models of finished requests before the
# zero-token early return. Request cleanup is a model lifecycle
# concern and does not depend on the inter-stage transfer mode.
if scheduler_output.finished_req_ids and hasattr(self.model, "on_requests_finished"):
self.model.on_requests_finished(scheduler_output.finished_req_ids)
if not scheduler_output.total_num_scheduled_tokens:
return self.attach_omni_connector_output(EMPTY_MODEL_RUNNER_OUTPUT)