mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
fix(anthropic-adapter): properly translate Anthropic image format to OpenAI (#16202)
* fix(anthropic-adapter): properly translate Anthropic image format to OpenAI Fixed bug where images were stripped during Anthropic Messages API to Azure OpenAI translation. Image source data was being stringified instead of having fields properly extracted. - Added _translate_anthropic_image_to_openai() helper method - Support both base64 and URL image formats per Anthropic API spec - Refactored user message and tool result image handling * test(anthropic-adapter): add comprehensive image translation tests Add 5 unit tests covering image translation from Anthropic to OpenAI format: - User messages with base64 images - User messages with URL images - Tool results with base64 images - Tool results with URL images - Mixed content with multiple images
This commit is contained in:
parent
bb86c94df4
commit
232d1558dd
2 changed files with 412 additions and 76 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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?"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue