diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 922e6626f23..a786f06921f 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -167,14 +167,20 @@ class LiteLLMAnthropicMessagesAdapter: ) new_user_content_list.append(text_obj) elif content.get("type") == "image": - image_url = ChatCompletionImageUrlObject( - url=f"data:{content.get('type', '')};base64,{content.get('source', '')}" - ) - image_obj = ChatCompletionImageObject( - type="image_url", image_url=image_url + # Convert Anthropic image format to OpenAI format + source = content.get("source", {}) + openai_image_url = ( + self._translate_anthropic_image_to_openai(source) ) - new_user_content_list.append(image_obj) + if openai_image_url: + image_url_obj = ChatCompletionImageUrlObject( + url=openai_image_url + ) + image_obj = ChatCompletionImageObject( + type="image_url", image_url=image_url_obj + ) + new_user_content_list.append(image_obj) elif content.get("type") == "tool_result": if "content" not in content: tool_result = ChatCompletionToolMessage( @@ -210,13 +216,21 @@ class LiteLLMAnthropicMessagesAdapter: ) tool_message_list.append(tool_result) elif c.get("type") == "image": - image_str = f"data:{c.get('type', '')};base64,{c.get('source', '')}" + # Convert Anthropic image format to OpenAI format for tool results + source = c.get("source", {}) + openai_image_url = ( + self._translate_anthropic_image_to_openai( + source + ) + or "" + ) + tool_result = ChatCompletionToolMessage( role="tool", tool_call_id=content.get( "tool_use_id", "" ), - content=image_str, + content=openai_image_url, ) tool_message_list.append(tool_result) @@ -232,7 +246,9 @@ class LiteLLMAnthropicMessagesAdapter: ## ASSISTANT MESSAGE ## assistant_message_str: Optional[str] = None tool_calls: List[ChatCompletionAssistantToolCall] = [] - thinking_blocks: List[Union[ChatCompletionThinkingBlock, ChatCompletionRedactedThinkingBlock]] = [] + thinking_blocks: List[ + Union[ChatCompletionThinkingBlock, ChatCompletionRedactedThinkingBlock] + ] = [] if m["role"] == "assistant": if isinstance(m.get("content"), str): assistant_message_str = str(m.get("content", "")) @@ -264,23 +280,30 @@ class LiteLLMAnthropicMessagesAdapter: type="thinking", thinking=content.get("thinking") or "", signature=content.get("signature") or "", - cache_control=content.get("cache_control", {}) + cache_control=content.get("cache_control", {}), ) thinking_blocks.append(thinking_block) elif content.get("type") == "redacted_thinking": - redacted_thinking_block = ChatCompletionRedactedThinkingBlock( - type="redacted_thinking", - data=content.get("data") or "", - cache_control=content.get("cache_control", {}) + redacted_thinking_block = ( + ChatCompletionRedactedThinkingBlock( + type="redacted_thinking", + data=content.get("data") or "", + cache_control=content.get("cache_control", {}), + ) ) thinking_blocks.append(redacted_thinking_block) - - if assistant_message_str is not None or len(tool_calls) > 0 or len(thinking_blocks) > 0: + if ( + assistant_message_str is not None + or len(tool_calls) > 0 + or len(thinking_blocks) > 0 + ): assistant_message = ChatCompletionAssistantMessage( role="assistant", content=assistant_message_str, - thinking_blocks=thinking_blocks if len(thinking_blocks) > 0 else None, + thinking_blocks=( + thinking_blocks if len(thinking_blocks) > 0 else None + ), ) if len(tool_calls) > 0: assistant_message["tool_calls"] = tool_calls @@ -406,19 +429,55 @@ class LiteLLMAnthropicMessagesAdapter: return new_kwargs - def _translate_openai_content_to_anthropic( - self, choices: List[Choices] - ) -> List[ - Union[AnthropicResponseContentBlockText, AnthropicResponseContentBlockToolUse, AnthropicResponseContentBlockThinking, AnthropicResponseContentBlockRedactedThinking] + def _translate_anthropic_image_to_openai(self, image_source: dict) -> Optional[str]: + """ + Translate Anthropic image source format to OpenAI-compatible image URL. + + Anthropic supports two image source formats: + 1. Base64: {"type": "base64", "media_type": "image/jpeg", "data": "..."} + 2. URL: {"type": "url", "url": "https://..."} + + Returns the properly formatted image URL string, or None if invalid format. + """ + if not isinstance(image_source, dict): + return None + + source_type = image_source.get("type") + + if source_type == "base64": + # Base64 image format + media_type = image_source.get("media_type", "image/jpeg") + image_data = image_source.get("data", "") + if image_data: + return f"data:{media_type};base64,{image_data}" + elif source_type == "url": + # URL-referenced image format + return image_source.get("url", "") + + return None + + def _translate_openai_content_to_anthropic(self, choices: List[Choices]) -> List[ + Union[ + AnthropicResponseContentBlockText, + AnthropicResponseContentBlockToolUse, + AnthropicResponseContentBlockThinking, + AnthropicResponseContentBlockRedactedThinking, + ] ]: new_content: List[ Union[ - AnthropicResponseContentBlockText, AnthropicResponseContentBlockToolUse, AnthropicResponseContentBlockThinking, AnthropicResponseContentBlockRedactedThinking + AnthropicResponseContentBlockText, + AnthropicResponseContentBlockToolUse, + AnthropicResponseContentBlockThinking, + AnthropicResponseContentBlockRedactedThinking, ] ] = [] for choice in choices: # Handle thinking blocks first - if hasattr(choice.message, 'thinking_blocks') and choice.message.thinking_blocks: + if ( + hasattr(choice.message, "thinking_blocks") + and choice.message.thinking_blocks + ): for thinking_block in choice.message.thinking_blocks: if thinking_block.get("type") == "thinking": thinking_value = thinking_block.get("thinking", "") @@ -426,8 +485,16 @@ class LiteLLMAnthropicMessagesAdapter: new_content.append( AnthropicResponseContentBlockThinking( type="thinking", - thinking=str(thinking_value) if thinking_value is not None else "", - signature=str(signature_value) if signature_value is not None else None, + thinking=( + str(thinking_value) + if thinking_value is not None + else "" + ), + signature=( + str(signature_value) + if signature_value is not None + else None + ), ) ) elif thinking_block.get("type") == "redacted_thinking": @@ -438,7 +505,7 @@ class LiteLLMAnthropicMessagesAdapter: data=str(data_value) if data_value is not None else "", ) ) - + # Handle tool calls if ( choice.message.tool_calls is not None @@ -450,7 +517,11 @@ class LiteLLMAnthropicMessagesAdapter: type="tool_use", id=tool_call.id, name=tool_call.function.name or "", - input=json.loads(tool_call.function.arguments) if tool_call.function.arguments else {}, + input=( + json.loads(tool_call.function.arguments) + if tool_call.function.arguments + else {} + ), ) ) # Handle text content @@ -525,8 +596,8 @@ class LiteLLMAnthropicMessagesAdapter: name=choice.delta.tool_calls[0].function.name or "", input={}, ) - elif ( - isinstance(choice, StreamingChoices) and hasattr(choice.delta, "thinking_blocks") + elif isinstance(choice, StreamingChoices) and hasattr( + choice.delta, "thinking_blocks" ): thinking_blocks = choice.delta.thinking_blocks or [] if len(thinking_blocks) > 0: @@ -539,22 +610,26 @@ class LiteLLMAnthropicMessagesAdapter: assert isinstance(signature, str) if thinking and signature: - raise ValueError("Both `thinking` and `signature` in a single streaming chunk isn't supported.") + raise ValueError( + "Both `thinking` and `signature` in a single streaming chunk isn't supported." + ) return "thinking", ChatCompletionThinkingBlock( - type="thinking", - thinking=thinking, - signature=signature + type="thinking", thinking=thinking, signature=signature ) - return "text", TextBlock(type="text", text="") def _translate_streaming_openai_chunk_to_anthropic( self, choices: List[Union[OpenAIStreamingChoice, StreamingChoices]] ) -> Tuple[ Literal["text_delta", "input_json_delta", "thinking_delta", "signature_delta"], - Union[ContentTextBlockDelta, ContentJsonBlockDelta, ContentThinkingBlockDelta, ContentThinkingSignatureBlockDelta], + Union[ + ContentTextBlockDelta, + ContentJsonBlockDelta, + ContentThinkingBlockDelta, + ContentThinkingSignatureBlockDelta, + ], ]: text: str = "" @@ -572,7 +647,9 @@ class LiteLLMAnthropicMessagesAdapter: and tool.function.arguments is not None ): partial_json = (partial_json or "") + tool.function.arguments - elif isinstance(choice, StreamingChoices) and hasattr(choice.delta, "thinking_blocks"): + elif isinstance(choice, StreamingChoices) and hasattr( + choice.delta, "thinking_blocks" + ): thinking_blocks = choice.delta.thinking_blocks or [] if len(thinking_blocks) > 0: for thinking_block in thinking_blocks: @@ -585,19 +662,24 @@ class LiteLLMAnthropicMessagesAdapter: reasoning_content += thinking reasoning_signature += signature - - if reasoning_content and reasoning_signature: - raise ValueError("Both `reasoning` and `signature` in a single streaming chunk isn't supported.") + if reasoning_content and reasoning_signature: + raise ValueError( + "Both `reasoning` and `signature` in a single streaming chunk isn't supported." + ) if partial_json is not None: return "input_json_delta", ContentJsonBlockDelta( type="input_json_delta", partial_json=partial_json ) elif reasoning_content: - return "thinking_delta", ContentThinkingBlockDelta(type="thinking_delta", thinking=reasoning_content) + return "thinking_delta", ContentThinkingBlockDelta( + type="thinking_delta", thinking=reasoning_content + ) elif reasoning_signature: - return "signature_delta", ContentThinkingSignatureBlockDelta(type="signature_delta", signature=reasoning_signature) + return "signature_delta", ContentThinkingSignatureBlockDelta( + type="signature_delta", signature=reasoning_signature + ) else: return "text_delta", ContentTextBlockDelta(type="text_delta", text=text) diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index aea267cd654..2db9cacb06a 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -2,11 +2,9 @@ import os import sys import pytest -from fastapi.testclient import TestClient sys.path.insert(0, os.path.abspath("../../../../..")) -from unittest.mock import patch from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import ( LiteLLMAnthropicMessagesAdapter, @@ -211,7 +209,7 @@ def test_translate_anthropic_messages_to_openai_thinking_blocks(): anthropic_messages = [ AnthropicMessagesUserMessageParam( role="user", - content=[{"type": "text", "text": "What's the weather in Boston?"}] + content=[{"type": "text", "text": "What's the weather in Boston?"}], ), AnthopicMessagesAssistantMessageParam( role="assistant", @@ -219,7 +217,7 @@ def test_translate_anthropic_messages_to_openai_thinking_blocks(): { "type": "thinking", "thinking": "I will call the get_weather tool.", - "signature": "sigsig" + "signature": "sigsig", }, { "type": "redacted_thinking", @@ -229,9 +227,9 @@ def test_translate_anthropic_messages_to_openai_thinking_blocks(): "type": "tool_use", "id": "toolu_01234", "name": "get_weather", - "input": {"location": "Boston"} - } - ] + "input": {"location": "Boston"}, + }, + ], ), ] @@ -243,7 +241,10 @@ def test_translate_anthropic_messages_to_openai_thinking_blocks(): assert "thinking_blocks" in result[1] assert len(result[1]["thinking_blocks"]) == 2 assert result[1]["thinking_blocks"][0]["type"] == "thinking" - assert result[1]["thinking_blocks"][0]["thinking"] == "I will call the get_weather tool." + assert ( + result[1]["thinking_blocks"][0]["thinking"] + == "I will call the get_weather tool." + ) assert result[1]["thinking_blocks"][0]["signature"] == "sigsig" assert result[1]["thinking_blocks"][1]["type"] == "redacted_thinking" assert result[1]["thinking_blocks"][1]["data"] == "REDACTED" @@ -258,7 +259,7 @@ def test_translate_anthropic_messages_to_openai_tool_message_placement(): anthropic_messages = [ AnthropicMessagesUserMessageParam( role="user", - content=[{"type": "text", "text": "What's the weather in Boston?"}] + content=[{"type": "text", "text": "What's the weather in Boston?"}], ), AnthopicMessagesAssistantMessageParam( role="assistant", @@ -267,9 +268,9 @@ def test_translate_anthropic_messages_to_openai_tool_message_placement(): "type": "tool_use", "id": "toolu_01234", "name": "get_weather", - "input": {"location": "Boston"} + "input": {"location": "Boston"}, } - ] + ], ), AnthropicMessagesUserMessageParam( role="user", @@ -277,11 +278,11 @@ def test_translate_anthropic_messages_to_openai_tool_message_placement(): { "type": "tool_result", "tool_use_id": "toolu_01234", - "content": "Sunny, 75°F" + "content": "Sunny, 75°F", }, - {"type": "text", "text": "What about tomorrow?"} - ] - ) + {"type": "text", "text": "What about tomorrow?"}, + ], + ), ] adapter = LiteLLMAnthropicMessagesAdapter() @@ -294,13 +295,19 @@ def test_translate_anthropic_messages_to_openai_tool_message_placement(): for i, msg in enumerate(result): if isinstance(msg, dict) and msg.get("role") == "tool": tool_message_idx = i - elif isinstance(msg, dict) and msg.get("role") == "user" and "What about tomorrow?" in str(msg.get("content", "")): + elif ( + isinstance(msg, dict) + and msg.get("role") == "user" + and "What about tomorrow?" in str(msg.get("content", "")) + ): user_message_idx = i break assert tool_message_idx is not None, "Tool message not found" assert user_message_idx is not None, "User message not found" - assert tool_message_idx < user_message_idx, "Tool message should be placed before user message" + assert ( + tool_message_idx < user_message_idx + ), "Tool message should be placed before user message" def test_translate_openai_content_to_anthropic_empty_function_arguments(): @@ -316,11 +323,10 @@ def test_translate_openai_content_to_anthropic_empty_function_arguments(): id="call_empty_args", type="function", function=Function( - name="test_function", - arguments="" # empty arguments string - ) + name="test_function", arguments="" # empty arguments string + ), ) - ] + ], ) ) ] @@ -335,7 +341,6 @@ def test_translate_openai_content_to_anthropic_empty_function_arguments(): assert result[0].input == {}, "Empty function arguments should result in empty dict" - def test_translate_streaming_openai_chunk_to_anthropic_with_partial_json(): """Test that partial tool arguments are correctly handled as input_json_delta.""" choices = [ @@ -344,15 +349,15 @@ def test_translate_streaming_openai_chunk_to_anthropic_with_partial_json(): index=1, delta=Delta( provider_specific_fields=None, - content='', - role='assistant', + content="", + role="assistant", function_call=None, tool_calls=[ ChatCompletionDeltaToolCall( id=None, function=Function(arguments=': "San ', name=None), - type='function', - index=0 + type="function", + index=0, ) ], audio=None, @@ -372,11 +377,10 @@ def test_translate_streaming_openai_chunk_to_anthropic_with_partial_json(): print("Content block delta:", content_block_delta) assert type_of_content == "input_json_delta" - assert content_block_delta["type"] == "input_json_delta" + assert content_block_delta["type"] == "input_json_delta" assert content_block_delta["partial_json"] == ': "San ' - def test_translate_openai_content_to_anthropic_thinking_and_redacted_thinking(): openai_choices = [ Choices( @@ -389,11 +393,8 @@ def test_translate_openai_content_to_anthropic_thinking_and_redacted_thinking(): "thinking": "I need to summar", "signature": "sigsig", }, - { - "type": "redacted_thinking", - "data": "REDACTED" - } - ] + {"type": "redacted_thinking", "data": "REDACTED"}, + ], ) ) ] @@ -450,7 +451,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_with_thinking(): ) assert type_of_content == "thinking_delta" - assert content_block_delta["type"] == "thinking_delta" + assert content_block_delta["type"] == "thinking_delta" assert content_block_delta["thinking"] == "I need to summar" @@ -495,7 +496,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_with_thinking(): ) assert type_of_content == "signature_delta" - assert content_block_delta["type"] == "signature_delta" + assert content_block_delta["type"] == "signature_delta" assert content_block_delta["signature"] == "sigsig" @@ -536,3 +537,256 @@ def test_translate_streaming_openai_chunk_to_anthropic_raises_when_thinking_and_ LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic( choices=choices ) + + +def test_translate_anthropic_messages_to_openai_user_message_with_base64_image(): + """Test that base64 images in user messages are correctly translated to OpenAI format.""" + + anthropic_messages = [ + AnthropicMessagesUserMessageParam( + role="user", + content=[ + {"type": "text", "text": "What's in this image?"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + }, + }, + ], + ) + ] + + adapter = LiteLLMAnthropicMessagesAdapter() + result = adapter.translate_anthropic_messages_to_openai(messages=anthropic_messages) + + assert len(result) == 1 + assert result[0]["role"] == "user" + assert isinstance(result[0]["content"], list) + assert len(result[0]["content"]) == 2 + + # Check text content + assert result[0]["content"][0]["type"] == "text" + assert result[0]["content"][0]["text"] == "What's in this image?" + + # Check image content + assert result[0]["content"][1]["type"] == "image_url" + assert "image_url" in result[0]["content"][1] + assert result[0]["content"][1]["image_url"]["url"].startswith( + "data:image/png;base64," + ) + assert ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + in result[0]["content"][1]["image_url"]["url"] + ) + + +def test_translate_anthropic_messages_to_openai_user_message_with_url_image(): + """Test that URL-based images in user messages are correctly translated to OpenAI format.""" + + anthropic_messages = [ + AnthropicMessagesUserMessageParam( + role="user", + content=[ + {"type": "text", "text": "Describe this forest path"}, + { + "type": "image", + "source": {"type": "url", "url": "https://example.com/forest.jpg"}, + }, + ], + ) + ] + + adapter = LiteLLMAnthropicMessagesAdapter() + result = adapter.translate_anthropic_messages_to_openai(messages=anthropic_messages) + + assert len(result) == 1 + assert result[0]["role"] == "user" + assert isinstance(result[0]["content"], list) + assert len(result[0]["content"]) == 2 + + # Check text content + assert result[0]["content"][0]["type"] == "text" + assert result[0]["content"][0]["text"] == "Describe this forest path" + + # Check image content + assert result[0]["content"][1]["type"] == "image_url" + assert "image_url" in result[0]["content"][1] + assert ( + result[0]["content"][1]["image_url"]["url"] == "https://example.com/forest.jpg" + ) + + +def test_translate_anthropic_messages_to_openai_tool_result_with_base64_image(): + """Test that base64 images in tool results are correctly translated to OpenAI format.""" + + anthropic_messages = [ + AnthropicMessagesUserMessageParam( + role="user", content=[{"type": "text", "text": "Take a screenshot"}] + ), + AnthopicMessagesAssistantMessageParam( + role="assistant", + content=[ + { + "type": "tool_use", + "id": "toolu_01A09q90qw90lq917835lq9", + "name": "get_screenshot", + "input": {"area": "desktop"}, + } + ], + ), + AnthropicMessagesUserMessageParam( + role="user", + content=[ + { + "type": "tool_result", + "tool_use_id": "toolu_01A09q90qw90lq917835lq9", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/jpeg", + "data": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/wA/==", + }, + } + ], + } + ], + ), + ] + + adapter = LiteLLMAnthropicMessagesAdapter() + result = adapter.translate_anthropic_messages_to_openai(messages=anthropic_messages) + + # Find the tool message in the result + tool_message = None + for msg in result: + if isinstance(msg, dict) and msg.get("role") == "tool": + tool_message = msg + break + + assert tool_message is not None, "Tool message not found in result" + # Tool messages in OpenAI format have string content (data URL), not list + assert isinstance(tool_message["content"], str) + assert tool_message["content"].startswith("data:image/jpeg;base64,") + assert "/9j/4AAQSkZJRgABAQAAAQABAAD" in tool_message["content"] + + +def test_translate_anthropic_messages_to_openai_tool_result_with_url_image(): + """Test that URL-based images in tool results are correctly translated to OpenAI format.""" + + anthropic_messages = [ + AnthropicMessagesUserMessageParam( + role="user", + content=[{"type": "text", "text": "Take a screenshot of the forest"}], + ), + AnthopicMessagesAssistantMessageParam( + role="assistant", + content=[ + { + "type": "tool_use", + "id": "toolu_01A09q90qw90lq917835lq9", + "name": "get_screenshot", + "input": {"area": "forest_path"}, + } + ], + ), + AnthropicMessagesUserMessageParam( + role="user", + content=[ + { + "type": "tool_result", + "tool_use_id": "toolu_01A09q90qw90lq917835lq9", + "content": [ + { + "type": "image", + "source": { + "type": "url", + "url": "https://i0.wp.com/picjumbo.com/wp-content/uploads/amazing-stone-path-in-forest-free-image.jpg", + }, + } + ], + } + ], + ), + ] + + adapter = LiteLLMAnthropicMessagesAdapter() + result = adapter.translate_anthropic_messages_to_openai(messages=anthropic_messages) + + # Find the tool message in the result + tool_message = None + for msg in result: + if isinstance(msg, dict) and msg.get("role") == "tool": + tool_message = msg + break + + assert tool_message is not None, "Tool message not found in result" + # Tool messages in OpenAI format have string content (URL), not list + assert isinstance(tool_message["content"], str) + assert ( + tool_message["content"] + == "https://i0.wp.com/picjumbo.com/wp-content/uploads/amazing-stone-path-in-forest-free-image.jpg" + ) + + +def test_translate_anthropic_messages_to_openai_mixed_content_with_image(): + """Test that messages with mixed text and image content are correctly translated.""" + + anthropic_messages = [ + AnthropicMessagesUserMessageParam( + role="user", + content=[ + {"type": "text", "text": "Here are two images:"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + }, + }, + {"type": "text", "text": "and this one:"}, + { + "type": "image", + "source": {"type": "url", "url": "https://example.com/image2.jpg"}, + }, + {"type": "text", "text": "What's the difference?"}, + ], + ) + ] + + adapter = LiteLLMAnthropicMessagesAdapter() + result = adapter.translate_anthropic_messages_to_openai(messages=anthropic_messages) + + assert len(result) == 1 + assert result[0]["role"] == "user" + assert isinstance(result[0]["content"], list) + assert len(result[0]["content"]) == 5 + + # Check text content + assert result[0]["content"][0]["type"] == "text" + assert result[0]["content"][0]["text"] == "Here are two images:" + + # Check first image (base64) + assert result[0]["content"][1]["type"] == "image_url" + assert result[0]["content"][1]["image_url"]["url"].startswith( + "data:image/png;base64," + ) + + # Check middle text + assert result[0]["content"][2]["type"] == "text" + assert result[0]["content"][2]["text"] == "and this one:" + + # Check second image (URL) + assert result[0]["content"][3]["type"] == "image_url" + assert ( + result[0]["content"][3]["image_url"]["url"] == "https://example.com/image2.jpg" + ) + + # Check final text + assert result[0]["content"][4]["type"] == "text" + assert result[0]["content"][4]["text"] == "What's the difference?"