diff --git a/litellm/llms/openai/transcriptions/whisper_transformation.py b/litellm/llms/openai/transcriptions/whisper_transformation.py index fa507e1bc26..2c01156fe05 100644 --- a/litellm/llms/openai/transcriptions/whisper_transformation.py +++ b/litellm/llms/openai/transcriptions/whisper_transformation.py @@ -1,3 +1,4 @@ +import json from typing import List, Optional, Union from httpx import Headers, Response @@ -107,9 +108,7 @@ class OpenAIWhisperAudioTranscriptionConfig(BaseAudioTranscriptionConfig): """ data = {"model": model, "file": audio_file, **optional_params} - if "response_format" not in data or ( - data["response_format"] == "text" or data["response_format"] == "json" - ): + if "response_format" not in data: data["response_format"] = ( "verbose_json" # ensures 'duration' is received - used for cost calculation ) @@ -133,10 +132,11 @@ class OpenAIWhisperAudioTranscriptionConfig(BaseAudioTranscriptionConfig): ) -> TranscriptionResponse: try: raw_response_json = raw_response.json() - except Exception as e: - raise ValueError( - f"Error transforming response to json: {str(e)}\nResponse: {raw_response.text}" - ) + except json.JSONDecodeError: + content_type = raw_response.headers.get("content-type", "").lower() + if "application/json" in content_type: + raise + return TranscriptionResponse(text=raw_response.text) if any( key in raw_response_json diff --git a/tests/test_litellm/llms/openai/transcriptions/test_whisper_transformation.py b/tests/test_litellm/llms/openai/transcriptions/test_whisper_transformation.py new file mode 100644 index 00000000000..2dc24b1b313 --- /dev/null +++ b/tests/test_litellm/llms/openai/transcriptions/test_whisper_transformation.py @@ -0,0 +1,106 @@ +""" +Tests for OpenAIWhisperAudioTranscriptionConfig.transform_audio_transcription_request +and transform_audio_transcription_response. +""" + +import io +import json +from unittest.mock import MagicMock + +import pytest + +from litellm.llms.openai.transcriptions.whisper_transformation import ( + OpenAIWhisperAudioTranscriptionConfig, +) + + +class TestWhisperTransformRequestResponseFormat: + def _transform(self, optional_params: dict) -> dict: + config = OpenAIWhisperAudioTranscriptionConfig() + audio_file = io.BytesIO(b"fake audio") + audio_file.name = "test.wav" + result = config.transform_audio_transcription_request( + model="whisper-1", + audio_file=audio_file, + optional_params=optional_params, + litellm_params={}, + ) + return result.data + + def test_defaults_to_verbose_json_when_unset(self): + """When response_format is not specified, default to verbose_json for cost calculation.""" + data = self._transform({}) + assert data["response_format"] == "verbose_json" + + def test_respects_explicit_json(self): + """When response_format='json' is set, do not override to verbose_json.""" + data = self._transform({"response_format": "json"}) + assert data["response_format"] == "json" + + def test_respects_explicit_text(self): + """When response_format='text' is set, do not override to verbose_json.""" + data = self._transform({"response_format": "text"}) + assert data["response_format"] == "text" + + def test_preserves_verbose_json_when_set(self): + """verbose_json explicitly set by the caller stays as-is.""" + data = self._transform({"response_format": "verbose_json"}) + assert data["response_format"] == "verbose_json" + + +class TestWhisperTransformResponse: + def _make_response(self, *, text: str, content_type: str, is_json: bool): + mock = MagicMock() + mock.headers = {"content-type": content_type} + if is_json: + mock.json.return_value = {"text": text} + else: + mock.json.side_effect = json.JSONDecodeError("", "", 0) + mock.text = text + return mock + + def test_parses_json_response(self): + """JSON body (verbose_json or json format) is parsed into TranscriptionResponse.""" + config = OpenAIWhisperAudioTranscriptionConfig() + result = config.transform_audio_transcription_response( + self._make_response( + text="Hello world", content_type="application/json", is_json=True + ) + ) + assert result.text == "Hello world" + + def test_parses_plain_text_response(self): + """Plain-text body (response_format=text) is returned as TranscriptionResponse without error.""" + config = OpenAIWhisperAudioTranscriptionConfig() + result = config.transform_audio_transcription_response( + self._make_response( + text="Four score and seven years ago", + content_type="text/plain", + is_json=False, + ) + ) + assert result.text == "Four score and seven years ago" + + def test_malformed_json_body_with_json_content_type_raises(self): + """A non-JSON body labelled application/json is a genuine upstream error, not a transcription.""" + config = OpenAIWhisperAudioTranscriptionConfig() + with pytest.raises(json.JSONDecodeError): + config.transform_audio_transcription_response( + self._make_response( + text="502 Bad Gateway", + content_type="application/json", + is_json=False, + ) + ) + + def test_json_content_type_match_is_case_insensitive(self): + """Media types are case-insensitive (RFC 7231), so a mixed-case application/json still re-raises.""" + config = OpenAIWhisperAudioTranscriptionConfig() + with pytest.raises(json.JSONDecodeError): + config.transform_audio_transcription_response( + self._make_response( + text="502 Bad Gateway", + content_type="Application/JSON; charset=utf-8", + is_json=False, + ) + )