fix: return proper HTTP status codes for audio voice endpoints (#3969)

Signed-off-by: Ian Eaves <ian.k.eaves@gmail.com>
Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
Co-authored-by: Yueqian Lin <linyueqian@outlook.com>
Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-06-22 22:57:07 +00:00
1 parent 044240d8eb
commit 50bc51ffdf
3 files changed
+340 -61

No files matched your search

@@ -621,7 +621,6 @@ def _voices_upload_multipart_files(kind: str | None) -> dict[str, Any] | None:
{"consent": "c1", "name": "v1"},
("audio_sample", "speaker_embedding", "provided"),
id="neither_audio_nor_embedding",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"wav_ok",
@@ -673,7 +672,6 @@ def _voices_upload_multipart_files(kind: str | None) -> dict[str, Any] | None:
{"consent": "consent_ok", "name": "evil/name"},
("voice name", "invalid voice name"),
id="invalid_name_path_sep",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"wav_ok",
@@ -698,21 +696,18 @@ def _voices_upload_multipart_files(kind: str | None) -> dict[str, Any] | None:
{"consent": "consent_ok", "name": "v_bad_mime_{uuid}"},
("mime", "unsupported", "audio/mp4"),
id="audio_unsupported_mime",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"wav_short",
{"consent": "consent_ok", "name": "v_short_{uuid}"},
("too short", "reference audio"),
id="audio_too_short",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"wav_bad_body",
{"consent": "consent_ok", "name": "v_bad_wav_{uuid}"},
("decode", "audio", "Format not recognised"),
id="audio_decode_error",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
None,
@@ -723,7 +718,6 @@ def _voices_upload_multipart_files(kind: str | None) -> dict[str, Any] | None:
},
("speaker_embedding", "valid JSON"),
id="speaker_embedding_invalid_json",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
None,
@@ -734,7 +728,6 @@ def _voices_upload_multipart_files(kind: str | None) -> dict[str, Any] | None:
},
("speaker_embedding", "non-empty"),
id="speaker_embedding_empty",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
None,
@@ -745,7 +738,6 @@ def _voices_upload_multipart_files(kind: str | None) -> dict[str, Any] | None:
},
("speaker_embedding", "finite"),
id="speaker_embedding_nan",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
None,
@@ -756,7 +748,6 @@ def _voices_upload_multipart_files(kind: str | None) -> dict[str, Any] | None:
},
("expected", "dimensions", "1024"),
id="speaker_embedding_wrong_dim",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"wav_ok",
@@ -767,7 +758,6 @@ def _voices_upload_multipart_files(kind: str | None) -> dict[str, Any] | None:
},
("mutually exclusive", "audio_sample", "speaker_embedding"),
id="audio_and_embedding_mutually_exclusive",
marks=_SKIP_ISSUE_3649,
),
],
)
@@ -29,6 +29,7 @@ from vllm_omni.entrypoints.openai.audio_utils_mixin import AudioMixin
from vllm_omni.entrypoints.openai.protocol.audio import (
BatchSpeechRequest,
CreateAudio,
OpenAICreateAudioGenerateRequest,
OpenAICreateSpeechRequest,
SpeechBatchItem,
StreamingSpeechSessionConfig,
@@ -2292,26 +2293,260 @@ def test_api_server_create_speech_wraps_error_response_status(mocker: MockerFixt
)
)
raw_request = _make_api_server_request(handler, path="/v1/audio/speech")
request = OpenAICreateSpeechRequest(input="Hello")
response = asyncio.run(api_server_module.create_speech(request, raw_request))
_assert_openai_error_response(response, status_code=400, message="bad request")
def _make_api_server_request(handler, *, method: str = "POST", path: str = "/v1/audio/voices") -> Request:
app = FastAPI()
app.state.openai_serving_speech = handler
scope = {
"type": "http",
"app": app,
"method": "POST",
"path": "/v1/audio/speech",
"method": method,
"path": path,
"headers": [],
"query_string": b"",
"client": ("127.0.0.1", 12345),
"server": ("testserver", 80),
"scheme": "http",
}
raw_request = Request(scope)
return Request(scope)
def _patch_api_server_base(mocker: MockerFixture):
def _fake_create_error_response(message, err_type="BadRequestError", status_code=400, param=None):
return ErrorResponse(
error=ErrorInfo(
message=message,
type=err_type,
param=param,
code=getattr(status_code, "value", status_code),
)
)
fake_base = mocker.MagicMock()
fake_base.create_error_response.side_effect = _fake_create_error_response
mocker.patch.object(api_server_module, "base", return_value=fake_base)
return fake_base
def _assert_openai_error_response(
response: JSONResponse,
*,
status_code: int,
message: str,
err_type: str = "BadRequestError",
) -> None:
assert isinstance(response, JSONResponse)
assert response.status_code == status_code
body = json.loads(response.body)
assert body["error"]["code"] == status_code
assert body["error"]["type"] == err_type
assert message in body["error"]["message"]
def test_api_server_list_voices_without_speech_handler_returns_404(mocker: MockerFixture):
_patch_api_server_base(mocker)
raw_request = _make_api_server_request(None, method="GET")
response = asyncio.run(api_server_module.list_voices(raw_request))
_assert_openai_error_response(
response, status_code=404, message="does not support Speech API", err_type="NotFoundError"
)
def test_api_server_upload_voice_value_error_returns_400(mocker: MockerFixture):
_patch_api_server_base(mocker)
handler = mocker.MagicMock()
handler.upload_voice = mocker.AsyncMock(side_effect=ValueError("Unsupported MIME type: audio/x-m4a"))
raw_request = _make_api_server_request(handler)
response = asyncio.run(
api_server_module.upload_voice(
raw_request,
audio_sample=mocker.MagicMock(),
speaker_embedding=None,
consent="cons_test",
name="probe",
)
)
_assert_openai_error_response(response, status_code=400, message="Unsupported MIME type")
def test_api_server_upload_voice_without_speech_handler_returns_404(mocker: MockerFixture):
_patch_api_server_base(mocker)
raw_request = _make_api_server_request(None)
response = asyncio.run(
api_server_module.upload_voice(
raw_request,
consent="cons_test",
name="probe",
)
)
_assert_openai_error_response(
response, status_code=404, message="does not support Speech API", err_type="NotFoundError"
)
def test_api_server_upload_voice_without_input_returns_400(mocker: MockerFixture):
_patch_api_server_base(mocker)
raw_request = _make_api_server_request(mocker.MagicMock())
response = asyncio.run(
api_server_module.upload_voice(
raw_request,
audio_sample=None,
speaker_embedding=None,
consent="cons_test",
name="probe",
)
)
_assert_openai_error_response(response, status_code=400, message="must be provided")
def test_api_server_upload_voice_with_audio_and_embedding_returns_400(mocker: MockerFixture):
_patch_api_server_base(mocker)
raw_request = _make_api_server_request(mocker.MagicMock())
response = asyncio.run(
api_server_module.upload_voice(
raw_request,
audio_sample=mocker.MagicMock(),
speaker_embedding="[0.1]",
consent="cons_test",
name="probe",
)
)
_assert_openai_error_response(response, status_code=400, message="mutually exclusive")
def test_api_server_upload_voice_exception_returns_500(mocker: MockerFixture):
_patch_api_server_base(mocker)
handler = mocker.MagicMock()
handler.upload_voice = mocker.AsyncMock(side_effect=RuntimeError("disk failed"))
raw_request = _make_api_server_request(handler)
response = asyncio.run(
api_server_module.upload_voice(
raw_request,
audio_sample=mocker.MagicMock(),
speaker_embedding=None,
consent="cons_test",
name="probe",
)
)
_assert_openai_error_response(
response,
status_code=500,
message="Failed to upload voice",
err_type="InternalServerError",
)
def test_api_server_delete_voice_without_speech_handler_returns_404(mocker: MockerFixture):
_patch_api_server_base(mocker)
raw_request = _make_api_server_request(None, method="DELETE", path="/v1/audio/voices/probe")
response = asyncio.run(api_server_module.delete_voice("probe", raw_request))
_assert_openai_error_response(
response, status_code=404, message="does not support Speech API", err_type="NotFoundError"
)
def test_api_server_delete_voice_value_error_returns_400(mocker: MockerFixture):
_patch_api_server_base(mocker)
handler = mocker.MagicMock()
handler.delete_voice = mocker.AsyncMock(side_effect=ValueError("Invalid voice name"))
raw_request = _make_api_server_request(handler, method="DELETE", path="/v1/audio/voices/probe")
response = asyncio.run(api_server_module.delete_voice("probe", raw_request))
_assert_openai_error_response(response, status_code=400, message="Invalid voice name")
def test_api_server_delete_voice_not_found_returns_404(mocker: MockerFixture):
_patch_api_server_base(mocker)
handler = mocker.MagicMock()
handler.delete_voice = mocker.AsyncMock(return_value=False)
raw_request = _make_api_server_request(handler, method="DELETE", path="/v1/audio/voices/missing")
response = asyncio.run(api_server_module.delete_voice("missing", raw_request))
_assert_openai_error_response(
response,
status_code=404,
message="Voice 'missing' not found",
err_type="NotFoundError",
)
def test_api_server_delete_voice_exception_returns_500(mocker: MockerFixture):
_patch_api_server_base(mocker)
handler = mocker.MagicMock()
handler.delete_voice = mocker.AsyncMock(side_effect=RuntimeError("disk failed"))
raw_request = _make_api_server_request(handler, method="DELETE", path="/v1/audio/voices/probe")
response = asyncio.run(api_server_module.delete_voice("probe", raw_request))
_assert_openai_error_response(
response,
status_code=500,
message="Failed to delete voice",
err_type="InternalServerError",
)
def test_api_server_create_speech_without_handler_returns_404(mocker: MockerFixture):
fake_base = _patch_api_server_base(mocker)
raw_request = _make_api_server_request(None, path="/v1/audio/speech")
raw_request.app.state.openai_serving_tokenization = fake_base
request = OpenAICreateSpeechRequest(input="Hello")
response = asyncio.run(api_server_module.create_speech(request, raw_request))
assert isinstance(response, JSONResponse)
assert response.status_code == 400
_assert_openai_error_response(
response, status_code=404, message="does not support Speech API", err_type="NotFoundError"
)
def test_api_server_create_speech_batch_without_handler_returns_404(mocker: MockerFixture):
fake_base = _patch_api_server_base(mocker)
raw_request = _make_api_server_request(None, path="/v1/audio/speech/batch")
raw_request.app.state.openai_serving_tokenization = fake_base
request = BatchSpeechRequest(items=[SpeechBatchItem(input="hi")])
response = asyncio.run(api_server_module.create_speech_batch(request, raw_request))
_assert_openai_error_response(
response, status_code=404, message="does not support Speech API", err_type="NotFoundError"
)
def test_api_server_create_audio_generate_without_handler_returns_404(mocker: MockerFixture):
fake_base = _patch_api_server_base(mocker)
raw_request = _make_api_server_request(None, path="/v1/audio/generate")
raw_request.app.state.openai_serving_audio_generate = None
raw_request.app.state.openai_serving_tokenization = fake_base
request = OpenAICreateAudioGenerateRequest(input="a bird singing")
response = asyncio.run(api_server_module.create_audio_generate(request, raw_request))
_assert_openai_error_response(
response, status_code=404, message="does not support Audio Generate API", err_type="NotFoundError"
)
def test_api_server_create_speech_engine_error_response_includes_request_and_stage_id(mocker: MockerFixture):
@@ -2325,26 +2560,13 @@ def test_api_server_create_speech_engine_error_response_includes_request_and_sta
terminate_mock = mocker.patch.object(api_server_module, "terminate_if_errored")
app = FastAPI()
app.state.args = SimpleNamespace(log_error_stack=False)
app.state.openai_serving_speech = handler
app.state.engine_client = SimpleNamespace(
raw_request = _make_api_server_request(handler, path="/v1/audio/speech")
raw_request.app.state.args = SimpleNamespace(log_error_stack=False)
raw_request.app.state.engine_client = SimpleNamespace(
engine=SimpleNamespace(is_alive=lambda: False),
errored=True,
)
app.state.server = SimpleNamespace()
scope = {
"type": "http",
"app": app,
"method": "POST",
"path": "/v1/audio/speech",
"headers": [],
"query_string": b"",
"client": ("127.0.0.1", 12345),
"server": ("testserver", 80),
"scheme": "http",
}
raw_request = Request(scope)
raw_request.app.state.server = SimpleNamespace()
raw_request.state.request_metadata = SimpleNamespace(request_id="speech-req-1")
request = OpenAICreateSpeechRequest(input="Hello")
+96 -29
View File
@@ -347,6 +347,38 @@ def _create_engine_error_json_response(
return JSONResponse(content=payload, status_code=status_code)
def _error_response_to_json_response(
err: ErrorResponse,
*,
status_code: HTTPStatus | int | None = None,
default_status_code: HTTPStatus | int = HTTPStatus.BAD_REQUEST,
) -> JSONResponse:
resolved_status = int(
status_code
if status_code is not None
else (err.error.code if err.error and err.error.code is not None else default_status_code)
)
payload = err.model_dump()
if err.error:
payload["error"]["code"] = resolved_status
return JSONResponse(content=payload, status_code=resolved_status)
def _create_speech_error_json_response(
raw_request: Request,
message: str,
*,
err_type: str = "BadRequestError",
status_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
) -> JSONResponse:
err = base(raw_request).create_error_response(
message=message,
err_type=err_type,
status_code=status_code,
)
return _error_response_to_json_response(err, status_code=status_code)
class _DiffusionServingModels:
"""Minimal OpenAIServingModels implementation for diffusion-only servers.
@@ -1231,14 +1263,16 @@ async def create_speech(request: OpenAICreateSpeechRequest, raw_request: Request
status_code=HTTPStatus.NOT_FOUND.value,
detail="The model does not support Speech API",
)
return base_server.create_error_response(message="The model does not support Speech API")
err = base_server.create_error_response(
message="The model does not support Speech API",
err_type="NotFoundError",
status_code=HTTPStatus.NOT_FOUND,
)
return _error_response_to_json_response(err, status_code=HTTPStatus.NOT_FOUND)
try:
result = await handler.create_speech(request, raw_request)
if isinstance(result, ErrorResponse):
return JSONResponse(
content=result.model_dump(),
status_code=result.error.code if result.error else 400,
)
return _error_response_to_json_response(result)
return result
except (EngineGenerateError, EngineDeadError) as exc:
return _create_engine_error_json_response(raw_request, exc)
@@ -1267,14 +1301,16 @@ async def create_speech_batch(request: BatchSpeechRequest, raw_request: Request)
status_code=HTTPStatus.NOT_FOUND.value,
detail="The model does not support Speech API",
)
return base_server.create_error_response(message="The model does not support Speech API")
err = base_server.create_error_response(
message="The model does not support Speech API",
err_type="NotFoundError",
status_code=HTTPStatus.NOT_FOUND,
)
return _error_response_to_json_response(err, status_code=HTTPStatus.NOT_FOUND)
try:
result = await handler.create_speech_batch(request)
if isinstance(result, ErrorResponse):
return JSONResponse(
content=result.model_dump(),
status_code=result.error.code if result.error else 400,
)
return _error_response_to_json_response(result)
return JSONResponse(content=result.model_dump())
except (EngineGenerateError, EngineDeadError) as exc:
return _create_engine_error_json_response(raw_request, exc)
@@ -1305,14 +1341,16 @@ async def create_audio_generate(request: OpenAICreateAudioGenerateRequest, raw_r
status_code=HTTPStatus.NOT_FOUND.value,
detail="The model does not support Audio Generate API",
)
return base_server.create_error_response(message="The model does not support Audio Generate API")
err = base_server.create_error_response(
message="The model does not support Audio Generate API",
err_type="NotFoundError",
status_code=HTTPStatus.NOT_FOUND,
)
return _error_response_to_json_response(err, status_code=HTTPStatus.NOT_FOUND)
try:
result = await handler.create_audio_generate(request, raw_request)
if isinstance(result, ErrorResponse):
return JSONResponse(
content=result.model_dump(),
status_code=result.error.code if result.error else 400,
)
return _error_response_to_json_response(result)
return result
except (EngineGenerateError, EngineDeadError) as exc:
return _create_engine_error_json_response(raw_request, exc)
@@ -1342,7 +1380,12 @@ async def list_voices(raw_request: Request):
"""
handler = Omnispeech(raw_request)
if handler is None:
return base(raw_request).create_error_response(message="The model does not support Speech API")
return _create_speech_error_json_response(
raw_request,
"The model does not support Speech API",
err_type="NotFoundError",
status_code=HTTPStatus.NOT_FOUND,
)
# Get all speakers (both model built-in and uploaded)
speakers = sorted(handler.supported_speakers) if handler.supported_speakers else []
@@ -1374,6 +1417,7 @@ async def list_voices(raw_request: Request):
responses={
HTTPStatus.OK.value: {"model": dict},
HTTPStatus.BAD_REQUEST.value: {"model": ErrorResponse},
HTTPStatus.NOT_FOUND.value: {"model": ErrorResponse},
HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse},
},
)
@@ -1415,12 +1459,17 @@ async def upload_voice(
"""
handler = Omnispeech(raw_request)
if handler is None:
return base(raw_request).create_error_response(message="The model does not support Speech API")
return _create_speech_error_json_response(
raw_request,
"The model does not support Speech API",
err_type="NotFoundError",
status_code=HTTPStatus.NOT_FOUND,
)
try:
if speaker_embedding is not None and audio_sample is not None:
return base(raw_request).create_error_response(
message="'audio_sample' and 'speaker_embedding' are mutually exclusive"
return _create_speech_error_json_response(
raw_request, "'audio_sample' and 'speaker_embedding' are mutually exclusive"
)
if speaker_embedding is not None:
result = await handler.upload_voice_embedding(speaker_embedding, consent, name)
@@ -1433,23 +1482,29 @@ async def upload_voice(
speaker_description=speaker_description,
)
else:
return base(raw_request).create_error_response(
message="Either 'audio_sample' or 'speaker_embedding' must be provided"
return _create_speech_error_json_response(
raw_request, "Either 'audio_sample' or 'speaker_embedding' must be provided"
)
return JSONResponse(content={"success": True, "voice": result})
except ValueError as e:
return base(raw_request).create_error_response(message=str(e))
return _create_speech_error_json_response(raw_request, str(e))
except Exception as e:
logger.exception(f"Failed to upload voice: {e}")
return base(raw_request).create_error_response(message=f"Failed to upload voice: {str(e)}")
return _create_speech_error_json_response(
raw_request,
f"Failed to upload voice: {str(e)}",
err_type="InternalServerError",
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
)
@router.delete(
"/v1/audio/voices/{name}",
responses={
HTTPStatus.OK.value: {"model": dict},
HTTPStatus.BAD_REQUEST.value: {"model": ErrorResponse},
HTTPStatus.NOT_FOUND.value: {"model": ErrorResponse},
HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse},
},
@@ -1469,24 +1524,36 @@ async def delete_voice(name: str, raw_request: Request):
"""
handler = Omnispeech(raw_request)
if handler is None:
return base(raw_request).create_error_response(message="The model does not support Speech API")
return _create_speech_error_json_response(
raw_request,
"The model does not support Speech API",
err_type="NotFoundError",
status_code=HTTPStatus.NOT_FOUND,
)
try:
# Delete the voice
success = await handler.delete_voice(name)
if not success:
return JSONResponse(
content={"success": False, "error": f"Voice '{name}' not found"},
status_code=HTTPStatus.NOT_FOUND.value,
return _create_speech_error_json_response(
raw_request,
f"Voice '{name}' not found",
err_type="NotFoundError",
status_code=HTTPStatus.NOT_FOUND,
)
return JSONResponse(content={"success": True, "message": f"Voice '{name}' deleted successfully"})
except ValueError as e:
return base(raw_request).create_error_response(message=str(e))
return _create_speech_error_json_response(raw_request, str(e))
except Exception as e:
logger.exception(f"Failed to delete voice '{name}': {e}")
return base(raw_request).create_error_response(message=f"Failed to delete voice: {str(e)}")
return _create_speech_error_json_response(
raw_request,
f"Failed to delete voice: {str(e)}",
err_type="InternalServerError",
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
)
@router.websocket("/v1/audio/speech/stream")