fix(proxy): keep Azure Speech multipart bodies intact through auth

user_api_key_auth called request.form() on multipart Azure Speech batch uploads, consuming the Starlette stream before the pass-through handler could read the raw bytes. The opaque body predicate now covers multipart on the whole /azure_speech prefix so auth caches an empty parsed body and the upload is forwarded byte for byte

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yassin 2026-09-17 04:14:26 +00:00
parent 6e56ba86c5
commit 1d8f19e4fd
2 changed files with 61 additions and 11 deletions

View file

@ -11,7 +11,6 @@ from typing_extensions import NotRequired, ReadOnly, Required
from litellm._logging import verbose_proxy_logger
from litellm.constants import (
AZURE_SPEECH_PASS_THROUGH_ROUTE_PREFIX,
AZURE_SPEECH_SHORT_AUDIO_PATH_PREFIX,
CLIENT_REQUESTED_MODEL_SCOPE_KEY,
MAX_REQUEST_BODY_SIZE_TO_REPAIR_MB,
)
@ -220,9 +219,11 @@ async def _read_request_body(request: Request | None) -> dict:
def is_opaque_audio_pass_through_request(route: str, content_type: str) -> bool:
return route.startswith(
f"{AZURE_SPEECH_PASS_THROUGH_ROUTE_PREFIX}{AZURE_SPEECH_SHORT_AUDIO_PATH_PREFIX}"
) and _normalize_media_type(content_type).startswith("audio/")
"""Azure Speech bodies (raw audio, multipart uploads) are forwarded byte for byte, so auth must not consume them."""
media_type: Final = _normalize_media_type(content_type)
return route.startswith(f"{AZURE_SPEECH_PASS_THROUGH_ROUTE_PREFIX}/") and (
media_type.startswith("audio/") or media_type == "multipart/form-data"
)
async def read_raw_json_body(request: Request | None) -> bytes | None:

View file

@ -6418,8 +6418,8 @@ def _azure_speech_real_auth_attrs() -> dict[str, object]:
class TestAzureSpeechRawBodyThroughRealAuth:
"""user_api_key_auth reads the body before the route runs; raw audio must not be parsed as JSON."""
def _post_wav(
self, monkeypatch: pytest.MonkeyPatch, path: str, api_key: str, body: bytes = AZURE_SPEECH_WAV_BYTES
def _post(
self, monkeypatch: pytest.MonkeyPatch, path: str, api_key: str, content_type: str, body: bytes
) -> httpx.Response:
from litellm.proxy.proxy_server import app
@ -6437,9 +6437,14 @@ class TestAzureSpeechRawBodyThroughRealAuth:
path,
params={"language": "en-US"},
content=body,
headers={"Content-Type": "audio/wav", "Authorization": f"Bearer {api_key}"},
headers={"Content-Type": content_type, "Authorization": f"Bearer {api_key}"},
)
def _post_wav(
self, monkeypatch: pytest.MonkeyPatch, path: str, api_key: str, body: bytes = AZURE_SPEECH_WAV_BYTES
) -> httpx.Response:
return self._post(monkeypatch, path, api_key, "audio/wav", body)
@pytest.mark.parametrize("body", [AZURE_SPEECH_WAV_BYTES, AZURE_SPEECH_NON_UTF8_WAV_BYTES], ids=["ascii", "binary"])
def test_master_key_with_raw_wav_body_reaches_azure_without_a_parse_attempt(
self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, body: bytes
@ -6466,11 +6471,55 @@ class TestAzureSpeechRawBodyThroughRealAuth:
assert response.status_code in (400, 401), response.text
assert not catch_all.called
@pytest.mark.parametrize("path", ["/v1/chat/completions", f"/azure_speech{AZURE_SPEECH_BATCH_ENDPOINT}"])
def test_audio_content_type_off_the_short_audio_route_is_still_parsed_as_json(
self, monkeypatch: pytest.MonkeyPatch, path: str
def test_master_key_with_multipart_batch_upload_is_forwarded_byte_for_byte(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
response = self._post_wav(monkeypatch, path, "sk-master-key", body=b'{}{"model": "gpt-4o"}')
boundary: Final = "lit7939boundary"
multipart_body: Final = (
f"--{boundary}\r\nContent-Disposition: form-data; name=\"definition\"\r\n\r\n".encode()
+ json.dumps({"locales": ["en-US"]}).encode()
+ f"\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"audio\"; filename=\"eagle.wav\"\r\n"
"Content-Type: audio/wav\r\n\r\n".encode()
+ AZURE_SPEECH_NON_UTF8_WAV_BYTES
+ f"\r\n--{boundary}--\r\n".encode()
)
with respx.mock(assert_all_called=True) as upstream:
route = upstream.post(f"https://eastus.api.cognitive.microsoft.com{AZURE_SPEECH_BATCH_ENDPOINT}").mock(
return_value=httpx.Response(201, json={"status": "NotStarted"})
)
response = self._post(
monkeypatch,
f"/azure_speech{AZURE_SPEECH_BATCH_ENDPOINT}",
"sk-master-key",
f"multipart/form-data; boundary={boundary}",
multipart_body,
)
assert (response.status_code, response.json()) == (201, {"status": "NotStarted"})
sent = route.calls.last.request
assert sent.content == multipart_body
assert sent.headers["content-type"] == f"multipart/form-data; boundary={boundary}"
assert sent.headers["ocp-apim-subscription-key"] == "server-subscription-key"
@pytest.mark.parametrize("content_type", ["audio/wav", "multipart/form-data; boundary=x"])
def test_wrong_litellm_key_with_multipart_batch_upload_is_rejected(
self, monkeypatch: pytest.MonkeyPatch, content_type: str
) -> None:
with respx.mock(assert_all_called=False) as upstream:
catch_all = upstream.route().mock(return_value=httpx.Response(200))
response = self._post(
monkeypatch, f"/azure_speech{AZURE_SPEECH_BATCH_ENDPOINT}", "sk-wrong", content_type, b"--x--\r\n"
)
assert response.status_code in (400, 401), response.text
assert not catch_all.called
def test_audio_content_type_off_the_azure_speech_route_is_still_parsed_as_json(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
response = self._post_wav(monkeypatch, "/v1/chat/completions", "sk-master-key", body=b'{}{"model": "gpt-4o"}')
assert response.status_code == 400
assert "Invalid JSON payload" in response.text