From 50bc51ffdfffc757b4f089ea039839fad48b0c0b Mon Sep 17 00:00:00 2001 From: Ian Eaves Date: Mon, 22 Jun 2026 17:57:07 -0500 Subject: [PATCH] fix: return proper HTTP status codes for audio voice endpoints (#3969) Signed-off-by: Ian Eaves Signed-off-by: Yueqian Lin Co-authored-by: Yueqian Lin Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com> --- .../test_invalid_audio_speech.py | 10 - .../openai_api/test_serving_speech.py | 266 ++++++++++++++++-- vllm_omni/entrypoints/openai/api_server.py | 125 ++++++-- 3 files changed, 340 insertions(+), 61 deletions(-) diff --git a/tests/dfx/reliability/invalid_param_test/test_invalid_audio_speech.py b/tests/dfx/reliability/invalid_param_test/test_invalid_audio_speech.py index 03845dd16..2c2e30c65 100644 --- a/tests/dfx/reliability/invalid_param_test/test_invalid_audio_speech.py +++ b/tests/dfx/reliability/invalid_param_test/test_invalid_audio_speech.py @@ -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, ), ], ) diff --git a/tests/entrypoints/openai_api/test_serving_speech.py b/tests/entrypoints/openai_api/test_serving_speech.py index 77bdf6d0b..1c85d7fa0 100644 --- a/tests/entrypoints/openai_api/test_serving_speech.py +++ b/tests/entrypoints/openai_api/test_serving_speech.py @@ -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") diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py index 52a76ff44..7de919fc8 100755 --- a/vllm_omni/entrypoints/openai/api_server.py +++ b/vllm_omni/entrypoints/openai/api_server.py @@ -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")