mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
287 lines
8.5 KiB
Python
287 lines
8.5 KiB
Python
"""
|
|
Regression tests for streaming connection pool leak fix.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
from unittest.mock import MagicMock
|
|
|
|
import anyio
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
|
|
import litellm
|
|
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
|
|
from litellm.llms.custom_httpx.aiohttp_transport import (
|
|
AiohttpResponseStream,
|
|
LiteLLMAiohttpTransport,
|
|
)
|
|
|
|
|
|
# ── aiohttp transport layer tests ──────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aiohttp_transport_response_uses_stream_not_content():
|
|
"""handle_async_request must use stream= so aclose() propagates to AiohttpResponseStream."""
|
|
|
|
class FakeSession:
|
|
closed = False
|
|
|
|
def __init__(self):
|
|
try:
|
|
self._loop = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
self._loop = None
|
|
|
|
def request(self, **kwargs):
|
|
class Resp:
|
|
status = 200
|
|
headers = {}
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *args):
|
|
pass
|
|
|
|
@property
|
|
def content(self):
|
|
class C:
|
|
async def iter_chunked(self, size):
|
|
yield b"data"
|
|
|
|
return C()
|
|
|
|
return Resp()
|
|
|
|
transport = LiteLLMAiohttpTransport(client=lambda: FakeSession()) # type: ignore
|
|
response = await transport.handle_async_request(httpx.Request("GET", "http://example.com"))
|
|
|
|
assert isinstance(response.stream, AiohttpResponseStream)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aiohttp_response_stream_aclose_releases_connection():
|
|
"""AiohttpResponseStream.aclose() must call __aexit__ on the aiohttp response."""
|
|
aexit_called = False
|
|
|
|
class MockResponse:
|
|
status = 200
|
|
headers = {}
|
|
|
|
@property
|
|
def content(self):
|
|
class C:
|
|
async def iter_chunked(self, size):
|
|
yield b"data"
|
|
|
|
return C()
|
|
|
|
async def __aexit__(self, *args):
|
|
nonlocal aexit_called
|
|
aexit_called = True
|
|
|
|
stream = AiohttpResponseStream(MockResponse()) # type: ignore
|
|
await stream.aclose()
|
|
assert aexit_called
|
|
|
|
|
|
# ── CustomStreamWrapper.aclose() tests ─────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aclose_falls_back_to_close():
|
|
"""OpenAI's AsyncStream has close() but not aclose(). Must fall back."""
|
|
close_called = False
|
|
|
|
class FakeAsyncStream:
|
|
async def close(self):
|
|
nonlocal close_called
|
|
close_called = True
|
|
|
|
wrapper = CustomStreamWrapper(
|
|
completion_stream=FakeAsyncStream(),
|
|
model=None,
|
|
logging_obj=MagicMock(),
|
|
custom_llm_provider=None,
|
|
)
|
|
|
|
await wrapper.aclose()
|
|
assert close_called
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aclose_prefers_aclose_over_close():
|
|
"""When both aclose() and close() exist, aclose() should be preferred."""
|
|
aclose_called = False
|
|
close_called = False
|
|
|
|
class FakeStream:
|
|
async def aclose(self):
|
|
nonlocal aclose_called
|
|
aclose_called = True
|
|
|
|
async def close(self):
|
|
nonlocal close_called
|
|
close_called = True
|
|
|
|
wrapper = CustomStreamWrapper(
|
|
completion_stream=FakeStream(),
|
|
model=None,
|
|
logging_obj=MagicMock(),
|
|
custom_llm_provider=None,
|
|
)
|
|
|
|
await wrapper.aclose()
|
|
assert aclose_called
|
|
assert not close_called
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aclose_completes_under_cancellation():
|
|
"""aclose() must shield cleanup from CancelledError so streams actually close."""
|
|
aclose_completed = False
|
|
|
|
class SlowCloseStream:
|
|
async def aclose(self):
|
|
await anyio.sleep(0)
|
|
nonlocal aclose_completed
|
|
aclose_completed = True
|
|
|
|
wrapper = CustomStreamWrapper(
|
|
completion_stream=SlowCloseStream(),
|
|
model=None,
|
|
logging_obj=MagicMock(),
|
|
custom_llm_provider=None,
|
|
)
|
|
|
|
with anyio.CancelScope() as scope:
|
|
scope.cancel()
|
|
await wrapper.aclose()
|
|
|
|
assert aclose_completed
|
|
|
|
|
|
# ── Router stream_with_fallbacks cleanup tests ──────────────────
|
|
|
|
|
|
def _sse_chunk(content: str, finish_reason: str | None = None) -> bytes:
|
|
payload = {
|
|
"id": "chatcmpl-test",
|
|
"object": "chat.completion.chunk",
|
|
"created": 1,
|
|
"model": "gpt-4",
|
|
"choices": [{"index": 0, "delta": {"content": content}, "finish_reason": finish_reason}],
|
|
}
|
|
return f"data: {json.dumps(payload)}\n\n".encode()
|
|
|
|
|
|
_DONE = b"data: [DONE]\n\n"
|
|
|
|
|
|
class _TrackingStream(httpx.AsyncByteStream):
|
|
"""An upstream SSE body that records whether the router closed it."""
|
|
|
|
def __init__(self, parts: list[bytes], fail: bool = False):
|
|
self._parts = parts
|
|
self._fail = fail
|
|
self.closed = False
|
|
|
|
async def __aiter__(self):
|
|
for part in self._parts:
|
|
yield part
|
|
if self._fail:
|
|
raise httpx.ReadError("upstream dropped the connection")
|
|
|
|
async def aclose(self):
|
|
self.closed = True
|
|
|
|
|
|
def _full_stream() -> _TrackingStream:
|
|
return _TrackingStream([_sse_chunk("c1"), _sse_chunk("c2"), _sse_chunk("c3", "stop"), _DONE])
|
|
|
|
|
|
@pytest.fixture
|
|
def stream_router(monkeypatch: pytest.MonkeyPatch):
|
|
from litellm.router import Router
|
|
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
litellm.in_memory_llm_clients_cache.flush_cache()
|
|
yield Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "test-model",
|
|
"litellm_params": {
|
|
"model": "openai/test",
|
|
"api_key": "fake",
|
|
"api_base": "https://primary.example/v1",
|
|
},
|
|
},
|
|
{
|
|
"model_name": "other-model",
|
|
"litellm_params": {
|
|
"model": "openai/test",
|
|
"api_key": "fake",
|
|
"api_base": "https://fallback.example/v1",
|
|
},
|
|
},
|
|
],
|
|
fallbacks=[{"test-model": ["other-model"]}],
|
|
)
|
|
litellm.in_memory_llm_clients_cache.flush_cache()
|
|
|
|
|
|
async def _open_stream(router, primary: _TrackingStream, fallback: _TrackingStream, mock_router):
|
|
for host, body in (("primary", primary), ("fallback", fallback)):
|
|
mock_router.post(f"https://{host}.example/v1/chat/completions").mock(
|
|
return_value=httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=body)
|
|
)
|
|
return await router.acompletion(model="test-model", messages=[{"role": "user", "content": "hi"}], stream=True)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_with_fallbacks_closes_stream_on_generator_close(stream_router):
|
|
"""Closing the router's stream after one chunk (a client disconnect) must
|
|
close the upstream response."""
|
|
primary = _full_stream()
|
|
|
|
with respx.mock(assert_all_called=False) as mock_router:
|
|
result = await _open_stream(stream_router, primary, _full_stream(), mock_router)
|
|
async for _ in result:
|
|
break
|
|
await result.aclose()
|
|
|
|
assert primary.closed, "upstream stream was not closed when the client disconnected"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_with_fallbacks_closes_stream_on_normal_completion(stream_router):
|
|
"""The upstream response must be closed even on normal completion."""
|
|
primary = _full_stream()
|
|
|
|
with respx.mock(assert_all_called=False) as mock_router:
|
|
result = await _open_stream(stream_router, primary, _full_stream(), mock_router)
|
|
async for _ in result:
|
|
pass
|
|
await result.aclose()
|
|
|
|
assert primary.closed, "upstream stream was not closed after normal completion"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_with_fallbacks_closes_both_on_fallback_disconnect(stream_router):
|
|
"""When a fallback is triggered and the client disconnects during fallback
|
|
iteration, both the primary and the fallback upstream must be closed."""
|
|
primary = _TrackingStream([], fail=True)
|
|
fallback = _full_stream()
|
|
|
|
with respx.mock() as mock_router:
|
|
result = await _open_stream(stream_router, primary, fallback, mock_router)
|
|
async for _ in result:
|
|
break
|
|
await result.aclose()
|
|
|
|
assert primary.closed, "primary upstream stream was not closed"
|
|
assert fallback.closed, "fallback upstream stream was not closed"
|