fix(anthropic): forward http:// image URLs as url sources instead of downloading

OpenAI to Anthropic conversion downloaded image_url parts with http:// URLs
and sent them as base64, while https:// URLs were forwarded as url sources.
Both schemes are now forwarded as url sources. Bedrock invoke, Vertex AI and
Snowflake still convert to base64 since those providers have no URL source.

Fixes #43098

Signed-off-by: Junyi Yao <j.yao@wustl.edu>
This commit is contained in:
Junyi Yao 2026-09-24 18:51:31 -07:00 • committed by Kylinny
parent 66c8f5b1a9
commit 13f9b82552
4 changed files with 85 additions and 4 deletions

View file

@ -926,7 +926,7 @@ def create_anthropic_image_param(
if image_url.startswith("http://") or image_url.startswith("https://"):
# For Bedrock invoke and Vertex AI Anthropic, always convert URLs to base64
# as these providers don't support URL sources for images
if is_bedrock_invoke or image_url.startswith("http://"):
if is_bedrock_invoke:
base64_url: Final = convert_url_to_base64(url=image_url)
image_chunk = convert_to_anthropic_image_obj(openai_image_url=base64_url, format=format)
return AnthropicMessagesImageParam(

View file

@ -41,7 +41,6 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
from litellm.litellm_core_utils.prompt_templates.image_handling import (
RemoteMedia,
async_inline_remote_media,
inline_remote_image_urls,
)
from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import (
place_mid_conversation_system,
@ -2231,8 +2230,8 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
break
return headers
def inlines_remote_media(self, media: RemoteMedia) -> bool:
return inline_remote_image_urls(media) and media.url.startswith("http://")
def inlines_remote_media(self, _media: RemoteMedia) -> bool:
return False
async def async_transform_request(
self,

View file

@ -6158,6 +6158,42 @@ def test_create_anthropic_image_param_with_https_url():
assert image_param["source"]["url"] == "https://example.com/image.png"
@patch("litellm.litellm_core_utils.prompt_templates.factory.convert_url_to_base64")
def test_create_anthropic_image_param_with_http_url_forwards_url(
mock_convert_url: MagicMock,
):
"""Plain http:// image URLs are forwarded as URL sources, like https://.
Regression test for https://github.com/BerriAI/litellm/issues/43098: the
proxy used to download http:// URLs and send them as base64 without notice.
"""
image_param = create_anthropic_image_param(
"http://example.com/image.png", format=None
)
mock_convert_url.assert_not_called()
assert image_param["type"] == "image"
assert image_param["source"]["type"] == "url"
assert image_param["source"]["url"] == "http://example.com/image.png"
@patch("litellm.litellm_core_utils.prompt_templates.factory.convert_url_to_base64")
def test_create_anthropic_image_param_with_http_url_still_base64_for_bedrock(
mock_convert_url: MagicMock,
):
"""Bedrock invoke keeps converting URLs to base64 (provider has no URL source)."""
mock_convert_url.return_value = "data:image/png;base64,/9j/4AAQSkZJRg=="
image_param = create_anthropic_image_param(
"http://example.com/image.png", format=None, is_bedrock_invoke=True
)
mock_convert_url.assert_called_once_with(url="http://example.com/image.png")
assert image_param["type"] == "image"
assert image_param["source"]["type"] == "base64"
def test_create_anthropic_image_param_with_dict_input():
"""Test that dict input with URL is handled correctly."""
image_param = create_anthropic_image_param(
@ -6217,6 +6253,37 @@ def test_anthropic_messages_pt_with_url_image():
assert result[0]["content"][1]["source"]["url"] == "https://example.com/image.jpg"
@patch("litellm.litellm_core_utils.prompt_templates.factory.convert_url_to_base64")
def test_anthropic_messages_pt_with_http_url_image(mock_convert_url: MagicMock):
"""Plain http:// image URLs are forwarded as URL sources for regular Anthropic.
Regression test for https://github.com/BerriAI/litellm/issues/43098.
"""
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "What's in this image?"},
{
"type": "image_url",
"image_url": "http://example.com/image.jpg",
},
],
}
]
result = anthropic_messages_pt(
messages=messages, model="claude-3-5-sonnet", llm_provider="anthropic"
)
mock_convert_url.assert_not_called()
assert len(result) == 1
image_content = result[0]["content"][1]
assert image_content["type"] == "image"
assert image_content["source"]["type"] == "url"
assert image_content["source"]["url"] == "http://example.com/image.jpg"
def test_anthropic_messages_pt_with_base64_image():
"""Test that anthropic_messages_pt correctly handles data URIs as base64."""
messages = [

View file

@ -21,6 +21,7 @@ from litellm.constants import (
RESPONSE_FORMAT_TOOL_NAME,
)
from litellm.litellm_core_utils.prompt_templates.common_utils import encrypted_reasoning_signature
from litellm.litellm_core_utils.prompt_templates.image_handling import RemoteMedia
from litellm.litellm_core_utils.prompt_templates.factory import anthropic_messages_pt
from litellm.llms.anthropic.chat import ModelResponseIterator
from litellm.llms.anthropic.common_utils import process_anthropic_headers
@ -9816,3 +9817,17 @@ def test_transform_request_tool_config_point_without_tools_drops_the_key():
assert "cache_control_injection_points" not in data
assert "tools" not in data
assert litellm_params["metadata"] == {}
@pytest.mark.parametrize(
"url", ["http://example.com/image.png", "https://example.com/image.png"]
)
def test_inlines_remote_media_never_inlines_image_urls(url: str):
"""Regular Anthropic forwards remote image URLs as url sources, so the async
request path must not download and inline them.
Regression test for https://github.com/BerriAI/litellm/issues/43098.
"""
media = RemoteMedia(url=url, fields={}, part_type="image_url")
assert AnthropicConfig().inlines_remote_media(media) is False