[Frontend] Reject built-in name collisions in voice upload and add VLLM_OMNI_SPEAKER_REGISTRATION_POLICY (#6848)

Signed-off-by: Hoseung Kim <hoseung.kim@navercorp.com>
Co-authored-by: Hoseung Kim <hoseung.kim@navercorp.com>
This commit is contained in:
Hoseung KimandHoseung Kim authored and GitHub committed 2026-09-21 18:35:07 -07:00
1 parent 085cf0ff34
commit d4570afabf
5 files changed
+128

No files matched your search

@@ -88,6 +88,7 @@ depends on the installed kernels and model path.
| `VLLM_OMNI_EVENT_DRIVEN_ORCH` | `1`, `true`, `yes` or `on` enables; defaults to on for Qwen3-TTS and off for other pipelines | Pipeline default computed from `pipeline_config.model_type` at engine initialization; env override resolved at `Orchestrator` construction and separately when the serving-side final-output drain starts | An explicit env value wins; otherwise both consumers use the engine's pipeline default. Values are stripped and case-normalized; any unrecognized value selects the legacy poll loop. Set before server startup. | Experimental |
| `VLLM_OMNI_INPUT_WAIT_TIMEOUT_S` | Float seconds; default `600`; `<=0` disables | Full-payload input coordinator, not async-chunk transfer; read when the scheduler module imports in each worker | Environment-only setting. A non-float logs a warning and uses `600`. | Stable operational control |
| `VLLM_OMNI_ORCH_MONITOR_PATH` | Filesystem path; default `<current-working-directory>/vllm_omni_orch_monitor_<timestamp>.json` | Orchestrator monitor enabled by `--enable-orch-monitor`; read when the monitor is created | Environment-only path override. Parent directories are created; write errors are logged. | Diagnostic |
| `VLLM_OMNI_SPEAKER_REGISTRATION_POLICY` | `overwrite` or `immutable`; default `overwrite` | Speech server; read when speaker storage initializes | Environment-only setting. `immutable` rejects re-registering an existing uploaded voice name until it is deleted; any other value raises `ValueError` at startup. | Experimental |
| `VLLM_OMNI_VIDEO_SYNC_TIMEOUT` | Float seconds; default `600` | Synchronous Videos API; read when the API server module imports | Environment-only setting. A non-float raises `ValueError` during import. | Experimental |
| `VLLM_VIDEO_ASYNC_CHUNK` | `on` or `off`; default `on` | Streaming video output; read on attribute access | Environment-only setting. Values are trimmed and case-normalized; an invalid value warns once and uses `on`. | Experimental |
| `VLLM_VIDEO_AUDIO_DELTA_MODE` | `fast` or `slow`; default `fast` | Streaming video audio deltas; read on attribute access | Environment-only setting. Values are trimmed and case-normalized; an invalid value warns once and uses `fast`. | Experimental |
+14
View File
@@ -289,6 +289,19 @@ Upload a new voice sample for voice cloning in Base task TTS requests.
Fields `ref_text` and `speaker_description` are omitted when not provided at upload time.
**Naming rules:**
- Names that collide with one of the model's built-in or precomputed voices are rejected (400). Voice
files already on disk under such a name are ignored at startup with a warning, so an upload can never
shadow a built-in voice.
- Re-uploading an existing uploaded name overwrites it in place (the previous audio file is
deleted). Set `VLLM_OMNI_SPEAKER_REGISTRATION_POLICY=immutable` on the server to reject duplicates instead,
requiring an explicit `DELETE /v1/audio/voices/{name}` before re-registering — useful when the
endpoint is reachable by multiple writers and silent overwrites are a risk.
- The voice registry has no per-user ownership: any client that can reach the endpoint can
overwrite (default policy) or delete any uploaded voice. For multi-tenant deployments, add
authentication at a proxy and namespace voice names per user (e.g. `{user}.{name}`).
**Usage Example:**
```bash
@@ -577,6 +590,7 @@ by `GET /v1/audio/voices`. Valid precomputed voices can be used in
| ---------- | --------- | ------------- |
| `SPEAKER_SAMPLES_DIR` | `~/.cache/vllm-omni/speakers` | Directory for persisted uploaded speakers (`.safetensors` files). |
| `SPEAKER_MAX_UPLOADED` | `1000` | Maximum number of uploaded speakers kept on disk. Upload requests past the cap return 400. |
| `VLLM_OMNI_SPEAKER_REGISTRATION_POLICY` | `overwrite` | `immutable` rejects re-uploading an existing uploaded name (400) until it is deleted; any other value fails startup. |
The in-memory LRU has a fixed 512 MiB byte budget.
@@ -505,6 +505,75 @@ class TestSpeechAPI:
assert voice_info["file_size"] == len(audio_content)
response = client.delete("/v1/audio/voices/test_voice")
def test_upload_voice_rejects_builtin_name(self, client):
"""A name colliding with a built-in or precomputed voice is rejected."""
handler = client.app.state.openai_serving_speech
handler._adapter.capabilities.supported_speakers = {"vivian"}
handler._adapter.capabilities.precomputed_speakers = {"ono_anna": {}}
files = {"audio_sample": ("test.wav", b"fake audio content" * 1000, "audio/wav")}
for reserved in ("Vivian", "ono_anna"):
response = client.post("/v1/audio/voices", files=files, data={"consent": "c1", "name": reserved})
assert response.status_code == 400
assert "reserved" in response.json()["detail"]
assert not handler.uploaded_speakers
def test_upload_voice_overwrites_same_name_by_default(self, client):
"""Default policy: re-registering an uploaded name replaces it in place."""
files = {"audio_sample": ("test.wav", b"first take", "audio/wav")}
data = {"consent": "c1", "name": "test_voice_ow"}
assert client.post("/v1/audio/voices", files=files, data=data).status_code == 200
files = {"audio_sample": ("test.wav", b"second take, longer content", "audio/wav")}
response = client.post("/v1/audio/voices", files=files, data=data)
assert response.status_code == 200
assert response.json()["voice"]["file_size"] == len(b"second take, longer content")
client.delete("/v1/audio/voices/test_voice_ow")
def test_upload_voice_immutable_policy_requires_delete(self, client):
"""VLLM_OMNI_SPEAKER_REGISTRATION_POLICY=immutable rejects duplicates until deleted."""
handler = client.app.state.openai_serving_speech
handler._registration_policy = "immutable"
try:
files = {"audio_sample": ("test.wav", b"fake audio content" * 1000, "audio/wav")}
data = {"consent": "c1", "name": "test_voice_imm"}
assert client.post("/v1/audio/voices", files=files, data=data).status_code == 200
response = client.post("/v1/audio/voices", files=files, data=data)
assert response.status_code == 400
assert "immutable" in response.json()["detail"]
assert client.delete("/v1/audio/voices/test_voice_imm").status_code == 200
assert client.post("/v1/audio/voices", files=files, data=data).status_code == 200
finally:
handler._registration_policy = "overwrite"
client.delete("/v1/audio/voices/test_voice_imm")
def test_invalid_registration_policy_fails_startup(self, mocker, monkeypatch, tmp_path):
"""An unknown VLLM_OMNI_SPEAKER_REGISTRATION_POLICY is a configuration error, not a silent fallback."""
monkeypatch.setenv("SPEAKER_SAMPLES_DIR", str(tmp_path))
monkeypatch.setenv("VLLM_OMNI_SPEAKER_REGISTRATION_POLICY", "append")
engine_client = mocker.MagicMock()
engine_client.default_sampling_params_list = [{}]
with pytest.raises(ValueError, match="VLLM_OMNI_SPEAKER_REGISTRATION_POLICY"):
OmniOpenAIServingSpeech(
engine_client=engine_client, models=mocker.MagicMock(), request_logger=mocker.MagicMock()
)
def test_restored_upload_shadowing_builtin_is_dropped(self, client):
"""A pre-guard upload restored under a built-in name must not keep shadowing it."""
handler = client.app.state.openai_serving_speech
handler._adapter.capabilities.supported_speakers = {"vivian"}
handler._adapter.capabilities.precomputed_speakers = {}
handler.uploaded_speakers["vivian"] = {"name": "vivian", "file_path": "/tmp/vivian.safetensors"}
handler.uploaded_speakers["keep_me"] = {"name": "keep_me", "file_path": "/tmp/keep_me.safetensors"}
handler._drop_shadowing_uploads()
assert "vivian" not in handler.uploaded_speakers
assert "keep_me" in handler.uploaded_speakers
handler.uploaded_speakers.pop("keep_me")
def test_upload_voice_with_ref_text(self, client, tmp_path):
"""Test voice upload with ref_text enables in-context cloning."""
audio_content = b"fake audio content" * 1000
@@ -77,6 +77,7 @@ _PUBLIC_OMNI = (
"VLLM_OMNI_SERVER_STORAGE__PATH",
"VLLM_OMNI_SERVER_STORAGE__TTL_SWEEP_INTERVAL",
"VLLM_OMNI_SKIP_NVFP4_NAN_CLAMP",
"VLLM_OMNI_SPEAKER_REGISTRATION_POLICY",
"VLLM_OMNI_STORAGE_MAX_CONCURRENCY",
"VLLM_OMNI_STORAGE_PATH",
"VLLM_OMNI_USE_QUACK_FP8",
@@ -246,6 +246,12 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
except ValueError:
logger.warning("Invalid SPEAKER_MAX_UPLOADED=%r; using default 1000", _raw_cap)
self._max_uploaded_speakers = 1000
_policy = os.environ.get("VLLM_OMNI_SPEAKER_REGISTRATION_POLICY", "overwrite").lower()
if _policy not in ("overwrite", "immutable"):
raise ValueError(
f"Invalid VLLM_OMNI_SPEAKER_REGISTRATION_POLICY={_policy!r}; expected 'overwrite' or 'immutable'."
)
self._registration_policy = _policy
self.uploaded_speakers: dict[str, dict[str, Any]] = {}
self._ref_audio_data_url_cache: dict[str, str] = {}
self._ref_audio_resolve_cache: OrderedDict[str, tuple[np.ndarray, int, int, str]] = OrderedDict()
@@ -410,6 +416,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
adapter = self._adapter
if adapter is not None:
adapter.load_capabilities()
self._drop_shadowing_uploads()
available_speakers = self._get_available_speakers()
logger.info("Loaded %d supported speakers: %s", len(available_speakers), sorted(available_speakers))
@@ -448,6 +455,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
ctx = SpeechServingContext(server=self, engine_client=self.engine_client)
self._adapter = adapter_cls(ctx)
self._adapter.load_capabilities()
self._drop_shadowing_uploads()
return self._adapter
def _uses_native_speed_control(self) -> bool:
@@ -803,6 +811,39 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
f"the cap via SPEAKER_MAX_UPLOADED."
)
def _drop_shadowing_uploads(self) -> None:
"""Uploads are restored from disk before the adapter exists, so a name
registered under a built-in speaker before the collision guard would
keep shadowing it across restarts. Drop such entries from the registry;
the file stays on disk for the operator to remove."""
caps = self._adapter.capabilities if self._adapter is not None else None
if caps is None:
return
for voice_name_lower in list(self.uploaded_speakers):
if voice_name_lower in caps.supported_speakers or voice_name_lower in caps.precomputed_speakers:
info = self.uploaded_speakers.pop(voice_name_lower)
logger.warning(
"Uploaded voice %r shadows a built-in speaker and is ignored; remove %s to clear this warning.",
voice_name_lower,
info.get("file_path"),
)
def _check_registration_allowed(self, voice_name_lower: str, name: str) -> None:
"""Reject names that would shadow a built-in voice or, under the
immutable policy, silently overwrite an existing upload."""
caps = self._adapter.capabilities if self._adapter is not None else None
if caps is not None and (
voice_name_lower in caps.supported_speakers or voice_name_lower in caps.precomputed_speakers
):
raise ValueError(
f"Voice name '{name}' is reserved by a built-in speaker of this model; choose a different name."
)
if self._registration_policy == "immutable" and voice_name_lower in self.uploaded_speakers:
raise ValueError(
f"Voice '{name}' already exists and VLLM_OMNI_SPEAKER_REGISTRATION_POLICY is 'immutable'; "
f"delete it first via DELETE /v1/audio/voices/{name}."
)
def _evict_existing_upload(self, voice_name_lower: str, name: str) -> None:
"""Drop an existing upload with this name so the caller can re-register it."""
if voice_name_lower not in self.uploaded_speakers:
@@ -885,6 +926,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
async with self._upload_lock:
voice_name_lower = name.lower()
self._check_registration_allowed(voice_name_lower, name)
self._evict_existing_upload(voice_name_lower, name)
self._check_upload_cap()
@@ -1005,6 +1047,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
async with self._upload_lock:
voice_name_lower = name.lower()
self._check_registration_allowed(voice_name_lower, name)
self._evict_existing_upload(voice_name_lower, name)
self._check_upload_cap()