diff --git a/docs/configuration/environment_variables.md b/docs/configuration/environment_variables.md index e113c9d7c..eaefb1015 100644 --- a/docs/configuration/environment_variables.md +++ b/docs/configuration/environment_variables.md @@ -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 `/vllm_omni_orch_monitor_.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 | diff --git a/docs/serving/speech_api.md b/docs/serving/speech_api.md index d973b51a5..2759fffc0 100644 --- a/docs/serving/speech_api.md +++ b/docs/serving/speech_api.md @@ -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. diff --git a/tests/entrypoints/openai_api/test_serving_speech.py b/tests/entrypoints/openai_api/test_serving_speech.py index 9a2f9e1fc..daf1c5d5b 100644 --- a/tests/entrypoints/openai_api/test_serving_speech.py +++ b/tests/entrypoints/openai_api/test_serving_speech.py @@ -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 diff --git a/vllm_omni/config/environment_variable_inventory.py b/vllm_omni/config/environment_variable_inventory.py index fd3845eda..c3871bace 100644 --- a/vllm_omni/config/environment_variable_inventory.py +++ b/vllm_omni/config/environment_variable_inventory.py @@ -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", diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index d3c23ca39..a32eeeac6 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -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()