From 7ec44c042a03ab71448bb74fc15f88f74e4d34b4 Mon Sep 17 00:00:00 2001 From: richboyneedcash <273099414+richboyneedcash@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:37:55 +0800 Subject: [PATCH] fix: send whisper timestamp_granularities as bracketed array field OpenAI's multipart transcription API expects array params as repeated bracketed fields (timestamp_granularities[]=segment & timestamp_granularities[]=word). LiteLLM stored the list under the bare timestamp_granularities key, so OpenAI kept only the last value and a combined ["segment", "word"] request silently returned just one granularity (words OR segments, never both). Rename the key to timestamp_granularities[] in transform_audio_transcription_request so httpx encodes it as the repeated bracketed fields OpenAI expects. This matches how the official openai-python client serializes array form fields (array_format="brackets"). Fixes the multi-value case; single-value arrays already worked and continue to. Co-authored-by: TRAE CLI --- .../transcriptions/whisper_transformation.py | 10 ++++ .../test_whisper_transformation.py | 50 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/litellm/llms/openai/transcriptions/whisper_transformation.py b/litellm/llms/openai/transcriptions/whisper_transformation.py index 5e4bb19cbb3..70fd08290a2 100644 --- a/litellm/llms/openai/transcriptions/whisper_transformation.py +++ b/litellm/llms/openai/transcriptions/whisper_transformation.py @@ -109,6 +109,16 @@ class OpenAIWhisperAudioTranscriptionConfig(BaseAudioTranscriptionConfig): if "response_format" not in data: data["response_format"] = "verbose_json" # ensures 'duration' is received - used for cost calculation + # OpenAI's multipart form API expects array params as repeated + # bracketed fields (`timestamp_granularities[]=segment` + + # `timestamp_granularities[]=word`). Sending the list under the bare + # `timestamp_granularities` key makes OpenAI keep only the last value, + # so a combined `["segment", "word"]` request silently returns just one + # granularity. Rename the key so the list is encoded as `[]` fields. + granularities = data.pop("timestamp_granularities", None) + if granularities is not None: + data["timestamp_granularities[]"] = granularities + return AudioTranscriptionRequestData( data=data, ) diff --git a/tests/test_litellm/llms/openai/transcriptions/test_whisper_transformation.py b/tests/test_litellm/llms/openai/transcriptions/test_whisper_transformation.py index 2dc24b1b313..ccc6aead6e2 100644 --- a/tests/test_litellm/llms/openai/transcriptions/test_whisper_transformation.py +++ b/tests/test_litellm/llms/openai/transcriptions/test_whisper_transformation.py @@ -48,6 +48,56 @@ class TestWhisperTransformRequestResponseFormat: assert data["response_format"] == "verbose_json" +class TestWhisperTransformTimestampGranularities: + """ + OpenAI's multipart form API expects array params as repeated bracketed + fields (``timestamp_granularities[]``). If the list is sent under the bare + ``timestamp_granularities`` key, OpenAI keeps only the last value, so a + combined ``["segment", "word"]`` request silently returns one granularity. + """ + + 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_multi_value_list_uses_bracketed_key(self): + """A multi-value list is sent under the bracketed key so both are honored.""" + data = self._transform( + { + "response_format": "verbose_json", + "timestamp_granularities": ["segment", "word"], + } + ) + assert data["timestamp_granularities[]"] == ["segment", "word"] + # The bare key must not be present, otherwise OpenAI drops all but the last. + assert "timestamp_granularities" not in data + + def test_single_value_list_uses_bracketed_key(self): + """A single-value list is also sent under the bracketed key for consistency.""" + data = self._transform( + { + "response_format": "verbose_json", + "timestamp_granularities": ["word"], + } + ) + assert data["timestamp_granularities[]"] == ["word"] + assert "timestamp_granularities" not in data + + def test_absent_granularities_not_added(self): + """When timestamp_granularities is not provided, no bracketed key is added.""" + data = self._transform({"response_format": "verbose_json"}) + assert "timestamp_granularities[]" not in data + assert "timestamp_granularities" not in data + + class TestWhisperTransformResponse: def _make_response(self, *, text: str, content_type: str, is_json: bool): mock = MagicMock()