diff --git a/litellm/proxy/common_utils/http_parsing_utils.py b/litellm/proxy/common_utils/http_parsing_utils.py index 1e4344a71f4..18cda6a9f41 100644 --- a/litellm/proxy/common_utils/http_parsing_utils.py +++ b/litellm/proxy/common_utils/http_parsing_utils.py @@ -274,9 +274,8 @@ async def get_form_data(request: Request) -> dict[str, Any]: Handles when OpenAI SDKs pass form keys as `timestamp_granularities[]="word"` instead of `timestamp_granularities=["word", "sentence"]` """ form: Final = await request.form() - form_data: Final = dict(form) parsed_form_data: Final[dict[str, Any]] = {} - for key, value in form_data.items(): + for key, value in form.multi_items(): # OpenAI SDKs pass form keys as `timestamp_granularities[]="word"` instead of `timestamp_granularities=["word", "sentence"]` if key.endswith("[]"): clean_key = key[:-2] diff --git a/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py b/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py index 869d228d5a4..82ae3e71abd 100644 --- a/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py @@ -7,6 +7,7 @@ import orjson import pytest from fastapi import Request from fastapi.testclient import TestClient +from starlette.datastructures import FormData sys.path.insert( 0, os.path.abspath("../../../..") @@ -506,19 +507,20 @@ async def test_get_form_data(): # Create a mock request with transcription form data mock_request = MagicMock() - # Create mock form data with array notation for timestamp_granularities - mock_form_data = { - "file": "file_object", # In a real request this would be an UploadFile - "model": "gpt-4o-transcribe", - "include[]": "logprobs", # Array notation - "language": "en", - "prompt": "Transcribe this audio file", - "response_format": "json", - "stream": "false", - "temperature": "0.2", - "timestamp_granularities[]": "word", # First array item - "timestamp_granularities[]": "segment", # Second array item (would overwrite in dict, but handled by the function) - } + mock_form_data = FormData( + [ + ("file", "file_object"), # In a real request this would be an UploadFile + ("model", "gpt-4o-transcribe"), + ("include[]", "logprobs"), + ("language", "en"), + ("prompt", "Transcribe this audio file"), + ("response_format", "json"), + ("stream", "false"), + ("temperature", "0.2"), + ("timestamp_granularities[]", "word"), + ("timestamp_granularities[]", "segment"), + ] + ) # Mock the form method to return the test data mock_request.form = AsyncMock(return_value=mock_form_data) @@ -540,11 +542,23 @@ async def test_get_form_data(): assert isinstance(result["include"], list) assert "logprobs" in result["include"] - assert "timestamp_granularities" in result - assert isinstance(result["timestamp_granularities"], list) - # Note: In a real MultiDict, both values would be present - # But in our mock dictionary the second value overwrites the first - assert "segment" in result["timestamp_granularities"] + assert result["timestamp_granularities"] == ["word", "segment"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "granularities", + [["segment", "word"], ["word", "segment"], ["segment"], ["word"]], +) +async def test_get_form_data_preserves_every_repeated_array_value(granularities): + mock_request = MagicMock() + mock_request.form = AsyncMock( + return_value=FormData([("model", "whisper-1"), *(("timestamp_granularities[]", g) for g in granularities)]) + ) + + result = await get_form_data(mock_request) + + assert result["timestamp_granularities"] == granularities def test_get_tags_from_request_body_with_metadata_tags(): @@ -969,7 +983,7 @@ class TestReadRequestBodyNonCanonicalContentType: @pytest.mark.asyncio async def test_real_form_post_still_parsed_as_form(self): mock_request = MagicMock() - mock_request.form = AsyncMock(return_value={"k": "v"}) + mock_request.form = AsyncMock(return_value=FormData([("k", "v")])) mock_request.body = AsyncMock(return_value=b"") mock_request.headers = {"content-type": "application/x-www-form-urlencoded"} mock_request.scope = {} @@ -1025,7 +1039,7 @@ class TestGetRequestBody: mock_request = MagicMock() mock_request.method = "POST" mock_request.headers = {"content-type": "multipart/form-data; boundary=x"} - mock_request.form = AsyncMock(return_value={"k": "v"}) + mock_request.form = AsyncMock(return_value=FormData([("k", "v")])) mock_request.scope = {} result = await get_request_body(mock_request) diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index 181846fe289..ebd12de64aa 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -9,6 +9,7 @@ import httpx import pytest from fastapi import Request, Response from fastapi.testclient import TestClient +from starlette.datastructures import FormData sys.path.insert( 0, os.path.abspath("../../../..") @@ -1259,7 +1260,7 @@ async def test_is_streaming_request_fn(): mock_request = Mock() mock_request.method = "POST" mock_request.headers = {"content-type": "multipart/form-data"} - mock_request.form = AsyncMock(return_value={"stream": "true"}) + mock_request.form = AsyncMock(return_value=FormData([("stream", "true")])) assert await is_streaming_request_fn(mock_request) is True