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:
Mateo Wang 2026-06-16 22:43:10 -07:00 committed by GitHub
parent 17b88719a2
commit cf2db415b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 113 additions and 7 deletions

View file

@ -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

View file

@ -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,
)
)