Files
vllm-omni/vllm_omni/entrypoints/openai/tts_adapters/base.py
T
2026-08-11 23:00:35 +00:00

267 lines
10 KiB
Python

# 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. Load-bearing: the serving
#: layer discovers the TTS stage and resolves the model type from these.
stage_keys: ClassVar[frozenset[str]] = frozenset()
#: Engine ``model_arch`` value(s) that identify this model type. Checked
#: before ``stage_keys`` in :meth:`matches`. Only needed by models whose
#: ``model_stage`` alone is ambiguous or absent.
model_archs: ClassVar[frozenset[str]] = frozenset()
#: Set when the model has no dedicated ``model_stage`` value and its AR
#: entry stage must be discovered by ``model_archs`` instead (Ming dense).
arch_identifies_entry_stage: ClassVar[bool] = False
#: Detection order; lower runs first. Only set this to break a genuine
#: overlap with another adapter — see ``tests/.../test_tts_detection.py``,
#: which fails if two same-priority detectors can match the same input.
detect_priority: ClassVar[int] = 100
#: Serving backend: ``"ar"`` (engine_client) or ``"diffusion"``.
backend: ClassVar[str] = "ar"
#: Whether the model consumes ``request.speed`` in its native parameters.
native_speed_control: ClassVar[bool] = False
max_new_tokens_min = 1
max_new_tokens_max = 4096
def __init__(self, ctx: SpeechServingContext) -> None:
self.ctx = ctx
@classmethod
def matches(cls, model_stage: str | None, model_arch: str | None) -> bool:
"""Whether a deployed stage with this ``model_stage``/``model_arch``
is served by this adapter.
Architecture wins over stage key, so a model that declares both is
recognized even when deployed under a stage key it shares with another
model. Override only for match rules that are not a set membership
test (see ``covo_audio``).
"""
if model_arch is not None and model_arch in cls.model_archs:
return True
return model_stage is not None and model_stage in cls.stage_keys
@classmethod
def stage_serves_speech(cls, model_stage: str | None, all_stage_keys: frozenset[str]) -> bool:
"""Whether a matched stage really accepts ``/v1/audio/speech`` in *this*
deployment.
Lets a model that is speech-capable only in some topologies say so
(Audex: its omni thinker is text-final unless the speech decoder is
deployed alongside). Default: always.
"""
return True
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()
@dataclass(frozen=True)
class LegacyDetector:
"""A model type the serving layer detects but has no adapter for yet.
Detection has to keep naming these models so ``serving_speech.py`` can route
them down its legacy path, but they own none of the adapter contract. Each
entry is a migration debt: delete it when the model gets an adapter. The
pre-commit gate (``tools/pre_commit/check_tts_adapter.py``) fails if the list
grows.
Exposes the subset of the :class:`TTSModelAdapter` class surface that
detection reads, so both kinds live in one sorted sequence.
"""
name: str
stage_keys: frozenset[str] = frozenset()
model_archs: frozenset[str] = frozenset()
arch_identifies_entry_stage: bool = False
detect_priority: int = 100
def matches(self, model_stage: str | None, model_arch: str | None) -> bool:
if model_arch is not None and model_arch in self.model_archs:
return True
return model_stage is not None and model_stage in self.stage_keys
# Re-exported here to avoid import cycles at call sites.
__all__ = [
"ARTTSAdapter",
"DiffusionTTSAdapter",
"LegacyDetector",
"OutputPolicy",
"PreparedRequest",
"SpeechServingContext",
"TTSModelAdapter",
]