From 1d8650413d4237f9dfce6aec838035618a858d8b Mon Sep 17 00:00:00 2001 From: jesus Date: Mon, 7 Sep 2026 11:51:35 +0000 Subject: [PATCH 1/4] fix(responses): keep background polling alive after the client disconnects Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../response_polling/background_streaming.py | 17 +++++- .../test_response_polling_handler.py | 4 +- .../proxy/response_polling/__init__.py | 0 .../test_background_streaming.py | 52 +++++++++++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 tests/test_litellm/proxy/response_polling/__init__.py create mode 100644 tests/test_litellm/proxy/response_polling/test_background_streaming.py diff --git a/litellm/proxy/response_polling/background_streaming.py b/litellm/proxy/response_polling/background_streaming.py index 0fd242f2bc1..fac45d4391c 100644 --- a/litellm/proxy/response_polling/background_streaming.py +++ b/litellm/proxy/response_polling/background_streaming.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, Final, TypeAlias from fastapi import Request, Response from fastapi.responses import StreamingResponse +from starlette.types import Message from typing_extensions import ReadOnly, TypedDict from litellm._logging import verbose_proxy_logger @@ -74,6 +75,20 @@ class _StreamEventParser: parse: Callable[[str], _StreamEvent] = staticmethod(json.loads) +async def _never_receive() -> Message: + await asyncio.Event().wait() + raise AssertionError("unreachable") + + +def detach_request_from_client(request: Request) -> Request: + """Same scope (headers, parsed body, auth) but a receive() that never yields http.disconnect. + + The polling client closes its connection right after getting the polling id, so the + upstream call must not be cancelled by the client-disconnect guards. + """ + return Request(request.scope, _never_receive) + + async def background_streaming_task( polling_id: str, data: dict[str, object], @@ -123,7 +138,7 @@ async def background_streaming_task( # Pre-call checks (rate limits, guardrails, budget) were already run # before polling ID creation, so skip them here to avoid double-counting. response: Final[StreamingResponse] = await processor.base_process_llm_request( - request=request, + request=detach_request_from_client(request), fastapi_response=fastapi_response, user_api_key_dict=user_api_key_dict, route_type="aresponses", diff --git a/tests/proxy_unit_tests/test_response_polling_handler.py b/tests/proxy_unit_tests/test_response_polling_handler.py index 772d3622745..7a4e0ca89c0 100644 --- a/tests/proxy_unit_tests/test_response_polling_handler.py +++ b/tests/proxy_unit_tests/test_response_polling_handler.py @@ -20,7 +20,7 @@ from typing import Any, Dict, Optional from unittest.mock import AsyncMock, Mock, patch import pytest - +from fastapi import Request from litellm.proxy.response_polling.polling_handler import ResponsePollingHandler @@ -1414,7 +1414,7 @@ def _make_background_streaming_kwargs( polling_id=polling_id, data={"model": "gpt-4o", "stream": False, "background": True}, polling_handler=polling_handler, - request=Mock(), + request=Request({"type": "http", "method": "POST", "path": "/v1/responses", "headers": []}), fastapi_response=Mock(), user_api_key_dict=Mock(), general_settings={}, diff --git a/tests/test_litellm/proxy/response_polling/__init__.py b/tests/test_litellm/proxy/response_polling/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/proxy/response_polling/test_background_streaming.py b/tests/test_litellm/proxy/response_polling/test_background_streaming.py new file mode 100644 index 00000000000..98d914b1848 --- /dev/null +++ b/tests/test_litellm/proxy/response_polling/test_background_streaming.py @@ -0,0 +1,52 @@ +import asyncio +from collections.abc import AsyncGenerator +from typing import Final + +import pytest +from fastapi import Request +from fastapi.responses import JSONResponse, StreamingResponse +from starlette.types import Message + +from litellm.constants import LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED +from litellm.proxy.common_request_processing import create_response +from litellm.proxy.response_polling.background_streaming import detach_request_from_client + + +def _request_whose_client_already_left() -> Request: + async def receive() -> Message: + return {"type": "http.disconnect"} + + scope: Final = { + "type": "http", + "method": "POST", + "path": "/v1/responses", + "headers": [(b"x-litellm-call-id", b"call-123")], + "query_string": b"", + } + return Request(scope, receive) + + +async def _slow_first_chunk() -> AsyncGenerator[str, None]: + await asyncio.sleep(0.05) + yield 'data: {"type": "response.created"}\n\n' + + +@pytest.mark.asyncio +async def test_detached_request_survives_client_disconnect_before_first_chunk(): + original: Final = _request_whose_client_already_left() + + cancelled: Final = await create_response(_slow_first_chunk(), "text/event-stream", {}, request=original) + assert isinstance(cancelled, JSONResponse) + assert cancelled.status_code == LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED + + detached: Final = detach_request_from_client(original) + kept_alive: Final = await create_response(_slow_first_chunk(), "text/event-stream", {}, request=detached) + assert isinstance(kept_alive, StreamingResponse) + assert kept_alive.status_code == 200 + + +def test_detached_request_keeps_scope(): + original: Final = _request_whose_client_already_left() + detached: Final = detach_request_from_client(original) + assert detached.headers["x-litellm-call-id"] == "call-123" + assert detached.scope is original.scope From 9c8caae1e4a793d17493f4de6caebf2cfa1ad651 Mon Sep 17 00:00:00 2001 From: jesus Date: Mon, 7 Sep 2026 11:53:50 +0000 Subject: [PATCH 2/4] ci: add tests/test_litellm/proxy/response_polling to the proxy-endpoints shard Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/test-unit.yml | 1 + Makefile | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index 33245ec5b5f..761960145eb 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -155,6 +155,7 @@ jobs: tests/test_litellm/proxy/vector_store_files_endpoints tests/test_litellm/proxy/video_endpoints tests/test_litellm/proxy/response_api_endpoints + tests/test_litellm/proxy/response_polling tests/test_litellm/proxy/image_endpoints tests/test_litellm/proxy/ocr_endpoints tests/test_litellm/proxy/vector_store_endpoints diff --git a/Makefile b/Makefile index e17fdba3c85..b14aaaf896f 100644 --- a/Makefile +++ b/Makefile @@ -298,7 +298,7 @@ test-unit-proxy-core: install-test-deps $(UV_RUN) pytest tests/test_litellm/proxy/auth tests/test_litellm/proxy/client tests/test_litellm/proxy/db tests/test_litellm/proxy/hooks tests/test_litellm/proxy/policy_engine --tb=short -vv -n 4 --durations=20 test-unit-proxy-misc: install-test-deps - $(UV_RUN) pytest tests/test_litellm/proxy/_experimental tests/test_litellm/proxy/agent_endpoints tests/test_litellm/proxy/anthropic_endpoints tests/test_litellm/proxy/common_utils tests/test_litellm/proxy/discovery_endpoints tests/test_litellm/proxy/experimental tests/test_litellm/proxy/google_endpoints tests/test_litellm/proxy/health_endpoints tests/test_litellm/proxy/image_endpoints tests/test_litellm/proxy/middleware tests/test_litellm/proxy/openai_files_endpoint tests/test_litellm/proxy/pass_through_endpoints tests/test_litellm/proxy/prompts tests/test_litellm/proxy/public_endpoints tests/test_litellm/proxy/response_api_endpoints tests/test_litellm/proxy/shutdown tests/test_litellm/proxy/spend_tracking tests/test_litellm/proxy/ui_crud_endpoints tests/test_litellm/proxy/vector_store_endpoints tests/test_litellm/proxy/test_*.py --tb=short -vv -n 4 --durations=20 + $(UV_RUN) pytest tests/test_litellm/proxy/_experimental tests/test_litellm/proxy/agent_endpoints tests/test_litellm/proxy/anthropic_endpoints tests/test_litellm/proxy/common_utils tests/test_litellm/proxy/discovery_endpoints tests/test_litellm/proxy/experimental tests/test_litellm/proxy/google_endpoints tests/test_litellm/proxy/health_endpoints tests/test_litellm/proxy/image_endpoints tests/test_litellm/proxy/middleware tests/test_litellm/proxy/openai_files_endpoint tests/test_litellm/proxy/pass_through_endpoints tests/test_litellm/proxy/prompts tests/test_litellm/proxy/public_endpoints tests/test_litellm/proxy/response_api_endpoints tests/test_litellm/proxy/response_polling tests/test_litellm/proxy/shutdown tests/test_litellm/proxy/spend_tracking tests/test_litellm/proxy/ui_crud_endpoints tests/test_litellm/proxy/vector_store_endpoints tests/test_litellm/proxy/test_*.py --tb=short -vv -n 4 --durations=20 test-unit-integrations: install-test-deps $(UV_RUN) pytest tests/test_litellm/integrations --tb=short -vv -n 4 --durations=20 From e589a0ed82023d6fa550e6ddfa005c5ebbf4951e Mon Sep 17 00:00:00 2001 From: jesus Date: Mon, 7 Sep 2026 12:07:31 +0000 Subject: [PATCH 3/4] test(responses): cover the polling disconnect regression through background_streaming_task Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/test-unit.yml | 1 - Makefile | 2 +- .../test_response_polling_handler.py | 58 +++++++++++++++++++ .../proxy/response_polling/__init__.py | 0 .../test_background_streaming.py | 52 ----------------- 5 files changed, 59 insertions(+), 54 deletions(-) delete mode 100644 tests/test_litellm/proxy/response_polling/__init__.py delete mode 100644 tests/test_litellm/proxy/response_polling/test_background_streaming.py diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index 761960145eb..33245ec5b5f 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -155,7 +155,6 @@ jobs: tests/test_litellm/proxy/vector_store_files_endpoints tests/test_litellm/proxy/video_endpoints tests/test_litellm/proxy/response_api_endpoints - tests/test_litellm/proxy/response_polling tests/test_litellm/proxy/image_endpoints tests/test_litellm/proxy/ocr_endpoints tests/test_litellm/proxy/vector_store_endpoints diff --git a/Makefile b/Makefile index b14aaaf896f..e17fdba3c85 100644 --- a/Makefile +++ b/Makefile @@ -298,7 +298,7 @@ test-unit-proxy-core: install-test-deps $(UV_RUN) pytest tests/test_litellm/proxy/auth tests/test_litellm/proxy/client tests/test_litellm/proxy/db tests/test_litellm/proxy/hooks tests/test_litellm/proxy/policy_engine --tb=short -vv -n 4 --durations=20 test-unit-proxy-misc: install-test-deps - $(UV_RUN) pytest tests/test_litellm/proxy/_experimental tests/test_litellm/proxy/agent_endpoints tests/test_litellm/proxy/anthropic_endpoints tests/test_litellm/proxy/common_utils tests/test_litellm/proxy/discovery_endpoints tests/test_litellm/proxy/experimental tests/test_litellm/proxy/google_endpoints tests/test_litellm/proxy/health_endpoints tests/test_litellm/proxy/image_endpoints tests/test_litellm/proxy/middleware tests/test_litellm/proxy/openai_files_endpoint tests/test_litellm/proxy/pass_through_endpoints tests/test_litellm/proxy/prompts tests/test_litellm/proxy/public_endpoints tests/test_litellm/proxy/response_api_endpoints tests/test_litellm/proxy/response_polling tests/test_litellm/proxy/shutdown tests/test_litellm/proxy/spend_tracking tests/test_litellm/proxy/ui_crud_endpoints tests/test_litellm/proxy/vector_store_endpoints tests/test_litellm/proxy/test_*.py --tb=short -vv -n 4 --durations=20 + $(UV_RUN) pytest tests/test_litellm/proxy/_experimental tests/test_litellm/proxy/agent_endpoints tests/test_litellm/proxy/anthropic_endpoints tests/test_litellm/proxy/common_utils tests/test_litellm/proxy/discovery_endpoints tests/test_litellm/proxy/experimental tests/test_litellm/proxy/google_endpoints tests/test_litellm/proxy/health_endpoints tests/test_litellm/proxy/image_endpoints tests/test_litellm/proxy/middleware tests/test_litellm/proxy/openai_files_endpoint tests/test_litellm/proxy/pass_through_endpoints tests/test_litellm/proxy/prompts tests/test_litellm/proxy/public_endpoints tests/test_litellm/proxy/response_api_endpoints tests/test_litellm/proxy/shutdown tests/test_litellm/proxy/spend_tracking tests/test_litellm/proxy/ui_crud_endpoints tests/test_litellm/proxy/vector_store_endpoints tests/test_litellm/proxy/test_*.py --tb=short -vv -n 4 --durations=20 test-unit-integrations: install-test-deps $(UV_RUN) pytest tests/test_litellm/integrations --tb=short -vv -n 4 --durations=20 diff --git a/tests/proxy_unit_tests/test_response_polling_handler.py b/tests/proxy_unit_tests/test_response_polling_handler.py index 7a4e0ca89c0..f5335508ce4 100644 --- a/tests/proxy_unit_tests/test_response_polling_handler.py +++ b/tests/proxy_unit_tests/test_response_polling_handler.py @@ -14,6 +14,7 @@ These tests ensure the polling handler correctly manages response state following the OpenAI Response API format. """ +import asyncio import json from datetime import datetime, timezone from typing import Any, Dict, Optional @@ -1663,6 +1664,63 @@ class TestBackgroundStreamingTerminalEvents: final_call = handler.update_state.call_args_list[-1] assert final_call.kwargs["status"] == "completed" + @pytest.mark.asyncio + async def test_polling_client_disconnect_does_not_cancel_upstream_call(self): + """The polling client hangs up right after getting its polling id. The detached task + must still stream the upstream response through the client-disconnect guards.""" + from litellm.proxy.common_request_processing import create_response + from litellm.proxy.response_polling.background_streaming import ( + background_streaming_task, + ) + + async def client_already_left(): + return {"type": "http.disconnect"} + + async def slow_upstream_stream(): + await asyncio.sleep(0.05) + for event in ( + {"type": "response.in_progress"}, + { + "type": "response.completed", + "response": { + "id": "resp_123", + "status": "completed", + "usage": {"input_tokens": 13, "output_tokens": 10}, + "model": "gpt-4o", + "output": [{"id": "item_1", "type": "message"}], + }, + }, + ): + yield f"data: {json.dumps(event)}\n\n" + + async def upstream_call_behind_disconnect_guard(**kwargs): + return await create_response( + slow_upstream_stream(), "text/event-stream", {}, request=kwargs["request"] + ) + + handler = AsyncMock(spec=ResponsePollingHandler) + kwargs = _make_background_streaming_kwargs("poll_7", handler) + kwargs["request"] = Request( + { + "type": "http", + "method": "POST", + "path": "/v1/responses", + "headers": [(b"x-litellm-call-id", b"call-123")], + "query_string": b"", + }, + client_already_left, + ) + + with patch( + "litellm.proxy.response_polling.background_streaming.ProxyBaseLLMRequestProcessing" + ) as MockProcessor: + MockProcessor.return_value.base_process_llm_request = upstream_call_behind_disconnect_guard + await background_streaming_task(**kwargs) + + final_call = handler.update_state.call_args_list[-1] + assert final_call.kwargs["status"] == "completed" + assert final_call.kwargs["usage"] == {"input_tokens": 13, "output_tokens": 10} + class TestEdgeCases: """Test edge cases and error scenarios""" diff --git a/tests/test_litellm/proxy/response_polling/__init__.py b/tests/test_litellm/proxy/response_polling/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/test_litellm/proxy/response_polling/test_background_streaming.py b/tests/test_litellm/proxy/response_polling/test_background_streaming.py deleted file mode 100644 index 98d914b1848..00000000000 --- a/tests/test_litellm/proxy/response_polling/test_background_streaming.py +++ /dev/null @@ -1,52 +0,0 @@ -import asyncio -from collections.abc import AsyncGenerator -from typing import Final - -import pytest -from fastapi import Request -from fastapi.responses import JSONResponse, StreamingResponse -from starlette.types import Message - -from litellm.constants import LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED -from litellm.proxy.common_request_processing import create_response -from litellm.proxy.response_polling.background_streaming import detach_request_from_client - - -def _request_whose_client_already_left() -> Request: - async def receive() -> Message: - return {"type": "http.disconnect"} - - scope: Final = { - "type": "http", - "method": "POST", - "path": "/v1/responses", - "headers": [(b"x-litellm-call-id", b"call-123")], - "query_string": b"", - } - return Request(scope, receive) - - -async def _slow_first_chunk() -> AsyncGenerator[str, None]: - await asyncio.sleep(0.05) - yield 'data: {"type": "response.created"}\n\n' - - -@pytest.mark.asyncio -async def test_detached_request_survives_client_disconnect_before_first_chunk(): - original: Final = _request_whose_client_already_left() - - cancelled: Final = await create_response(_slow_first_chunk(), "text/event-stream", {}, request=original) - assert isinstance(cancelled, JSONResponse) - assert cancelled.status_code == LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED - - detached: Final = detach_request_from_client(original) - kept_alive: Final = await create_response(_slow_first_chunk(), "text/event-stream", {}, request=detached) - assert isinstance(kept_alive, StreamingResponse) - assert kept_alive.status_code == 200 - - -def test_detached_request_keeps_scope(): - original: Final = _request_whose_client_already_left() - detached: Final = detach_request_from_client(original) - assert detached.headers["x-litellm-call-id"] == "call-123" - assert detached.scope is original.scope From 0d27ec952aa6b1df6697d952b90e9ea4569c06e5 Mon Sep 17 00:00:00 2001 From: jesus Date: Mon, 7 Sep 2026 12:14:36 +0000 Subject: [PATCH 4/4] test(responses): annotate the processor patch for the test quality gate Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/proxy_unit_tests/test_response_polling_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/proxy_unit_tests/test_response_polling_handler.py b/tests/proxy_unit_tests/test_response_polling_handler.py index f5335508ce4..467c1332325 100644 --- a/tests/proxy_unit_tests/test_response_polling_handler.py +++ b/tests/proxy_unit_tests/test_response_polling_handler.py @@ -1711,7 +1711,7 @@ class TestBackgroundStreamingTerminalEvents: client_already_left, ) - with patch( + with patch( # test-quality-ok: the processor is built inside the task, same idiom as the sibling tests "litellm.proxy.response_polling.background_streaming.ProxyBaseLLMRequestProcessing" ) as MockProcessor: MockProcessor.return_value.base_process_llm_request = upstream_call_behind_disconnect_guard