Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,6 @@ def test_speech_missing_required_fields(omni_server: OmniServer, openai_client:
{"response_format": "mpeg"}, ("response_format", "literal_error", "wav"), id="response_format_invalid"
),
pytest.param({"stream_format": 1}, ("stream_format", "literal_error", "audio"), id="stream_format_wrong_type"),
pytest.param(
{"stream_format": "sse"}, ("stream_format", "value_error", "audio"), id="stream_format_sse_blocked"
),
pytest.param({"stream": "wrong_type"}, ("stream", "bool_parsing", "validation error"), id="stream_wrong_type"),
pytest.param(
{
Expand Down
205 changes: 199 additions & 6 deletions tests/entrypoints/openai_api/test_serving_speech.py
Original file line number Diff line number Diff line change
Expand Up @@ -1826,15 +1826,27 @@ def test_validate_path_within_directory(self, tmp_path):


class TestStreamingProtocolValidation:
"""Unit tests for the stream field validators in OpenAICreateSpeechRequest."""
"""Unit tests for streaming validators in OpenAICreateSpeechRequest."""

def test_default_is_non_streaming(self):
req = OpenAICreateSpeechRequest(input="Hello")
assert req.stream is False
assert req.stream_format is None
assert req.is_streaming() is False

def test_stream_validation_errors(self):
"""stream=True requires response_format not in ('pcm', 'wav') and speed=1.0."""
"""stream=True requires response_format in ('pcm', 'wav') and speed=1.0."""
with pytest.raises(ValidationError, match="requires response_format='pcm' or 'wav'"):
OpenAICreateSpeechRequest(input="Hello", stream=True, response_format="mp3")
with pytest.raises(ValidationError, match="Speed adjustment is not supported"):
OpenAICreateSpeechRequest(input="Hello", stream=True, response_format="pcm", speed=2.0)

def test_stream_format_audio_validation_errors(self):
with pytest.raises(ValidationError, match="requires response_format='pcm' or 'wav'"):
OpenAICreateSpeechRequest(input="Hello", stream_format="audio", response_format="mp3")
with pytest.raises(ValidationError, match="Speed adjustment is not supported"):
OpenAICreateSpeechRequest(input="Hello", stream_format="audio", response_format="pcm", speed=2.0)

def test_stream_valid(self):
"""stream=True + response_format in ('pcm', 'wav') + speed=1.0 is accepted."""
req = OpenAICreateSpeechRequest(input="Hello", stream=True, response_format="pcm")
Expand All @@ -1843,10 +1855,17 @@ def test_stream_valid(self):
req = OpenAICreateSpeechRequest(input="Hello", stream=True, response_format="wav")
assert req.stream is True

def test_sse_stream_format_is_blocked(self):
"""stream_format='sse' is blocked."""
with pytest.raises(ValidationError, match="sse"):
OpenAICreateSpeechRequest(input="Hello", stream_format="sse")
def test_stream_format_audio_is_valid(self):
req = OpenAICreateSpeechRequest(input="Hello", stream_format="audio", response_format="pcm")
assert req.stream_format == "audio"
assert req.is_raw_audio_stream() is True

def test_sse_stream_format_is_valid(self):
"""stream_format='sse' is accepted for /audio/speech."""
req = OpenAICreateSpeechRequest(input="Hello", stream_format="sse")

assert req.stream_format == "sse"
assert req.is_sse_stream() is True


class TestStreamingResponse:
Expand Down Expand Up @@ -1927,6 +1946,146 @@ def test_streaming(self, streaming_app):
assert "audio/pcm" in response.headers["content-type"]
assert len(response.content) > 0

def test_stream_format_audio_streaming(self, streaming_app):
"""stream_format=audio without stream=True returns raw audio/pcm chunks."""
client = TestClient(streaming_app)
response = client.post(
"/v1/audio/speech",
json={"input": "Hello", "stream_format": "audio", "response_format": "pcm"},
)
assert response.status_code == 200
assert "audio/pcm" in response.headers["content-type"]
assert "text/event-stream" not in response.headers["content-type"]
assert len(response.content) > 0

def test_sse_streaming(self, streaming_app):
"""stream_format=sse without stream=True returns audio deltas as SSE."""
client = TestClient(streaming_app)
response = client.post(
"/v1/audio/speech",
json={"input": "Hello", "stream_format": "sse", "response_format": "pcm"},
)

assert response.status_code == 200
assert "text/event-stream" in response.headers["content-type"]
body = response.text
assert "event: speech.audio.delta" in body
assert "event: speech.audio.done" in body
data_line = next(line for line in body.splitlines() if line.startswith("data: "))
payload = json.loads(data_line.removeprefix("data: "))
assert payload["type"] == "speech.audio.delta"
assert payload["response_format"] == "pcm"
assert base64.b64decode(payload["audio"])

def test_stream_true_prefers_raw_audio_over_sse(self, streaming_app):
"""stream=True keeps the existing raw audio stream behavior even with stream_format=sse."""
client = TestClient(streaming_app)
response = client.post(
"/v1/audio/speech",
json={"input": "Hello", "stream": True, "stream_format": "sse", "response_format": "pcm"},
)

assert response.status_code == 200
assert "audio/pcm" in response.headers["content-type"]
assert "text/event-stream" not in response.headers["content-type"]
assert len(response.content) > 0

def test_sse_rejects_unsupported_response_format(self, streaming_app):
"""stream_format=sse with a non-pcm/wav format must fail before streaming starts."""
client = TestClient(streaming_app)
response = client.post(
"/v1/audio/speech",
json={"input": "Hello", "stream_format": "sse", "response_format": "mp3"},
)

assert response.status_code in (400, 422)
assert "text/event-stream" not in response.headers.get("content-type", "")

def test_sse_rejects_speed_adjustment(self, streaming_app):
"""stream_format=sse with speed != 1.0 must fail before streaming starts."""
client = TestClient(streaming_app)
response = client.post(
"/v1/audio/speech",
json={"input": "Hello", "stream_format": "sse", "response_format": "pcm", "speed": 2.0},
)

assert response.status_code in (400, 422)
assert "text/event-stream" not in response.headers.get("content-type", "")

def test_stream_format_audio_rejects_unsupported_response_format(self, streaming_app):
client = TestClient(streaming_app)
response = client.post(
"/v1/audio/speech",
json={"input": "Hello", "stream_format": "audio", "response_format": "mp3"},
)

assert response.status_code in (400, 422)
assert "audio/" not in response.headers.get("content-type", "")

def test_stream_format_audio_rejects_speed_adjustment(self, streaming_app):
client = TestClient(streaming_app)
response = client.post(
"/v1/audio/speech",
json={"input": "Hello", "stream_format": "audio", "response_format": "pcm", "speed": 2.0},
)

assert response.status_code in (400, 422)
assert "audio/" not in response.headers.get("content-type", "")

@pytest.fixture
def erroring_streaming_app(self, mocker: MockerFixture):
"""Test app whose mock engine raises mid-stream, to exercise the SSE error event."""

async def mock_generate_streaming(*args, **kwargs):
raise RuntimeError("boom: simulated engine failure")
yield # pragma: no cover - generator marker, unreachable

mock_engine_client = mocker.MagicMock()
mock_engine_client.errored = False
mock_engine_client.generate = mocker.MagicMock(side_effect=mock_generate_streaming)
mock_engine_client.default_sampling_params_list = [{}]
mock_models = mocker.MagicMock()
mock_models.is_base_model.return_value = True

speech_server = OmniOpenAIServingSpeech(
engine_client=mock_engine_client,
models=mock_models,
request_logger=mocker.MagicMock(),
)

original_create_speech = speech_server.create_speech
sig = signature(original_create_speech)
new_parameters = [p for name, p in sig.parameters.items() if name != "raw_request"]
new_sig = Signature(parameters=new_parameters, return_annotation=sig.return_annotation)

async def awaitable_create_speech(*args, **kwargs):
return await original_create_speech(*args, **kwargs)

awaitable_create_speech.__signature__ = new_sig
speech_server.create_speech = awaitable_create_speech

app = FastAPI()
app.add_api_route("/v1/audio/speech", speech_server.create_speech, methods=["POST"], response_model=None)
return app

def test_sse_emits_error_event_on_generator_failure(self, erroring_streaming_app):
"""An exception inside the SSE generator must surface as a speech.audio.error event."""
client = TestClient(erroring_streaming_app)
response = client.post(
"/v1/audio/speech",
json={"input": "Hello", "stream_format": "sse", "response_format": "pcm"},
)

assert response.status_code == 200
assert "text/event-stream" in response.headers["content-type"]
body = response.text
assert "event: speech.audio.error" in body
error_line = next(line for line in body.splitlines() if line.startswith("data: "))
payload = json.loads(error_line.removeprefix("data: "))
assert payload["type"] == "speech.audio.error"
assert "error" in payload
assert payload["error"]["message"]

def test_non_streaming_unchanged(self, streaming_app):
"""Non-streaming path must still return audio/wav."""
client = TestClient(streaming_app)
Expand Down Expand Up @@ -2883,6 +3042,40 @@ def test_prepare_speech_generation_awaits_qwen3_tts_async(self, qwen3_tts_server
qwen3_tts_server._build_tts_params.assert_called_once()
qwen3_tts_server._estimate_prompt_len_async.assert_awaited_once()

def test_prepare_speech_generation_treats_sse_as_streaming(self, qwen3_tts_server, mocker: MockerFixture):
"""stream_format=sse should request delta-style multimodal outputs."""
qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None)
qwen3_tts_server._build_tts_params = mocker.MagicMock(
return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]}
)
qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
mock_coerce = mocker.patch(
"vllm_omni.entrypoints.openai.serving_speech.coerce_param_message_types",
return_value=qwen3_tts_server.engine_client.default_sampling_params_list,
)
request = OpenAICreateSpeechRequest(input="hello", stream_format="sse")

