[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:
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 |
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in new issue
Block a user