255 lines
9.7 KiB
Python
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",
|
|
]
|