asyncio.run(qwen3_tts_server._prepare_speech_generation(request))

mock_coerce.assert_called_once_with(qwen3_tts_server.engine_client.default_sampling_params_list, True)

def test_prepare_speech_generation_treats_audio_as_streaming(self, qwen3_tts_server, mocker: MockerFixture):
"""stream_format=audio should request delta-style multimodal outputs."""
qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None)
qwen3_tts_server._build_tts_params = mocker.MagicMock(
return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]}
)
qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
mock_coerce = mocker.patch(
"vllm_omni.entrypoints.openai.serving_speech.coerce_param_message_types",
return_value=qwen3_tts_server.engine_client.default_sampling_params_list,
)
request = OpenAICreateSpeechRequest(input="hello", stream_format="audio", response_format="pcm")

asyncio.run(qwen3_tts_server._prepare_speech_generation(request))

mock_coerce.assert_called_once_with(qwen3_tts_server.engine_client.default_sampling_params_list, True)

def test_prepare_speech_generation_qwen3_voicedesign_non_streaming_mode_false(
self, qwen3_tts_server, mocker: MockerFixture
):
Expand Down
2 changes: 1 addition & 1 deletion vllm_omni/entrypoints/openai/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1200,7 +1200,7 @@ async def create_chat_completion(request: ChatCompletionRequest, raw_request: Re
"/v1/audio/speech",
dependencies=[Depends(validate_json_request)],
responses={
HTTPStatus.OK.value: {"content": {"audio/*": {}}},
HTTPStatus.OK.value: {"content": {"audio/*": {}, "text/event-stream": {}}},
HTTPStatus.BAD_REQUEST.value: {"model": ErrorResponse},
HTTPStatus.NOT_FOUND.value: {"model": ErrorResponse},
HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse},
Expand Down
4 changes: 0 additions & 4 deletions vllm_omni/entrypoints/openai/audio_utils_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,9 @@ def create_audio(self, audio_obj: CreateAudio) -> AudioResponse:
audio_tensor = audio_obj.audio_tensor
sample_rate = audio_obj.sample_rate
response_format = audio_obj.response_format.lower()
stream_format = audio_obj.stream_format
base64_encode = audio_obj.base64_encode
speed = audio_obj.speed

if stream_format != "audio":
raise ValueError(f"Unsupported stream format: {stream_format}")

if soundfile is None:
raise ImportError(
"soundfile is required for audio generation. Please install it with: pip install soundfile"
Expand Down
33 changes: 22 additions & 11 deletions vllm_omni/entrypoints/openai/protocol/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,18 @@ class OpenAICreateSpeechRequest(BaseModel):
ge=0.25,
le=4.0,
)
stream_format: Literal["sse", "audio"] | None = "audio"
stream_format: Literal["sse", "audio"] | None = Field(
default=None,
description=(
"Streaming output format. 'audio' streams raw pcm/wav bytes; "
"'sse' streams OpenAI speech.audio.* SSE events. Omit for non-streaming."
),
)
stream: bool = Field(
default=False,
description=(
"If true, stream raw PCM audio chunks as they are decoded. "
"Requires response_format='pcm'. Speed adjustment is not supported when streaming."
"Legacy streaming switch; equivalent to stream_format='audio'. "
"Requires response_format='pcm' or 'wav'. Speed adjustment is not supported when streaming."
),
)

Expand Down Expand Up @@ -158,8 +164,6 @@ class OpenAICreateSpeechRequest(BaseModel):
@field_validator("stream_format")
@classmethod
def validate_stream_format(cls, v: str) -> str:
if v == "sse":
raise ValueError("'sse' is not a supported stream_format yet. Please use 'audio'.")
return v

@field_validator("ref_audio", mode="before")
Expand Down Expand Up @@ -287,20 +291,28 @@ def validate_embedding_constraints(self) -> "OpenAICreateSpeechRequest":
raise ValueError("'speaker_embedding' and 'ref_audio' are mutually exclusive")
return self

def is_raw_audio_stream(self) -> bool:
return self.stream or self.stream_format == "audio"

def is_sse_stream(self) -> bool:
return self.stream_format == "sse" and not self.is_raw_audio_stream()

def is_streaming(self) -> bool:
return self.is_raw_audio_stream() or self.stream_format == "sse"

@model_validator(mode="after")
def validate_streaming_constraints(self) -> "OpenAICreateSpeechRequest":
if self.stream:
if self.is_streaming():
if self.response_format not in ("pcm", "wav"):
raise ValueError(
"Streaming (stream=true) requires response_format='pcm' or 'wav'. "
"Streaming (stream=true, stream_format='audio', or stream_format='sse') "
"requires response_format='pcm' or 'wav'. "
f"Got response_format='{self.response_format}'."
)
if self.speed is None:
self.speed = 1.0
elif self.speed != 1.0:
raise ValueError(
"Speed adjustment is not supported when streaming (stream=true). Set speed=1.0 or omit it."
)
raise ValueError("Speed adjustment is not supported when streaming. Set speed=1.0 or omit it.")
return self


Expand Down Expand Up @@ -356,7 +368,6 @@ class CreateAudio(BaseModel):
sample_rate: int = 24000
response_format: str = "wav"
speed: float = 1.0
stream_format: Literal["sse", "audio"] | None = "audio"
base64_encode: bool = True

class Config:
Expand Down
1 change: 0 additions & 1 deletion vllm_omni/entrypoints/openai/serving_audio_generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,6 @@ async def create_audio_generate(
sample_rate=int(sample_rate),
response_format=request.response_format or "wav",
speed=request.speed or 1.0,
stream_format=request.stream_format,
base64_encode=False,
)

Expand Down
2 changes: 0 additions & 2 deletions vllm_omni/entrypoints/openai/serving_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -2520,7 +2520,6 @@ def _create_audio_choice(
sample_rate=sample_rate,
response_format="wav",
speed=1.0,
stream_format="audio",
base64_encode=True,
)

Expand Down Expand Up @@ -3475,7 +3474,6 @@ async def _create_diffusion_chat_completion(
sample_rate=sample_rate,
response_format="wav",
speed=1.0,
stream_format="audio",
base64_encode=True,
)
audio_response: AudioResponse = self.create_audio(audio_obj)
Expand Down
Loading
Loading