From be7b9319d2017cd3590cee2805fe90574260a035 Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 4 Jun 2026 04:53:14 -0700 Subject: [PATCH] fix(proxy): disable proxy buffering on streaming SSE responses (#29557) Streaming responses from the proxy (/chat/completions, /v1/messages, /v1/responses, assistants) all return through create_response() but never sent the headers that tell an intermediary reverse proxy not to buffer the SSE stream. nginx with the default proxy_buffering, k8s ingress-nginx, and Envoy/Istio sidecars therefore hold the whole stream and release it in one batch, which looks like a broken/buffered stream to the client even though litellm is yielding chunks incrementally. Add Cache-Control: no-cache and X-Accel-Buffering: no to every StreamingResponse create_response() returns, matching what the proxy already does for its own usage/policy SSE endpoints. Fixes #28384. --- litellm/proxy/common_request_processing.py | 13 +++++++--- .../proxy/test_common_request_processing.py | 25 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 36acd9653e..6558543370 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -249,6 +249,13 @@ async def create_response( # noqa: PLR0915 If the first chunk is an error, return a standard JSON error response. Otherwise, return StreamingResponse and stream all content. """ + # Tell buffering reverse proxies (nginx, ingress-nginx, Envoy) to flush SSE + # immediately instead of releasing the whole stream in one batch (issue #28384). + streaming_headers = { + **headers, + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + } first_chunk_value: Optional[str] = None final_status_code = default_status_code @@ -300,7 +307,7 @@ async def create_response( # noqa: PLR0915 return StreamingResponse( empty_gen(), media_type=media_type, - headers=headers, + headers=streaming_headers, status_code=default_status_code, ) except Exception as e: @@ -338,7 +345,7 @@ async def create_response( # noqa: PLR0915 return StreamingResponse( error_gen_message(), media_type=media_type, - headers=headers, + headers=streaming_headers, status_code=error_status, ) @@ -360,7 +367,7 @@ async def create_response( # noqa: PLR0915 return StreamingResponse( combined_generator(), media_type=media_type, - headers=headers, + headers=streaming_headers, status_code=final_status_code, ) diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index 265a82d4a4..0f5a0cbe4b 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -1258,6 +1258,31 @@ class TestCommonRequestProcessingHelpers: ) assert response.headers["x-custom-header"] == "TestValue" + async def test_create_streaming_response_disables_proxy_buffering(self): + """Regression for #28384: every StreamingResponse create_response returns + must carry the headers that stop nginx/ingress/Envoy from buffering the + SSE stream into one batch, while preserving caller-supplied headers.""" + + async def normal_stream(): + yield 'data: {"content": "part"}\n\n' + yield "data: [DONE]\n\n" + + async def empty_stream(): + if False: # never yields -> StopAsyncIteration + yield + + error_stream = AsyncMock() + error_stream.__anext__.side_effect = ValueError("boom") + + for generator in (normal_stream(), empty_stream(), error_stream): + response = await create_response( + generator, "text/event-stream", {"X-Custom-Header": "keep"} + ) + assert isinstance(response, StreamingResponse) + assert response.headers["x-accel-buffering"] == "no" + assert response.headers["cache-control"] == "no-cache" + assert response.headers["x-custom-header"] == "keep" + async def test_create_streaming_response_non_default_status_code(self): async def mock_generator(): yield 'data: {"content": "data"}\n\n'