This commit is contained in:
d 🔹 2026-04-17 08:03:42 +00:00 committed by GitHub
commit 3ee90e3dc6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 174 additions and 20 deletions

View file

@ -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}",
)

View file

@ -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

View file

@ -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