From 276b4f8c1319feece24e20d315b017e7a7d49fe8 Mon Sep 17 00:00:00 2001 From: LouisShark Date: Wed, 11 Feb 2026 17:43:22 +0800 Subject: [PATCH] fix(http_handler): prevent empty params from stripping URL query string When `HTTPHandler.get()` / `AsyncHTTPHandler.get()` call `extract_query_params(url)` on a URL whose query string doesn't contain `key=value` pairs (e.g. CDN image processing URLs like `?imageMogr2/thumbnail/800x800/format/jpg`), `parse_qsl` returns an empty dict. Passing `params={}` to httpx causes it to **remove** the original query string entirely, so the downstream server receives a bare URL without any processing parameters. This breaks image downloads for providers like Bedrock that rely on `BedrockImageProcessor` to fetch images via `get_image_details_async()`. The CDN returns the unprocessed original image (e.g. 3.8 MB PNG instead of 144 KB JPEG), which after base64 encoding exceeds Bedrock's 5 MB image size limit. Fix: pass `params=None` instead of `params={}` when no key=value query parameters are extracted, so httpx preserves the original query string. Co-authored-by: Cursor --- litellm/llms/custom_httpx/http_handler.py | 15 ++- .../llms/custom_httpx/test_http_handler.py | 93 ++++++++++++++++++- 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index 3dfef07d426..e213d5f5010 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -423,8 +423,15 @@ class AsyncHTTPHandler: params = params or {} params.update(HTTPHandler.extract_query_params(url)) + # Only pass params when non-empty; passing an empty dict causes httpx to + # strip the original query-string from the URL entirely, which breaks + # URLs whose query-string doesn't use key=value pairs (e.g. CDN image + # processing URLs like `?imageMogr2/thumbnail/800x800/format/jpg`). response = await self.client.get( - url, params=params, headers=headers, follow_redirects=_follow_redirects # type: ignore + url, + params=params or None, + headers=headers, + follow_redirects=_follow_redirects, # type: ignore ) return response @@ -967,9 +974,13 @@ class HTTPHandler: params = params or {} params.update(self.extract_query_params(url)) + # Only pass params when non-empty; passing an empty dict causes httpx to + # strip the original query-string from the URL entirely, which breaks + # URLs whose query-string doesn't use key=value pairs (e.g. CDN image + # processing URLs like `?imageMogr2/thumbnail/800x800/format/jpg`). response = self.client.get( url, - params=params, + params=params or None, headers=headers, ) diff --git a/tests/test_litellm/llms/custom_httpx/test_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_http_handler.py index f3b76ddbe82..0fb2a04919f 100644 --- a/tests/test_litellm/llms/custom_httpx/test_http_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_http_handler.py @@ -3,7 +3,7 @@ import os import pathlib import ssl import sys -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, AsyncMock, patch import certifi import httpx @@ -15,7 +15,7 @@ sys.path.insert( ) # Adds the parent directory to the system path import litellm from litellm.llms.custom_httpx.aiohttp_transport import LiteLLMAiohttpTransport -from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, get_ssl_configuration +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler, get_ssl_configuration @pytest.mark.asyncio @@ -635,3 +635,92 @@ async def test_httpx_handler_uses_env_user_agent(monkeypatch): assert req.headers.get("User-Agent") == "Claude Code" finally: await handler.close() + +def test_extract_query_params_standard_url(): + """Test extract_query_params with standard key=value query strings.""" + result = HTTPHandler.extract_query_params( + "https://example.com/path?foo=1&bar=2" + ) + assert result == {"foo": "1", "bar": "2"} + + +def test_extract_query_params_no_keyvalue_pairs(): + """Test extract_query_params returns empty dict for non key=value query strings. + + CDN image processing URLs (e.g. Qiniu) use path-style query strings like + `?imageMogr2/thumbnail/800x800/format/jpg` which don't contain `=`. + `parse_qsl` cannot parse these and returns an empty list. + """ + result = HTTPHandler.extract_query_params( + "https://cdn.example.com/img.png?imageMogr2/thumbnail/800x800/format/jpg" + ) + assert result == {} + + +def test_extract_query_params_no_query_string(): + """Test extract_query_params with no query string at all.""" + result = HTTPHandler.extract_query_params("https://example.com/path") + assert result == {} + + +@pytest.mark.asyncio +async def test_async_get_preserves_query_string_when_no_keyvalue_params(): + """Test that AsyncHTTPHandler.get() preserves the original query string + when extract_query_params returns an empty dict. + + Regression test: previously, passing `params={}` to httpx caused it to + strip the original query string from the URL entirely. This broke CDN + image processing URLs that use non-standard query strings without `=`. + """ + handler = AsyncHTTPHandler(timeout=httpx.Timeout(timeout=10.0)) + + cdn_url = "https://cdn.example.com/img.png?imageMogr2/thumbnail/800x800%3E/format/jpg" + + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(handler.client, "get", new_callable=AsyncMock, return_value=mock_response) as mock_get: + await handler.get(cdn_url, follow_redirects=True) + + # Verify the URL passed to httpx still contains the query string + call_args = mock_get.call_args + actual_url = call_args[0][0] if call_args[0] else call_args[1].get("url") + assert "imageMogr2" in str(actual_url), ( + f"Query string was stripped from URL. Got: {actual_url}" + ) + + # Verify params is None (not empty dict) so httpx doesn't strip query string + actual_params = call_args[1].get("params") + assert actual_params is None, ( + f"Expected params=None when no key=value pairs in query string, got: {actual_params}" + ) + + +def test_sync_get_preserves_query_string_when_no_keyvalue_params(): + """Test that HTTPHandler.get() preserves the original query string + when extract_query_params returns an empty dict. + + Regression test: same issue as async variant above. + """ + handler = HTTPHandler(timeout=httpx.Timeout(timeout=10.0)) + + cdn_url = "https://cdn.example.com/img.png?imageMogr2/thumbnail/800x800%3E/format/jpg" + + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(handler.client, "get", return_value=mock_response) as mock_get: + handler.get(cdn_url, follow_redirects=True) + + # Verify the URL passed to httpx still contains the query string + call_args = mock_get.call_args + actual_url = call_args[0][0] if call_args[0] else call_args[1].get("url") + assert "imageMogr2" in str(actual_url), ( + f"Query string was stripped from URL. Got: {actual_url}" + ) + + # Verify params is None (not empty dict) so httpx doesn't strip query string + actual_params = call_args[1].get("params") + assert actual_params is None, ( + f"Expected params=None when no key=value pairs in query string, got: {actual_params}" + )