Files
vllm-omni/vllm_omni/entrypoints/openai/tts_adapters/base.py
T

255 lines
9.7 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).
"""
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)
def apply_max_new_tokens(
sampling_params_list: list,
request: "OpenAICreateSpeechRequest",
) -> list:
"""Apply a request-level ``max_new_tokens`` limit."""
if request.max_new_tokens is None:
return sampling_params_list
import copy
sampling_params_list = copy.deepcopy(sampling_params_list)
sampling_params_list[0].max_tokens = request.max_new_tokens
return sampling_params_list
@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",
prompt: dict[str, Any] | None = None,
request_id: str | None = None,
) -> 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",
]