Merge branch 'main' into codex/fix-diffusion-allrank-rpc

This commit is contained in:
Hongsheng Liu authored and GitHub committed 2026-06-13 12:32:44 +08:00
commit 170f98c847
20 files changed
+1045 -208

No files matched your search

@@ -0,0 +1,101 @@
# SPDX-License-Identifier: Apache-2.0
"""Unit tests for the TTS serving adapter registry (RFC #4327).
Pure-Python registry/resolution logic; no model or GPU resources are loaded.
"""
import pytest
from vllm_omni.entrypoints.openai.tts_adapters import (
TTS_ADAPTER_REGISTRY,
ARTTSAdapter,
DiffusionTTSAdapter,
all_tts_model_types,
resolve_adapter,
)
from vllm_omni.entrypoints.openai.tts_adapters.qwen3_tts import Qwen3TTSAdapter
# Every dedicated TTS model-type must have an adapter so the orchestrator's
# uniform ``self._adapter.build(...)`` dispatch covers it.
EXPECTED_MODEL_TYPES = {
"qwen3_tts",
"voxcpm2",
"voxtral_tts",
"fish_tts",
"cosyvoice3",
"omnivoice",
"covo_audio",
"ming_tts",
"moss_tts_nano",
"moss_tts",
"higgs_audio_v2",
"higgs_audio_v3",
"glm_tts",
"step_audio2",
}
def test_all_model_types_registered():
assert EXPECTED_MODEL_TYPES <= all_tts_model_types()
def test_registry_keyed_by_name():
for name, cls in TTS_ADAPTER_REGISTRY.items():
assert cls.name == name
def test_resolve_each_model_type():
for model_type in EXPECTED_MODEL_TYPES:
cls = resolve_adapter(model_type)
assert cls is not None, model_type
assert cls.name == model_type
def test_resolve_qwen3_tts_class():
assert resolve_adapter("qwen3_tts") is Qwen3TTSAdapter
def test_resolve_unknown_returns_none():
assert resolve_adapter("not_a_real_model") is None
assert resolve_adapter(None) is None
def test_ming_flash_omni_not_migrated():
"""ming_flash_omni is intentionally excluded from the adapter migration in
this PR; it stays on the legacy inline dispatch in serving_speech.py."""
assert resolve_adapter("ming_flash_omni_tts") is None
def test_voxcpm2_resolves():
"""VoxCPM2 (the served ``latent_generator`` model) resolves cleanly.
Detection never returns the legacy ``voxcpm`` type, so there is no shared
stage-key ambiguity to resolve.
"""
assert resolve_adapter("voxcpm2") is not None
assert resolve_adapter("voxcpm") is None
def test_all_adapters_are_ar_or_diffusion():
for cls in TTS_ADAPTER_REGISTRY.values():
assert issubclass(cls, (ARTTSAdapter, DiffusionTTSAdapter))
assert cls.backend in ("ar", "diffusion")
def test_qwen3_tts_metadata():
assert Qwen3TTSAdapter.backend == "ar"
assert issubclass(Qwen3TTSAdapter, ARTTSAdapter)
def test_diffusion_adapter_extra_body_params_fallback():
class _DiffAdapter(DiffusionTTSAdapter):
name = "diff_probe"
async def build(self, request, sampling_params_list): # pragma: no cover
raise NotImplementedError
assert _DiffAdapter.extra_body_params() == frozenset()
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+18 -2
View File
@@ -520,9 +520,25 @@ def assert_omni_response(response: Any, request_config: dict[str, Any], run_leve
def assert_audio_speech_response(response: Any, request_config: dict[str, Any], run_level: str) -> None:
"""Validate speech API results from :class:`~tests.helpers.runtime.OmniResponse`.
Success path only. Use :meth:`OpenAIClientHandler.send_audio_speech_http_request` with ``err_code`` /
``err_message`` for expected HTTP errors.
When ``request_config`` carries ``status_code`` and/or ``err_message``, the
request is expected to be rejected: assert it failed and that the HTTP status
/ error text match. Otherwise the normal success-path checks run.
"""
expected_status = request_config.get("status_code")
expected_err = request_config.get("err_message")
if expected_status is not None or expected_err is not None:
assert not response.success, "Expected an error response, but the request succeeded."
if expected_status is not None:
allowed = expected_status if isinstance(expected_status, (list, tuple)) else (expected_status,)
assert response.status_code in allowed, f"Expected HTTP status in {allowed}, got {response.status_code}"
if expected_err is not None:
alternatives = expected_err if isinstance(expected_err, (list, tuple)) else (expected_err,)
error_text = response.error_message or ""
assert any(alt in error_text for alt in alternatives), (
f"Expected one of {alternatives} in error text, got: {error_text!r}"
)
return
assert response.success, "The request failed."
# Optional floor on decoded audio size (models with very short clips may use a lower value).
+4
View File
@@ -762,6 +762,10 @@ class OmniResponse:
prompt_tokens: int | None = None
cached_tokens: int | None = None
logprobs: list | None = None
#: HTTP status + error text for the error-handling path (e.g. validator
#: rejections); populated when the OpenAI client raises an APIError.
status_code: int | None = None
error_message: str | None = None
@dataclass
+100 -196
View File
@@ -44,6 +44,10 @@ from vllm_omni.entrypoints.openai.protocol.audio import (
SpeechBatchItem,
SpeechBatchItemResult,
)
from vllm_omni.entrypoints.openai.tts_adapters import (
SpeechServingContext,
resolve_adapter,
)
from vllm_omni.entrypoints.utils import coerce_param_message_types
from vllm_omni.model_executor.models.fish_speech.prompt_utils import (
build_fish_text_only_prompt_ids,
@@ -474,6 +478,36 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
self._build_fish_speech_prompt_async = make_async(self._build_fish_speech_prompt, executor=self._tts_executor)
self._estimate_prompt_len_async = make_async(self._estimate_prompt_len, executor=self._tts_executor)
# Resolve the per-model serving adapter (RFC #4327), keyed on the
# detected model-type. Every dedicated TTS model has an adapter; the
# adapter owns request validation + prompt/param building. Sampling
# overrides and the model-type label remain in the orchestrator tail
# (keyed on ``_tts_model_type``) during this incremental migration.
self._adapter = None
if self._tts_stage is not None:
adapter_cls = resolve_adapter(self._tts_model_type)
if adapter_cls is not None:
ctx = SpeechServingContext(server=self, engine_client=self.engine_client)
self._adapter = adapter_cls(ctx)
logger.info("Resolved TTS serving adapter: %s", adapter_cls.__name__)
def _get_tts_adapter(self):
"""Return the per-model serving adapter for the current ``_tts_model_type``.
Resolved lazily (rebuilt if ``_tts_model_type`` changed since the cached
instance was built) so callers that set ``_tts_model_type`` after
construction still dispatch to the matching adapter. In production
``_tts_model_type`` is fixed at init, so the cached instance is reused.
"""
adapter_cls = resolve_adapter(self._tts_model_type)
if adapter_cls is None:
self._adapter = None
return None
if self._adapter is None or type(self._adapter) is not adapter_cls:
ctx = SpeechServingContext(server=self, engine_client=self.engine_client)
self._adapter = adapter_cls(ctx)
return self._adapter
async def warmup(self) -> None:
"""Run a synthetic speech request to trigger all first-request warmup.
@@ -3212,6 +3246,44 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
# ---- Common speech generation helpers ----
async def _build_qwen3_tts_request(
self,
request: OpenAICreateSpeechRequest,
) -> tuple[dict[str, Any], dict[str, Any], str | None]:
"""Build prompt + tts_params for Qwen3-TTS.
Called from ``Qwen3TTSAdapter.build``. Returns
``(prompt, tts_params, warmup_artifact_key)`` where the warmup key is the
Qwen3-TTS ref-audio artifact tracked after ``generate()``.
"""
qwen3_ref_audio_warmup_artifact_key: str | None = None
tts_params = self._build_tts_params(request)
# Resolve ref_audio (explicit or auto-set for uploaded voices)
# to [[wav_list, sr]] so the model doesn't re-decode base64.
ref_audio_source = request.ref_audio
if ref_audio_source is None and isinstance(tts_params.get("ref_audio"), list):
# Uploaded voice: ref_audio was auto-set as [base64_data_url]
ref_audio_source = tts_params["ref_audio"][0]
if ref_audio_source is not None and isinstance(ref_audio_source, str):
wav_list, sr = await self._resolve_ref_audio(ref_audio_source)
artifact_key = self._get_resolved_ref_audio_artifact_key(ref_audio_source)
if artifact_key:
tts_params[_QWEN3_TTS_REF_AUDIO_CACHE_KEY] = [artifact_key]
ref_code_length = self._estimate_ref_code_len([wav_list, sr])
if ref_code_length is not None:
tts_params["ref_code_length"] = [int(ref_code_length)]
if self._qwen3_tts_can_use_ref_audio_artifact_only(tts_params, artifact_key):
logger.debug("Using Qwen3-TTS ref_audio artifact-only path: %s", artifact_key)
else:
tts_params["ref_audio"] = [[wav_list, sr]]
qwen3_ref_audio_warmup_artifact_key = artifact_key
ph_len = await self._estimate_prompt_len_async(tts_params)
prompt = tokens_input(prompt_token_ids=[1] * ph_len)
prompt["additional_information"] = tts_params
prompt["cache_salt"] = _conditioning_cache_salt(request, tts_params)
return prompt, tts_params, qwen3_ref_audio_warmup_artifact_key
async def _prepare_speech_generation(
self,
request: OpenAICreateSpeechRequest,
@@ -3230,208 +3302,35 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
sampling_params_list = list(self.engine_client.default_sampling_params_list)
sampling_params_list = coerce_param_message_types(sampling_params_list, request.stream)
# Resolve uploaded voice for non-Qwen3 models.
# Qwen3 TTS has its own uploaded voice handling in _build_tts_params().
# Build prompt + tts_params via the per-model adapter (RFC #4327). Every
# dedicated TTS model resolves to an adapter that owns its validation,
# uploaded-speaker handling, and prompt/param building. Sampling
# overrides and the model-type label remain in the orchestrator tail
# below (keyed on ``_tts_model_type``) during this incremental migration.
# Non-TTS deployments (no adapter) fall through to the rejection below.
# Capture inline-ref-audio status BEFORE validate(): several adapters
# apply uploaded speakers inside validate(), which sets request.ref_audio
# in place. The builders need to know whether the caller supplied audio
# inline vs. via an uploaded voice.
has_inline_ref_audio = request.ref_audio is not None
if self._tts_model_type in ("fish_tts", "cosyvoice3", "moss_tts_nano", "glm_tts"):
err = self._apply_uploaded_speaker(request)
if err:
raise ValueError(err)
if self._tts_model_type == "step_audio2":
if not request.input or not request.input.strip():
raise ValueError("Input text cannot be empty")
prompt = self._build_step_audio2_prompt(request)
tts_params = {}
elif self._is_fish_speech:
validation_error = self._validate_fish_tts_request(request)
if self._tts_model_type == "ming_flash_omni_tts":
# ming_flash_omni is intentionally NOT migrated onto the adapter
# framework in this PR (it has no registered adapter); keep it on the
# legacy inline dispatch so serving still works.
validation_error = self._validate_ming_flash_omni_tts_request(request)
if validation_error:
raise ValueError(validation_error)
ref_audio_data = None
if request.ref_audio is not None:
wav_list, sr = await self._resolve_ref_audio(request.ref_audio)
ref_audio_data = (wav_list, sr)
prompt = await self._build_fish_speech_prompt_async(
request, ref_audio_data=ref_audio_data, has_inline_ref_audio=has_inline_ref_audio
)
prompt = self._build_ming_flash_omni_prompt(request)
tts_params = {}
# Fish Speech resolves conditioning entirely from raw request fields
# (text / ref_audio / ref_text rebuilt into inputs_embeds); unlike
# uploaded-voice models it has no resolved tts_params to fold in, so
# the salt is derived from the request alone.
prompt["cache_salt"] = _conditioning_cache_salt(request, tts_params)
elif self._tts_model_type == "omnivoice":
if not request.input or not request.input.strip():
raise ValueError("Input text cannot be empty")
err = self._apply_uploaded_speaker(request)
if err:
raise ValueError(err)
tts_params = {}
prompt: dict[str, Any] = {"input": request.input}
if request.ref_audio:
wav, sr = await self._resolve_ref_audio(request.ref_audio)
prompt["ref_audio"] = (np.asarray(wav, dtype=np.float32), sr)
if request.ref_text:
prompt["ref_text"] = request.ref_text
if request.voice:
voice_lower = request.voice.lower()
if voice_lower in self.uploaded_speakers and not has_inline_ref_audio:
prompt["voice_name"] = voice_lower
prompt["voice_created_at"] = self._voice_created_at(voice_lower)
if request.language:
prompt["lang"] = request.language
if request.instructions:
prompt["instruct"] = request.instructions
elif self._tts_model_type == "covo_audio":
prompt = self._build_covo_audio_prompt(request)
tts_params = {}
elif self._tts_model_type == "higgs_audio_v2":
# Explicit higgs_audio_v2 branch: do NOT fall through to the
# Qwen-style generic placeholder path; build prompt_token_ids
# directly via the upstream processor + the project's plain-text
# helper.
#
# Resolve uploaded voices first so a voice=<name> request (after
# POST /v1/audio/voices) populates ref_audio + ref_text from the
# cached speaker entry, mirroring the cosyvoice3 / voxcpm2 flow.
err = self._apply_uploaded_speaker(request)
if err:
raise ValueError(err)
validation_error = self._validate_higgs_audio_v2_request(request)
qwen3_ref_audio_warmup_artifact_key = None
elif (adapter := self._get_tts_adapter()) is not None:
validation_error = adapter.validate(request)
if validation_error:
raise ValueError(validation_error)
prompt = await self._build_higgs_audio_v2_params(request)
if request.voice:
voice_lower = request.voice.lower()
if voice_lower in self.uploaded_speakers and not has_inline_ref_audio:
additional = prompt.setdefault("additional_information", {})
additional["voice_name"] = voice_lower
additional["voice_created_at"] = self._voice_created_at(voice_lower)
tts_params = {}
elif self._tts_model_type == "higgs_audio_v3":
err = self._apply_uploaded_speaker(request)
if err:
raise ValueError(err)
validation_error = self._validate_higgs_audio_v3_request(request)
if validation_error:
raise ValueError(validation_error)
prompt = await self._build_higgs_audio_v3_params(request)
tts_params = {}
elif self._tts_model_type == "voxcpm2":
# voxcpm2 doesn't use `_apply_uploaded_speaker` because the prompt builder needs the
# raw waveform tuple for prefill-length accounting, not a base64 data URL.
validation_error = self._validate_voxcpm2_request(request)
if validation_error:
raise ValueError(validation_error)
uploaded_ref: tuple[np.ndarray, int] | None = None
if request.voice:
voice_lower = request.voice.lower()
if voice_lower in self.uploaded_speakers and not has_inline_ref_audio:
if self.uploaded_speakers[voice_lower].get("embedding_source") == "direct":
raise ValueError(
f"Uploaded voice '{request.voice}' uses a speaker embedding (Qwen3-only). "
f"Re-upload with an audio file for VoxCPM2."
)
if request.ref_audio is None:
uploaded_ref = self._load_uploaded_audio(voice_lower)
prompt = await self._build_voxcpm2_prompt(request, uploaded_ref=uploaded_ref)
tts_params = {}
if request.voice:
voice_lower = request.voice.lower()
if voice_lower in self.uploaded_speakers or voice_lower in self.precomputed_speakers:
additional = prompt.setdefault("additional_information", {})
additional["voice_name"] = voice_lower
additional["voice_created_at"] = self._voice_created_at(voice_lower)
elif self._is_tts:
validation_error = self._validate_tts_request(request)
if validation_error:
raise ValueError(validation_error)
if self._tts_model_type == "voxtral_tts":
prompt = await self._build_voxtral_prompt_async(request)
tts_params = {}
elif self._tts_model_type == "cosyvoice3":
prompt = await self._build_cosyvoice3_prompt(request, has_inline_ref_audio=has_inline_ref_audio)
tts_params = {}
elif self._tts_model_type == "glm_tts":
prompt = await self._build_glm_tts_prompt(request, has_inline_ref_audio=has_inline_ref_audio)
tts_params = {}
elif self._tts_model_type == "ming_flash_omni_tts":
prompt = self._build_ming_flash_omni_prompt(request)
tts_params = {}
elif self._tts_model_type == "ming_tts":
ref_audio_source = request.ref_audio
voice_lower = request.voice.lower() if isinstance(request.voice, str) else None
if ref_audio_source is None and voice_lower in self.uploaded_speakers:
ref_audio_source = self._get_uploaded_audio_data(request.voice)
if request.ref_text is None:
request.ref_text = self.uploaded_speakers[voice_lower].get("ref_text")
ref_audio_data = None
if isinstance(ref_audio_source, list):
ref_audio_data = await self._resolve_ref_audio_many(ref_audio_source)
if request.speaker_embedding is None:
request.speaker_embedding = self._extract_ming_speaker_embeddings_from_ref_audio(ref_audio_data)
elif ref_audio_source is not None and isinstance(ref_audio_source, str):
wav_list, sr = await self._resolve_ref_audio(ref_audio_source)
ref_audio_data = (wav_list, sr)
if request.speaker_embedding is None:
request.speaker_embedding = self._extract_ming_speaker_embeddings_from_ref_audio(
[ref_audio_data]
)[0]
prompt = self._build_ming_dense_prompt(request, ref_audio_data=ref_audio_data)
tts_params = prompt.get("additional_information", {})
elif self._tts_model_type in ("moss_tts_nano", "moss_tts"):
tts_params = await self._build_moss_tts_params(request)
if request.voice:
voice_lower = request.voice.lower()
if voice_lower in self.uploaded_speakers and not has_inline_ref_audio:
tts_params["voice_name"] = [voice_lower]
tts_params["voice_created_at"] = [self._voice_created_at(voice_lower)]
# Propagate seed from sampling params for deterministic generation.
# MOSS-TTS-Nano uses its own internal sampling inside
# inference_stream(), which reads seed from additional_information
# (not SamplingParams). Without this the model uses ambient RNG
# state and produces non-deterministic output.
if sampling_params_list and getattr(sampling_params_list[0], "seed", None) is not None:
tts_params["seed"] = [sampling_params_list[0].seed]
# Full MOSS-TTS family returns real text_ids from the upstream
# processor; use them as prompt_token_ids. Nano keeps the
# ``[1]`` placeholder (it owns its own tokenisation internally).
if isinstance(tts_params.get("prompt_token_ids"), list):
prompt_token_ids = tts_params.pop("prompt_token_ids")
prompt = tokens_input(prompt_token_ids=prompt_token_ids)
else:
prompt = tokens_input(prompt_token_ids=[1])
prompt["additional_information"] = tts_params
prompt["cache_salt"] = _conditioning_cache_salt(request, tts_params)
else:
tts_params = self._build_tts_params(request)
# Resolve ref_audio (explicit or auto-set for uploaded voices)
# to [[wav_list, sr]] so the model doesn't re-decode base64.
ref_audio_source = request.ref_audio
if ref_audio_source is None and isinstance(tts_params.get("ref_audio"), list):
# Uploaded voice: ref_audio was auto-set as [base64_data_url]
ref_audio_source = tts_params["ref_audio"][0]
if ref_audio_source is not None and isinstance(ref_audio_source, str):
wav_list, sr = await self._resolve_ref_audio(ref_audio_source)
artifact_key = self._get_resolved_ref_audio_artifact_key(ref_audio_source)
if self._tts_model_type == "qwen3_tts" and artifact_key:
tts_params[_QWEN3_TTS_REF_AUDIO_CACHE_KEY] = [artifact_key]
ref_code_length = self._estimate_ref_code_len([wav_list, sr])
if self._tts_model_type == "qwen3_tts" and ref_code_length is not None:
tts_params["ref_code_length"] = [int(ref_code_length)]
if self._qwen3_tts_can_use_ref_audio_artifact_only(tts_params, artifact_key):
logger.debug("Using Qwen3-TTS ref_audio artifact-only path: %s", artifact_key)
else:
tts_params["ref_audio"] = [[wav_list, sr]]
if self._tts_model_type == "qwen3_tts":
qwen3_ref_audio_warmup_artifact_key = artifact_key
ph_len = await self._estimate_prompt_len_async(tts_params)
prompt = tokens_input(prompt_token_ids=[1] * ph_len)
prompt["additional_information"] = tts_params
prompt["cache_salt"] = _conditioning_cache_salt(request, tts_params)
prepared = await adapter.build(request, sampling_params_list, has_inline_ref_audio)
prompt = prepared.prompt
tts_params = prepared.tts_params
qwen3_ref_audio_warmup_artifact_key = prepared.warmup_artifact_key
else:
# Qwen omni models (Qwen3-Omni, Qwen2.5-Omni) use a "talker"
# stage whose preprocess requires chat-templated tokens. The
@@ -4047,6 +3946,11 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
merged_requests = [self._merge_batch_item(batch_request, item) for item in batch_request.items]
async def _run_item(idx: int, req: OpenAICreateSpeechRequest) -> SpeechBatchItemResult:
# Batch validation still goes through _validate_tts_request directly
# (not the adapter). The single-request path validates via the
# adapter; both ultimately call the same per-model validators, so the
# surfaces stay in sync. Routing batch through the adapter (and its
# uploaded-speaker handling) is a follow-up (RFC #4327).
validation_error = self._validate_tts_request(req)
if validation_error is not None:
return SpeechBatchItemResult(index=idx, status="error", error=validation_error)
@@ -0,0 +1,80 @@
# SPDX-License-Identifier: Apache-2.0
"""Registry of TTS serving adapters.
Adapters register themselves by their ``name`` (the model-type discriminator
returned by ``OmniOpenAIServingSpeech._detect_tts_model_type``) via
``@register_tts_adapter``. Resolution is by that name: detection already encodes
the (order-sensitive, partly ``model_arch``-based) stage logic, so keying the
registry on the resolved type is both simpler and correct by construction.
``stage_keys`` is retained on each adapter as documentation metadata.
"""
from vllm.logger import init_logger
from vllm_omni.entrypoints.openai.tts_adapters.base import (
ARTTSAdapter,
DiffusionTTSAdapter,
OutputPolicy,
PreparedRequest,
SpeechServingContext,
TTSModelAdapter,
)
logger = init_logger(__name__)
TTS_ADAPTER_REGISTRY: dict[str, type[TTSModelAdapter]] = {}
def register_tts_adapter(cls: type[TTSModelAdapter]) -> type[TTSModelAdapter]:
"""Class decorator: index ``cls`` under its ``name`` (model-type)."""
if cls.name in TTS_ADAPTER_REGISTRY:
raise ValueError(
f"TTS adapter name {cls.name!r} already registered to "
f"{TTS_ADAPTER_REGISTRY[cls.name].__qualname__}; {cls.__qualname__} conflicts."
)
TTS_ADAPTER_REGISTRY[cls.name] = cls
return cls
def all_tts_model_types() -> frozenset[str]:
"""All registered model-type names."""
return frozenset(TTS_ADAPTER_REGISTRY)
def resolve_adapter(model_type: str | None) -> type[TTSModelAdapter] | None:
"""Return the adapter for a detected model-type, or ``None``."""
if model_type is None:
return None
return TTS_ADAPTER_REGISTRY.get(model_type)
# Import adapter modules for their registration side effects. Keep at the bottom
# so the registry helpers above are defined first.
from vllm_omni.entrypoints.openai.tts_adapters import ( # noqa: E402,F401
cosyvoice3,
covo_audio,
fish_speech,
glm_tts,
higgs_audio_v2,
higgs_audio_v3,
ming_tts,
moss_tts,
omnivoice,
qwen3_tts,
step_audio2,
voxcpm2,
voxtral,
)
__all__ = [
"ARTTSAdapter",
"DiffusionTTSAdapter",
"OutputPolicy",
"PreparedRequest",
"SpeechServingContext",
"TTSModelAdapter",
"TTS_ADAPTER_REGISTRY",
"all_tts_model_types",
"register_tts_adapter",
"resolve_adapter",
]
@@ -0,0 +1,199 @@
# SPDX-License-Identifier: Apache-2.0
"""Base contract for per-model TTS serving adapters.
This package factors the per-model ``if self._tts_model_type == ...`` dispatch
in ``serving_speech.py`` into one adapter class per model. Each adapter owns its
model's request normalization, validation, prompt/param building, sampling
overrides, and output policy, so adding a model means writing one adapter file
instead of editing the shared serving module in ~10 scattered places.
See the RFC for the full design (issue #4327). This is the foundation landed in
the first migration PR; Qwen3-TTS is the first model routed through it while the
remaining models stay on the legacy path until individually migrated.
"""
from abc import ABC, abstractmethod
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, ClassVar
if TYPE_CHECKING:
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
_conditioning_cache_salt_fn: "Callable[..., str] | None" = None
def conditioning_cache_salt(request: "OpenAICreateSpeechRequest", tts_params: dict) -> str:
"""Return the conditioning cache salt for ``request`` + ``tts_params``.
Lazily imports and caches ``serving_speech._conditioning_cache_salt`` on first
use: the import is deferred to break the adapters<->serving_speech import
cycle, and cached so it resolves once instead of on every ``build()`` call.
"""
global _conditioning_cache_salt_fn
if _conditioning_cache_salt_fn is None:
from vllm_omni.entrypoints.openai.serving_speech import _conditioning_cache_salt
_conditioning_cache_salt_fn = _conditioning_cache_salt
return _conditioning_cache_salt_fn(request, tts_params)
@dataclass
class OutputPolicy:
"""How the orchestrator aggregates engine output for a model.
``accumulate_nonstreaming`` enables MOSS-style cross-step accumulation in the
non-streaming path. Streaming cumulative/delta semantics stay engine-side,
keyed by request id, and are unaffected by this flag.
"""
accumulate_nonstreaming: bool = False
@dataclass
class PreparedRequest:
"""Everything the generic orchestrator needs to call ``<engine>.generate()``.
The fields mirror what ``_prepare_speech_generation`` assembled inline:
``prompt`` is the engine prompt dict, ``tts_params`` the per-model parameter
dict, ``model_type`` the discriminator used for logging, and
``output_policy`` controls non-streaming aggregation.
"""
prompt: dict[str, Any]
tts_params: dict[str, Any] = field(default_factory=dict)
model_type: str = "generic"
output_policy: OutputPolicy = field(default_factory=OutputPolicy)
#: Cross-cutting per-request state the orchestrator still owns (e.g. the
#: Qwen3-TTS ref-audio warmup artifact key tracked after ``generate()``).
warmup_artifact_key: str | None = None
@dataclass
class SpeechServingContext:
"""Shared state and helpers an adapter may use.
During the incremental migration this holds a back-reference to the owning
``OmniOpenAIServingSpeech`` instance (``server``) so adapters can reuse the
existing, battle-tested helper implementations (ref-audio resolution,
uploaded-speaker handling, prompt-length estimation, speaker storage)
without relocating them. Follow-up PRs may narrow this to explicit fields as
more models migrate. Exactly one of ``engine_client`` / ``diffusion_engine``
is set, matching the deployment's serving backend.
"""
server: Any
engine_client: Any | None = None
diffusion_engine: Any | None = None
class TTSModelAdapter(ABC):
"""Mandatory base class for a TTS model served via ``/v1/audio/speech``.
One concrete subclass per model, registered by stage key. The serving layer
resolves exactly one adapter at startup and dispatches all per-model
decisions to it. Adapters reuse shared helpers through ``ctx`` rather than
re-implementing them.
"""
#: Stable discriminator string (the model-type from detection); registry key.
name: ClassVar[str]
#: Engine ``model_stage`` key(s) this model uses, for documentation only.
stage_keys: ClassVar[frozenset[str]] = frozenset()
#: Serving backend: ``"ar"`` (engine_client) or ``"diffusion"``.
backend: ClassVar[str] = "ar"
def __init__(self, ctx: SpeechServingContext) -> None:
self.ctx = ctx
def normalize(self, request: "OpenAICreateSpeechRequest") -> None:
"""In-place request normalization/mutation (e.g. infer task type,
lowercase voice). Default: no-op."""
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
"""Return an error string, or ``None`` if the request is valid.
Should be free of new side effects beyond what ``normalize`` did.
"""
return None
@abstractmethod
async def build(
self,
request: "OpenAICreateSpeechRequest",
sampling_params_list: list,
has_inline_ref_audio: bool,
) -> PreparedRequest:
"""Build the engine prompt + tts_params for this request.
``sampling_params_list`` is passed read-only for models (e.g. MOSS) that
fold the resolved seed into ``additional_information`` at build time.
``has_inline_ref_audio`` is captured by the orchestrator *before*
``validate()`` runs, because ``_apply_uploaded_speaker`` (invoked inside
several adapters' ``validate``) sets ``request.ref_audio`` in place.
Recomputing it here would misclassify uploaded voices as inline and drop
the ``voice_name`` / ``voice_created_at`` metadata.
"""
def apply_sampling_overrides(
self,
sampling_params_list: list,
request: "OpenAICreateSpeechRequest",
) -> list:
"""Apply model-specific sampling mutations.
The orchestrator guarantees the call order:
stream-coercion -> extra_params -> THIS -> seed. Default: identity.
"""
return sampling_params_list
class ARTTSAdapter(TTSModelAdapter):
"""Adapter for models served through the AR ``engine_client`` path.
Covers pure-AR codec models as well as AR-base-LM + diffusion-side-computation
hybrids (e.g. VoxCPM2, Ming) whose internal diffusion is invisible to the
serving layer.
"""
backend: ClassVar[str] = "ar"
class DiffusionTTSAdapter(TTSModelAdapter):
"""Adapter for pure-diffusion pipelines served through the diffusion engine.
In scope today: OmniVoice. Bridges to the diffusion pipeline parameter
contract (#3572) when present; see ``extra_body_params``.
"""
backend: ClassVar[str] = "diffusion"
#: Backing diffusion pipeline class (for EXTRA_BODY_PARAMS lookup).
pipeline_cls: ClassVar[type | None] = None
@classmethod
def extra_body_params(cls) -> frozenset[str]:
"""Fallback-safe access to the pipeline's declared body params.
Returns the pipeline's ``EXTRA_BODY_PARAMS`` if the #3572 contract is
present, else an empty frozenset (the adapter then uses its own inline
parameter logic).
"""
params = getattr(cls.pipeline_cls, "EXTRA_BODY_PARAMS", None)
return frozenset(params) if params is not None else frozenset()
# Re-exported here to avoid import cycles at call sites.
__all__ = [
"ARTTSAdapter",
"DiffusionTTSAdapter",
"OutputPolicy",
"PreparedRequest",
"SpeechServingContext",
"TTSModelAdapter",
]
# Type alias kept for readability in adapter signatures.
NormalizeFn = Callable[["OpenAICreateSpeechRequest"], None]
@@ -0,0 +1,30 @@
# SPDX-License-Identifier: Apache-2.0
"""CosyVoice3 TTS serving adapter."""
from typing import TYPE_CHECKING
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
if TYPE_CHECKING:
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
@register_tts_adapter
class CosyVoice3Adapter(ARTTSAdapter):
stage_keys = frozenset({"cosyvoice3_talker"})
name = "cosyvoice3"
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
err = self.ctx.server._apply_uploaded_speaker(request)
if err:
return err
return self.ctx.server._validate_cosyvoice3_request(request)
async def build(
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
) -> PreparedRequest:
prompt = await self.ctx.server._build_cosyvoice3_prompt(request, has_inline_ref_audio=has_inline_ref_audio)
# NOTE: CosyVoice3 dynamic-token sampling stays in the orchestrator tail
# (keyed on _tts_model_type) during this incremental migration.
return PreparedRequest(prompt=prompt, tts_params={}, model_type="cosyvoice3")
@@ -0,0 +1,22 @@
# SPDX-License-Identifier: Apache-2.0
"""CoVo-Audio serving adapter."""
from typing import TYPE_CHECKING
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
if TYPE_CHECKING:
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
@register_tts_adapter
class CovoAudioAdapter(ARTTSAdapter):
stage_keys = frozenset({"fused_thinker_talker"})
name = "covo_audio"
async def build(
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
) -> PreparedRequest:
prompt = self.ctx.server._build_covo_audio_prompt(request)
return PreparedRequest(prompt=prompt, tts_params={}, model_type="covo_audio")
@@ -0,0 +1,37 @@
# SPDX-License-Identifier: Apache-2.0
"""Fish Speech serving adapter (retires the legacy ``_is_fish_speech`` flag)."""
from typing import TYPE_CHECKING
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest, conditioning_cache_salt
if TYPE_CHECKING:
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
@register_tts_adapter
class FishSpeechAdapter(ARTTSAdapter):
stage_keys = frozenset({"fish_speech_slow_ar"})
name = "fish_tts"
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
err = self.ctx.server._apply_uploaded_speaker(request)
if err:
return err
return self.ctx.server._validate_fish_tts_request(request)
async def build(
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
) -> PreparedRequest:
server = self.ctx.server
ref_audio_data = None
if request.ref_audio is not None:
wav_list, sr = await server._resolve_ref_audio(request.ref_audio)
ref_audio_data = (wav_list, sr)
prompt = await server._build_fish_speech_prompt_async(
request, ref_audio_data=ref_audio_data, has_inline_ref_audio=has_inline_ref_audio
)
tts_params = {}
prompt["cache_salt"] = conditioning_cache_salt(request, tts_params)
return PreparedRequest(prompt=prompt, tts_params=tts_params, model_type="fish_speech")
@@ -0,0 +1,30 @@
# SPDX-License-Identifier: Apache-2.0
"""GLM-TTS serving adapter."""
from typing import TYPE_CHECKING
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
if TYPE_CHECKING:
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
@register_tts_adapter
class GlmTTSAdapter(ARTTSAdapter):
stage_keys = frozenset({"glm_tts"})
name = "glm_tts"
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
err = self.ctx.server._apply_uploaded_speaker(request)
if err:
return err
return self.ctx.server._validate_glm_tts_request(request)
async def build(
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
) -> PreparedRequest:
prompt = await self.ctx.server._build_glm_tts_prompt(request, has_inline_ref_audio=has_inline_ref_audio)
# GLM-TTS dynamic-token sampling stays in the orchestrator tail
# (keyed on _tts_model_type) during this incremental migration.
return PreparedRequest(prompt=prompt, tts_params={}, model_type="glm_tts")
@@ -0,0 +1,35 @@
# SPDX-License-Identifier: Apache-2.0
"""Higgs-Audio v2 serving adapter."""
from typing import TYPE_CHECKING
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
if TYPE_CHECKING:
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
@register_tts_adapter
class HiggsAudioV2Adapter(ARTTSAdapter):
stage_keys = frozenset({"higgs_audio_v2"})
name = "higgs_audio_v2"
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
err = self.ctx.server._apply_uploaded_speaker(request)
if err:
return err
return self.ctx.server._validate_higgs_audio_v2_request(request)
async def build(
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
) -> PreparedRequest:
server = self.ctx.server
prompt = await server._build_higgs_audio_v2_params(request)
if request.voice:
voice_lower = request.voice.lower()
if voice_lower in server.uploaded_speakers and not has_inline_ref_audio:
additional = prompt.setdefault("additional_information", {})
additional["voice_name"] = voice_lower
additional["voice_created_at"] = server._voice_created_at(voice_lower)
return PreparedRequest(prompt=prompt, tts_params={}, model_type="higgs_audio_v2")
@@ -0,0 +1,28 @@
# SPDX-License-Identifier: Apache-2.0
"""Higgs-Audio v3 serving adapter."""
from typing import TYPE_CHECKING
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
if TYPE_CHECKING:
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
@register_tts_adapter
class HiggsAudioV3Adapter(ARTTSAdapter):
stage_keys = frozenset({"higgs_audio_v3"})
name = "higgs_audio_v3"
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
err = self.ctx.server._apply_uploaded_speaker(request)
if err:
return err
return self.ctx.server._validate_higgs_audio_v3_request(request)
async def build(
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
) -> PreparedRequest:
prompt = await self.ctx.server._build_higgs_audio_v3_params(request)
return PreparedRequest(prompt=prompt, tts_params={}, model_type="higgs_audio_v3")
@@ -0,0 +1,44 @@
# SPDX-License-Identifier: Apache-2.0
"""Ming-TTS (dense) serving adapter."""
from typing import TYPE_CHECKING
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
if TYPE_CHECKING:
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
@register_tts_adapter
class MingTTSAdapter(ARTTSAdapter):
# Detected by model_arch (MingTTSForConditionalGeneration), not stage key.
name = "ming_tts"
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
return self.ctx.server._validate_ming_tts_request(request)
async def build(
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
) -> PreparedRequest:
server = self.ctx.server
ref_audio_source = request.ref_audio
voice_lower = request.voice.lower() if isinstance(request.voice, str) else None
if ref_audio_source is None and voice_lower in server.uploaded_speakers:
ref_audio_source = server._get_uploaded_audio_data(request.voice)
if request.ref_text is None:
request.ref_text = server.uploaded_speakers[voice_lower].get("ref_text")
ref_audio_data = None
if isinstance(ref_audio_source, list):
ref_audio_data = await server._resolve_ref_audio_many(ref_audio_source)
if request.speaker_embedding is None:
request.speaker_embedding = server._extract_ming_speaker_embeddings_from_ref_audio(ref_audio_data)
elif ref_audio_source is not None and isinstance(ref_audio_source, str):
wav_list, sr = await server._resolve_ref_audio(ref_audio_source)
ref_audio_data = (wav_list, sr)
if request.speaker_embedding is None:
request.speaker_embedding = server._extract_ming_speaker_embeddings_from_ref_audio([ref_audio_data])[0]
prompt = server._build_ming_dense_prompt(request, ref_audio_data=ref_audio_data)
tts_params = prompt.get("additional_information", {})
# Ming stop-token / max_tokens sampling stays in the orchestrator tail.
return PreparedRequest(prompt=prompt, tts_params=tts_params, model_type="ming_tts")
@@ -0,0 +1,58 @@
# SPDX-License-Identifier: Apache-2.0
"""MOSS-TTS serving adapters (Nano + full family).
Both variants share the same build/validate flow (``_build_moss_tts_params``
handles each); they are registered under distinct model-type names.
"""
from typing import TYPE_CHECKING
from vllm.inputs import tokens_input
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest, conditioning_cache_salt
if TYPE_CHECKING:
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
class _MossTTSAdapterBase(ARTTSAdapter):
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
err = self.ctx.server._apply_uploaded_speaker(request)
if err:
return err
return self.ctx.server._validate_moss_tts_request(request)
async def build(
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
) -> PreparedRequest:
server = self.ctx.server
tts_params = await server._build_moss_tts_params(request)
if request.voice:
voice_lower = request.voice.lower()
if voice_lower in server.uploaded_speakers and not has_inline_ref_audio:
tts_params["voice_name"] = [voice_lower]
tts_params["voice_created_at"] = [server._voice_created_at(voice_lower)]
# MOSS reads the resolved seed at build time (it samples internally).
if sampling_params_list and getattr(sampling_params_list[0], "seed", None) is not None:
tts_params["seed"] = [sampling_params_list[0].seed]
if isinstance(tts_params.get("prompt_token_ids"), list):
prompt_token_ids = tts_params.pop("prompt_token_ids")
prompt = tokens_input(prompt_token_ids=prompt_token_ids)
else:
prompt = tokens_input(prompt_token_ids=[1])
prompt["additional_information"] = tts_params
prompt["cache_salt"] = conditioning_cache_salt(request, tts_params)
return PreparedRequest(prompt=prompt, tts_params=tts_params, model_type=self.name)
@register_tts_adapter
class MossTTSNanoAdapter(_MossTTSAdapterBase):
stage_keys = frozenset({"moss_tts_nano"})
name = "moss_tts_nano"
@register_tts_adapter
class MossTTSAdapter(_MossTTSAdapterBase):
stage_keys = frozenset({"moss_tts", "moss_tts_codec"})
name = "moss_tts"
@@ -0,0 +1,49 @@
# SPDX-License-Identifier: Apache-2.0
"""OmniVoice serving adapter (AR engine_client path).
OmniVoice can also be served through the pure-diffusion engine via
``for_diffusion`` (``_create_diffusion_speech``); unifying that path is a
follow-up. This adapter covers the AR-stage deployment.
"""
from typing import TYPE_CHECKING
import numpy as np
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
if TYPE_CHECKING:
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
@register_tts_adapter
class OmniVoiceAdapter(ARTTSAdapter):
stage_keys = frozenset({"omnivoice_generator"})
name = "omnivoice"
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
if not request.input or not request.input.strip():
return "Input text cannot be empty"
return self.ctx.server._apply_uploaded_speaker(request)
async def build(
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
) -> PreparedRequest:
server = self.ctx.server
prompt: dict = {"input": request.input}
if request.ref_audio:
wav, sr = await server._resolve_ref_audio(request.ref_audio)
prompt["ref_audio"] = (np.asarray(wav, dtype=np.float32), sr)
if request.ref_text:
prompt["ref_text"] = request.ref_text
if request.voice:
voice_lower = request.voice.lower()
if voice_lower in server.uploaded_speakers and not has_inline_ref_audio:
prompt["voice_name"] = voice_lower
prompt["voice_created_at"] = server._voice_created_at(voice_lower)
if request.language:
prompt["lang"] = request.language
if request.instructions:
prompt["instruct"] = request.instructions
return PreparedRequest(prompt=prompt, tts_params={}, model_type="omnivoice")
@@ -0,0 +1,56 @@
# SPDX-License-Identifier: Apache-2.0
"""Qwen3-TTS serving adapter.
First model migrated onto the adapter framework (RFC #4327). It is the dispatch
surface for Qwen3-TTS: the serving orchestrator routes prompt/param building
through ``build()`` instead of the inline ``else`` branch in
``_prepare_speech_generation``.
To stay behaviour-identical and avoid divergence from the batch path (which
shares ``_validate_qwen_tts_request`` and ``_build_tts_params``), the adapter
reuses the single-source helper implementations on the serving instance through
``ctx.server`` rather than copying them. Subsequent PRs relocate those bodies as
more models migrate and the batch path also routes through adapters.
"""
from typing import TYPE_CHECKING
from vllm.logger import init_logger
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
if TYPE_CHECKING:
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
logger = init_logger(__name__)
@register_tts_adapter
class Qwen3TTSAdapter(ARTTSAdapter):
"""Adapter for Qwen3-TTS (AR ``engine_client`` backend)."""
stage_keys = frozenset({"qwen3_tts"})
name = "qwen3_tts"
def normalize(self, request: "OpenAICreateSpeechRequest") -> None:
"""Qwen3-TTS normalization (Base-task inference, voice lowercasing) is
performed inside ``validate`` today; kept fused for a strict behaviour
match. See ``_validate_qwen_tts_request``."""
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
# Go through the shared dispatcher (which routes to
# _validate_qwen_tts_request for this model-type) rather than the leaf
# validator directly, matching the pre-adapter qwen3 path.
return self.ctx.server._validate_tts_request(request)
async def build(
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
) -> PreparedRequest:
prompt, tts_params, warmup_key = await self.ctx.server._build_qwen3_tts_request(request)
return PreparedRequest(
prompt=prompt,
tts_params=tts_params,
model_type=tts_params.get("task_type", ["unknown"])[0],
warmup_artifact_key=warmup_key,
)
@@ -0,0 +1,33 @@
# SPDX-License-Identifier: Apache-2.0
"""Step-Audio2 serving adapter."""
from typing import TYPE_CHECKING
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
if TYPE_CHECKING:
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
@register_tts_adapter
class StepAudio2Adapter(ARTTSAdapter):
"""Adapter for Step-Audio2 (AR ``engine_client`` backend).
Step-Audio2 runs a single thinker stage that emits audio tokens after a
``<tts_start>`` marker; prompt building is a synchronous chat-template
construction with no uploaded-voice / ref-audio handling.
"""
stage_keys = frozenset({"step_audio2_thinker"})
name = "step_audio2"
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
# Dispatcher routes to the step_audio2 case (non-empty input check).
return self.ctx.server._validate_tts_request(request)
async def build(
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
) -> PreparedRequest:
prompt = self.ctx.server._build_step_audio2_prompt(request)
return PreparedRequest(prompt=prompt, tts_params={}, model_type="step_audio2")
@@ -0,0 +1,49 @@
# SPDX-License-Identifier: Apache-2.0
"""VoxCPM2 serving adapter (AR base-LM + diffusion side-computation)."""
from typing import TYPE_CHECKING
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
if TYPE_CHECKING:
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
@register_tts_adapter
class VoxCPM2Adapter(ARTTSAdapter):
"""VoxCPM2 shares ``latent_generator`` with VoxCPM; selected when no ``vae``
stage is present (and/or via ``model_arch``)."""
stage_keys = frozenset({"latent_generator"})
name = "voxcpm2"
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
return self.ctx.server._validate_voxcpm2_request(request)
async def build(
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
) -> PreparedRequest:
server = self.ctx.server
# VoxCPM2 needs the raw waveform tuple for prefill-length accounting, so
# it loads uploaded audio directly rather than via _apply_uploaded_speaker.
uploaded_ref = None
if request.voice:
voice_lower = request.voice.lower()
if voice_lower in server.uploaded_speakers and not has_inline_ref_audio:
if server.uploaded_speakers[voice_lower].get("embedding_source") == "direct":
raise ValueError(
f"Uploaded voice '{request.voice}' uses a speaker embedding (Qwen3-only). "
f"Re-upload with an audio file for VoxCPM2."
)
if request.ref_audio is None:
uploaded_ref = server._load_uploaded_audio(voice_lower)
prompt = await server._build_voxcpm2_prompt(request, uploaded_ref=uploaded_ref)
tts_params = {}
if request.voice:
voice_lower = request.voice.lower()
if voice_lower in server.uploaded_speakers or voice_lower in server.precomputed_speakers:
additional = prompt.setdefault("additional_information", {})
additional["voice_name"] = voice_lower
additional["voice_created_at"] = server._voice_created_at(voice_lower)
return PreparedRequest(prompt=prompt, tts_params=tts_params, model_type="voxcpm2")
@@ -0,0 +1,25 @@
# SPDX-License-Identifier: Apache-2.0
"""Voxtral TTS serving adapter."""
from typing import TYPE_CHECKING
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
if TYPE_CHECKING:
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
@register_tts_adapter
class VoxtralTTSAdapter(ARTTSAdapter):
stage_keys = frozenset({"audio_generation"})
name = "voxtral_tts"
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
return self.ctx.server._validate_voxtral_tts_request(request)
async def build(
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
) -> PreparedRequest:
prompt = await self.ctx.server._build_voxtral_prompt_async(request)
return PreparedRequest(prompt=prompt, tts_params={}, model_type="voxtral_tts")
@@ -21,6 +21,7 @@ to avoid OOM from vLLM's post-init KV-cache profiling. Mirrors qwen3_tts
from __future__ import annotations
import contextlib
import tempfile
import threading
from collections.abc import Iterable
@@ -38,6 +39,40 @@ from vllm_omni.model_executor.models.utils import reinit_rotary_inv_freq
logger = init_logger(__name__)
@contextlib.contextmanager
def _hf_load_without_tp_warmup():
"""Work around a transformers 5.8.x crash loading some trust_remote_code LMs.
``caching_allocator_warmup`` does ``len(model._tp_plan)`` when
``torch.distributed`` is initialized, but the MOSS-TTS-Nano remote model
leaves ``_tp_plan`` as ``None`` (it survives ``PreTrainedModel.__init__``),
so the warmup raises ``TypeError: object of type 'NoneType' has no len()``.
vLLM initializes ``torch.distributed`` even at TP=1, so the crash fires.
MOSS-TTS-Nano runs single-GPU (no tensor parallelism), so forcing the
warmup down its non-distributed branch is byte-for-byte equivalent and
sidesteps the ``None`` plan. Scoped to the wrapped ``from_pretrained`` call.
Targets the private ``transformers.modeling_utils._is_torch_distributed_initialized``.
Guarded by ``hasattr`` so that if a future transformers renames or removes it
(and presumably the crash with it), we no-op instead of raising AttributeError.
"""
import transformers.modeling_utils as _tmu
if not hasattr(_tmu, "_is_torch_distributed_initialized"):
# Internal hook gone (transformers refactor); assume the upstream crash
# is gone too and run the load unpatched.
yield
return
original = _tmu._is_torch_distributed_initialized
_tmu._is_torch_distributed_initialized = lambda: False
try:
yield
finally:
_tmu._is_torch_distributed_initialized = original
def _patch_torchaudio_load() -> None:
"""Patch torchaudio.load to use soundfile if torchcodec is unavailable."""
try:
@@ -158,11 +193,12 @@ class MossTTSNanoForGeneration(nn.Module):
logger.info("Loading MOSS-TTS-Nano LM from %s (dtype=%s)", self.model_path, tts_dtype)
from transformers import AutoModelForCausalLM
lm = AutoModelForCausalLM.from_pretrained(
self.model_path,
trust_remote_code=True,
torch_dtype=tts_dtype,
)
with _hf_load_without_tp_warmup():
lm = AutoModelForCausalLM.from_pretrained(
self.model_path,
trust_remote_code=True,
torch_dtype=tts_dtype,
)
if device.type == "cuda":
try:
import flash_attn # noqa: F401
@@ -197,11 +233,12 @@ class MossTTSNanoForGeneration(nn.Module):
logger.info("Loading MOSS-Audio-Tokenizer-Nano from %s", codec_path)
from transformers import AutoModel
audio_tokenizer = AutoModel.from_pretrained(
codec_path,
trust_remote_code=True,
torch_dtype=torch.float32,
)
with _hf_load_without_tp_warmup():
audio_tokenizer = AutoModel.from_pretrained(
codec_path,
trust_remote_code=True,
torch_dtype=torch.float32,
)
audio_tokenizer.to(device=device)
audio_tokenizer.eval()
self._audio_tokenizer: nn.Module = audio_tokenizer