mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
fix(audio): don't override explicit response_format with verbose_json (#30599)
* fix(audio): don't override explicit response_format with verbose_json
* fix(audio): handle plain-text response body for response_format=text
* fix(audio): only swallow non-JSON transcription body when not declared JSON
Guard the plain-text fallback in transform_audio_transcription_response with
the response Content-Type: a body that fails json() but is labelled
application/json is a genuine upstream error and is re-raised, while
text/plain bodies (response_format=text) are still returned as-is. Prevents
a malformed JSON 2xx from silently becoming a transcription of garbled bytes.
* fix: normalize content-type header case in whisper transcription fallback
* test(audio): lock in case-insensitive content-type guard for transcription fallback
Adds a regression test that a mixed-case 'Application/JSON' content-type still
re-raises a malformed JSON body, covering the case-insensitivity fix in 72982e4
(removing the .lower() normalization fails this test).
---------
Co-authored-by: cohml <62400541+cohml@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
parent
17b88719a2
commit
cf2db415b8
2 changed files with 113 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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="<html>502 Bad Gateway</html>",
|
||||
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="<html>502 Bad Gateway</html>",
|
||||
content_type="Application/JSON; charset=utf-8",
|
||||
is_json=False,
|
||||
)
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue