mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-20 00:11:50 +00:00
Merge 06da927a9f into 850fe595ac
This commit is contained in:
commit
3ee90e3dc6
3 changed files with 174 additions and 20 deletions
|
|
@ -4,6 +4,7 @@ Helper functions to handle images passed in messages
|
|||
|
||||
import base64
|
||||
|
||||
import httpx
|
||||
from httpx import Response
|
||||
|
||||
import litellm
|
||||
|
|
@ -13,9 +14,32 @@ from litellm.constants import MAX_IMAGE_URL_DOWNLOAD_SIZE_MB
|
|||
|
||||
MAX_IMGS_IN_MEMORY = 10
|
||||
|
||||
# Maximum connect timeout for image URL fetching (seconds).
|
||||
# Caps the connect phase independently of litellm.request_timeout so that
|
||||
# unreachable hosts (e.g. internal IPs) fail fast instead of blocking
|
||||
# the caller (and potentially the asyncio event loop) for minutes.
|
||||
_IMAGE_FETCH_CONNECT_TIMEOUT = 5.0
|
||||
|
||||
# Maximum overall timeout for a single image fetch attempt (seconds).
|
||||
_IMAGE_FETCH_OVERALL_TIMEOUT = 30.0
|
||||
|
||||
in_memory_cache = InMemoryCache(max_size_in_memory=MAX_IMGS_IN_MEMORY)
|
||||
|
||||
|
||||
def _get_image_fetch_timeout() -> httpx.Timeout:
|
||||
"""
|
||||
Build an httpx.Timeout with a capped connect timeout for image fetching.
|
||||
|
||||
The connect timeout is always capped at _IMAGE_FETCH_CONNECT_TIMEOUT to
|
||||
prevent long hangs on unreachable hosts. The overall (read/write/pool)
|
||||
timeout uses _IMAGE_FETCH_OVERALL_TIMEOUT.
|
||||
"""
|
||||
return httpx.Timeout(
|
||||
timeout=_IMAGE_FETCH_OVERALL_TIMEOUT,
|
||||
connect=_IMAGE_FETCH_CONNECT_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
def _process_image_response(response: Response, url: str) -> str:
|
||||
if response.status_code != 200:
|
||||
raise litellm.ImageFetchError(
|
||||
|
|
@ -82,14 +106,19 @@ async def async_convert_url_to_base64(url: str) -> str:
|
|||
return cached_result
|
||||
|
||||
client = litellm.module_level_aclient
|
||||
for _ in range(3):
|
||||
image_timeout = _get_image_fetch_timeout()
|
||||
for attempt in range(3):
|
||||
try:
|
||||
response = await client.get(url, follow_redirects=True)
|
||||
response = await client.get(
|
||||
url, follow_redirects=True, timeout=image_timeout
|
||||
)
|
||||
return _process_image_response(response, url)
|
||||
except litellm.ImageFetchError:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
verbose_logger.warning(
|
||||
"Image fetch attempt %d/3 failed for url=%s: %s", attempt + 1, url, e
|
||||
)
|
||||
raise litellm.ImageFetchError(
|
||||
f"Error: Unable to fetch image from URL after 3 attempts. url={url}"
|
||||
)
|
||||
|
|
@ -107,15 +136,19 @@ def convert_url_to_base64(url: str) -> str:
|
|||
return cached_result
|
||||
|
||||
client = litellm.module_level_client
|
||||
for _ in range(3):
|
||||
image_timeout = _get_image_fetch_timeout()
|
||||
for attempt in range(3):
|
||||
try:
|
||||
response = client.get(url, follow_redirects=True)
|
||||
response = client.get(
|
||||
url, follow_redirects=True, timeout=image_timeout
|
||||
)
|
||||
return _process_image_response(response, url)
|
||||
except litellm.ImageFetchError:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_logger.exception(e)
|
||||
pass
|
||||
verbose_logger.warning(
|
||||
"Image fetch attempt %d/3 failed for url=%s: %s", attempt + 1, url, e
|
||||
)
|
||||
raise litellm.ImageFetchError(
|
||||
f"Error: Unable to fetch image from URL after 3 attempts. url={url}",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -486,6 +486,7 @@ class AsyncHTTPHandler:
|
|||
params: Optional[dict] = None,
|
||||
headers: Optional[dict] = None,
|
||||
follow_redirects: Optional[bool] = None,
|
||||
timeout: Optional[Union[float, httpx.Timeout]] = None,
|
||||
):
|
||||
# Set follow_redirects to UseClientDefault if None
|
||||
_follow_redirects = (
|
||||
|
|
@ -495,9 +496,16 @@ class AsyncHTTPHandler:
|
|||
params = params or {}
|
||||
params.update(HTTPHandler.extract_query_params(url))
|
||||
|
||||
response = await self.client.get(
|
||||
url, params=params, headers=headers, follow_redirects=_follow_redirects # type: ignore
|
||||
)
|
||||
# Build kwargs for the underlying httpx call
|
||||
kwargs: dict = {
|
||||
"params": params,
|
||||
"headers": headers,
|
||||
"follow_redirects": _follow_redirects,
|
||||
}
|
||||
if timeout is not None:
|
||||
kwargs["timeout"] = timeout
|
||||
|
||||
response = await self.client.get(url, **kwargs) # type: ignore
|
||||
return response
|
||||
|
||||
@track_llm_api_timing()
|
||||
|
|
@ -1007,6 +1015,7 @@ class HTTPHandler:
|
|||
params: Optional[dict] = None,
|
||||
headers: Optional[dict] = None,
|
||||
follow_redirects: Optional[bool] = None,
|
||||
timeout: Optional[Union[float, httpx.Timeout]] = None,
|
||||
):
|
||||
# Set follow_redirects to UseClientDefault if None
|
||||
_follow_redirects = (
|
||||
|
|
@ -1015,11 +1024,15 @@ class HTTPHandler:
|
|||
params = params or {}
|
||||
params.update(self.extract_query_params(url))
|
||||
|
||||
response = self.client.get(
|
||||
url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
)
|
||||
# Build kwargs for the underlying httpx call
|
||||
kwargs: dict = {
|
||||
"params": params,
|
||||
"headers": headers,
|
||||
}
|
||||
if timeout is not None:
|
||||
kwargs["timeout"] = timeout
|
||||
|
||||
response = self.client.get(url, **kwargs)
|
||||
|
||||
return response
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import Request, Response
|
||||
|
||||
import litellm
|
||||
from litellm import constants
|
||||
from litellm.litellm_core_utils.prompt_templates.image_handling import (
|
||||
_get_image_fetch_timeout,
|
||||
convert_url_to_base64,
|
||||
)
|
||||
|
||||
|
||||
class DummyClient:
|
||||
def get(self, url, follow_redirects=True):
|
||||
def get(self, url, follow_redirects=True, timeout=None):
|
||||
return Response(status_code=404, request=Request("GET", url))
|
||||
|
||||
|
||||
|
|
@ -53,7 +55,7 @@ class LargeImageClient:
|
|||
self.size_mb = size_mb
|
||||
self.include_content_length = include_content_length
|
||||
|
||||
def get(self, url, follow_redirects=True):
|
||||
def get(self, url, follow_redirects=True, timeout=None):
|
||||
size_bytes = int(self.size_mb * 1024 * 1024)
|
||||
headers = {"Content-Type": "image/jpeg"}
|
||||
if self.include_content_length:
|
||||
|
|
@ -76,7 +78,7 @@ class StreamingLargeImageClient:
|
|||
self.size_mb = size_mb
|
||||
self.include_content_length = include_content_length
|
||||
|
||||
def get(self, url, follow_redirects=True):
|
||||
def get(self, url, follow_redirects=True, timeout=None):
|
||||
size_bytes = int(self.size_mb * 1024 * 1024)
|
||||
headers = {"Content-Type": "image/jpeg"}
|
||||
if self.include_content_length:
|
||||
|
|
@ -158,7 +160,7 @@ class SmallImageClient:
|
|||
Client that returns a small valid image.
|
||||
"""
|
||||
|
||||
def get(self, url, follow_redirects=True):
|
||||
def get(self, url, follow_redirects=True, timeout=None):
|
||||
size_bytes = 1024
|
||||
headers = {
|
||||
"Content-Type": "image/jpeg",
|
||||
|
|
@ -217,3 +219,109 @@ def test_image_size_limit_disabled(monkeypatch):
|
|||
|
||||
assert "Image URL download is disabled" in str(excinfo.value)
|
||||
assert "MAX_IMAGE_URL_DOWNLOAD_SIZE_MB=0" in str(excinfo.value)
|
||||
|
||||
|
||||
# ---------- Image fetch timeout tests ----------
|
||||
|
||||
|
||||
def test_image_fetch_timeout_has_capped_connect():
|
||||
"""
|
||||
Verify _get_image_fetch_timeout() returns an httpx.Timeout whose connect
|
||||
phase is capped at 5 s regardless of litellm.request_timeout.
|
||||
"""
|
||||
timeout = _get_image_fetch_timeout()
|
||||
assert isinstance(timeout, httpx.Timeout)
|
||||
assert timeout.connect == 5.0
|
||||
assert timeout.read == 30.0
|
||||
|
||||
|
||||
class TimeoutCapturingClient:
|
||||
"""Dummy client that records the timeout passed to .get()."""
|
||||
|
||||
def __init__(self):
|
||||
self.last_timeout = None
|
||||
|
||||
def get(self, url, follow_redirects=True, timeout=None):
|
||||
self.last_timeout = timeout
|
||||
return Response(
|
||||
status_code=200,
|
||||
headers={"Content-Type": "image/jpeg", "Content-Length": "4"},
|
||||
content=b"\xff\xd8\xff\xe0",
|
||||
request=Request("GET", url),
|
||||
)
|
||||
|
||||
|
||||
def test_convert_url_to_base64_passes_capped_timeout(monkeypatch):
|
||||
"""
|
||||
Ensure convert_url_to_base64 passes a capped timeout to the HTTP client so
|
||||
that unreachable hosts fail fast instead of blocking the caller for minutes.
|
||||
"""
|
||||
client = TimeoutCapturingClient()
|
||||
monkeypatch.setattr(litellm, "module_level_client", client)
|
||||
|
||||
convert_url_to_base64("https://example.com/tiny.jpg")
|
||||
|
||||
assert client.last_timeout is not None
|
||||
assert isinstance(client.last_timeout, httpx.Timeout)
|
||||
assert client.last_timeout.connect == 5.0
|
||||
|
||||
|
||||
class ConnectTimeoutClient:
|
||||
"""Simulates an unreachable host that raises ConnectTimeout."""
|
||||
|
||||
def __init__(self):
|
||||
self.call_count = 0
|
||||
|
||||
def get(self, url, follow_redirects=True, timeout=None):
|
||||
self.call_count += 1
|
||||
raise httpx.ConnectTimeout(
|
||||
"Timed out connecting", request=Request("GET", url)
|
||||
)
|
||||
|
||||
|
||||
def test_unreachable_host_fails_after_3_retries(monkeypatch):
|
||||
"""
|
||||
When the host is unreachable, convert_url_to_base64 should retry 3 times
|
||||
then raise ImageFetchError — not hang indefinitely.
|
||||
"""
|
||||
client = ConnectTimeoutClient()
|
||||
monkeypatch.setattr(litellm, "module_level_client", client)
|
||||
|
||||
with pytest.raises(litellm.ImageFetchError) as excinfo:
|
||||
convert_url_to_base64("http://10.254.3.71/uploads/photo.png")
|
||||
|
||||
assert "after 3 attempts" in str(excinfo.value)
|
||||
assert client.call_count == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_convert_url_to_base64_passes_capped_timeout(monkeypatch):
|
||||
"""
|
||||
Ensure async_convert_url_to_base64 passes a capped timeout to the async
|
||||
HTTP client.
|
||||
"""
|
||||
from litellm.litellm_core_utils.prompt_templates.image_handling import (
|
||||
async_convert_url_to_base64,
|
||||
)
|
||||
|
||||
class AsyncTimeoutCapturingClient:
|
||||
def __init__(self):
|
||||
self.last_timeout = None
|
||||
|
||||
async def get(self, url, follow_redirects=True, timeout=None):
|
||||
self.last_timeout = timeout
|
||||
return Response(
|
||||
status_code=200,
|
||||
headers={"Content-Type": "image/png", "Content-Length": "4"},
|
||||
content=b"\x89PNG",
|
||||
request=Request("GET", url),
|
||||
)
|
||||
|
||||
client = AsyncTimeoutCapturingClient()
|
||||
monkeypatch.setattr(litellm, "module_level_aclient", client)
|
||||
|
||||
await async_convert_url_to_base64("https://example.com/tiny.png")
|
||||
|
||||
assert client.last_timeout is not None
|
||||
assert isinstance(client.last_timeout, httpx.Timeout)
|
||||
assert client.last_timeout.connect == 5.0
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue