mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
Merge 955484ee1d into 2e27a02a9f
This commit is contained in:
commit
f370f52b5f
4 changed files with 612 additions and 186 deletions
|
|
@ -52,6 +52,7 @@ from litellm.llms.base_llm.guardrail_translation.utils import (
|
|||
effective_scan_only_tool_results_for_guardrail,
|
||||
effective_skip_system_message_for_guardrail,
|
||||
effective_skip_tool_message_for_guardrail,
|
||||
extract_tool_from_addition_block,
|
||||
list_field,
|
||||
merge_guardrailed_scoped_messages,
|
||||
merge_returned_tools_into_request_tools,
|
||||
|
|
@ -720,6 +721,12 @@ class AnthropicMessagesHandler(BaseTranslation):
|
|||
*anthropic_tools,
|
||||
]
|
||||
)
|
||||
if not scan_only_tool_results:
|
||||
data_dict: Final[dict[str, object]] = cast(dict[str, object], data) # cast-ok: request dict
|
||||
retained_names: Final = frozenset( # comprehension-ok: flattens tool names across tools
|
||||
name for t in list_field(data_dict, "tools") for name in anthropic_tool_names(t)
|
||||
)
|
||||
self._strip_disallowed_tool_additions(data_dict, retained_names)
|
||||
|
||||
guardrailed_structured_messages: Final = guardrailed_inputs.get("structured_messages")
|
||||
if (
|
||||
|
|
@ -1000,8 +1007,72 @@ class AnthropicMessagesHandler(BaseTranslation):
|
|||
|
||||
def extract_request_tool_names(self, data: Mapping[str, object]) -> list[str]:
|
||||
"""Extract every tool name in an Anthropic messages request: tools[].name, plus
|
||||
tools[].function.name for OpenAI-format tools the bridge forwards verbatim."""
|
||||
return [name for tool in list_field(data, "tools") for name in anthropic_tool_names(tool)]
|
||||
tools[].function.name for OpenAI-format tools the bridge forwards verbatim,
|
||||
as well as mid-conversation tool additions in messages."""
|
||||
top_names: Final = [ # comprehension-ok: flattens tool names across tools
|
||||
name for tool in list_field(data, "tools") for name in anthropic_tool_names(tool)
|
||||
]
|
||||
messages: Final = list_field(data, "messages")
|
||||
if not messages:
|
||||
return top_names
|
||||
extra_names: Final[list[str]] = [] # mutable-ok: accumulator
|
||||
for m in messages:
|
||||
m_map = as_str_mapping(m)
|
||||
if m_map is not None:
|
||||
for block in list_field(m_map, "content"):
|
||||
block_map = as_str_mapping(block)
|
||||
if block_map is not None and block_map.get("type") == "tool_addition":
|
||||
tool = extract_tool_from_addition_block(block_map)
|
||||
if tool is not None:
|
||||
for name in anthropic_tool_names(tool):
|
||||
if name not in top_names and name not in extra_names:
|
||||
extra_names.append(name)
|
||||
return [*top_names, *extra_names]
|
||||
|
||||
@staticmethod
|
||||
def _is_disallowed_addition_block(block: object, retained_names: frozenset[str]) -> bool:
|
||||
block_map: Final = as_str_mapping(block)
|
||||
if block_map is None or block_map.get("type") != "tool_addition":
|
||||
return False
|
||||
tool: Final = extract_tool_from_addition_block(block_map)
|
||||
if tool is None:
|
||||
return True
|
||||
tool_names: Final = anthropic_tool_names(tool)
|
||||
if not tool_names:
|
||||
return True
|
||||
return any(name not in retained_names for name in tool_names)
|
||||
|
||||
@classmethod
|
||||
def _strip_disallowed_tool_additions(cls, data: Mapping[str, object], retained_names: frozenset[str]) -> None:
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
messages_val: Final = data.get("messages")
|
||||
if not isinstance(messages_val, list):
|
||||
return
|
||||
messages: Final = cast(Sequence[object], messages_val) # cast-ok: messages list
|
||||
cleaned_messages: Final[list[object]] = [] # mutable-ok: accumulator
|
||||
for m in messages:
|
||||
m_map = as_str_mapping(m)
|
||||
if m_map is None:
|
||||
cleaned_messages.append(m)
|
||||
continue
|
||||
content_val = m_map.get("content")
|
||||
if not isinstance(content_val, list):
|
||||
cleaned_messages.append(m)
|
||||
continue
|
||||
content: Sequence[object] = cast(Sequence[object], content_val) # cast-ok: content list
|
||||
had_disallowed_addition = any(cls._is_disallowed_addition_block(block, retained_names) for block in content)
|
||||
if not had_disallowed_addition:
|
||||
cleaned_messages.append(m)
|
||||
continue
|
||||
remaining_blocks: list[object] = [ # mutable-ok: accumulator
|
||||
block for block in content if not cls._is_disallowed_addition_block(block, retained_names)
|
||||
]
|
||||
if remaining_blocks:
|
||||
new_m: dict[str, object] = dict(m_map) # mutable-ok: message clone
|
||||
new_m["content"] = remaining_blocks
|
||||
cleaned_messages.append(new_m)
|
||||
data["messages"] = cleaned_messages # rebind-ok: update request in place
|
||||
|
||||
@classmethod
|
||||
def _extract_input_text_and_images(
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, TypeVar, cast
|
|||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.core_helpers import normalize_drop_params
|
||||
from litellm.litellm_core_utils.core_helpers import as_str_mapping, normalize_drop_params
|
||||
from litellm.litellm_core_utils.hidden_params import get_hidden_params
|
||||
from litellm.llms.anthropic.pass_through.convertible_blocks import tool_result_text_part
|
||||
from litellm.llms.anthropic.pass_through.utils import (
|
||||
|
|
@ -16,6 +16,10 @@ from litellm.llms.anthropic.pass_through.utils import (
|
|||
json_copy_or_original,
|
||||
prompt_cache_key_from_user_id,
|
||||
)
|
||||
from litellm.llms.base_llm.guardrail_translation.utils import (
|
||||
extract_tool_from_addition_block,
|
||||
list_field,
|
||||
)
|
||||
from litellm.responses.utils import auto_reasoning_summary
|
||||
|
||||
# OpenAI has a 64-character limit for function/tool names
|
||||
|
|
@ -474,7 +478,10 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
preserve_midturn_system: bool = False,
|
||||
) -> list[AllMessageValues]:
|
||||
new_messages: Final[list[AllMessageValues]] = []
|
||||
replayable_messages: Final = strip_encrypted_reasoning_blocks_from_anthropic_messages(messages)
|
||||
_, cleaned_input_messages = self._extract_and_apply_midturn_tool_changes(messages)
|
||||
replayable_messages: Final = strip_encrypted_reasoning_blocks_from_anthropic_messages(
|
||||
cast(Sequence[dict[str, object]], cleaned_input_messages) # cast-ok: anthropic messages shape
|
||||
)
|
||||
leading_count: Final = next(
|
||||
(i for i, m in enumerate(replayable_messages) if not is_system_role_message(m)),
|
||||
len(replayable_messages),
|
||||
|
|
@ -1230,6 +1237,102 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
if k not in translatable_params: # pass remaining params as is
|
||||
new_kwargs[k] = v
|
||||
|
||||
@staticmethod
|
||||
def _adapter_tool_name(tool: object) -> str | None:
|
||||
tool_map: Final = as_str_mapping(tool)
|
||||
if tool_map is not None:
|
||||
name_val: Final = tool_map.get("name")
|
||||
if isinstance(name_val, str) and name_val:
|
||||
return name_val
|
||||
return None
|
||||
|
||||
def _extract_and_apply_midturn_tool_changes(
|
||||
self,
|
||||
messages: Sequence[Mapping[str, object] | AllAnthropicPassThroughMessageValues],
|
||||
initial_tools: Sequence[object] | None = None,
|
||||
) -> tuple[tuple[AllAnthropicToolsValues, ...] | None, tuple[AllAnthropicPassThroughMessageValues, ...]]:
|
||||
"""
|
||||
Extract and apply mid-conversation tool changes (tool_addition, tool_removal)
|
||||
such as those sent by Claude Code under mid-conversation-tool-changes beta.
|
||||
|
||||
Returns:
|
||||
Tuple of (active_tools, cleaned_messages)
|
||||
- active_tools: Updated tools tuple with additions applied and removals removed,
|
||||
or None if no tools exist and none were added.
|
||||
- cleaned_messages: Messages tuple with tool_addition and tool_removal blocks stripped,
|
||||
and carrier messages left without content dropped.
|
||||
"""
|
||||
active_tools: tuple[AllAnthropicToolsValues, ...] = tuple( # rebind-ok: tool updates
|
||||
cast( # cast-ok: initial tools
|
||||
AllAnthropicToolsValues, t
|
||||
)
|
||||
for t in (initial_tools or ())
|
||||
)
|
||||
had_tool_changes: bool = False # rebind-ok: state flag
|
||||
cleaned_messages: Final[list[AllAnthropicPassThroughMessageValues]] = [] # mutable-ok: accumulator
|
||||
|
||||
for m in messages:
|
||||
m_map = as_str_mapping(m)
|
||||
if m_map is None:
|
||||
cleaned_messages.append(cast(AllAnthropicPassThroughMessageValues, m)) # cast-ok: msg
|
||||
continue
|
||||
content = m_map.get("content")
|
||||
if not isinstance(content, Sequence) or isinstance(content, (str, bytes)):
|
||||
msg_val = cast(AllAnthropicPassThroughMessageValues, m) # cast-ok: msg
|
||||
cleaned_messages.append(msg_val)
|
||||
continue
|
||||
|
||||
content_seq: Sequence[object] = cast(Sequence[object], content) # cast-ok: content blocks
|
||||
remaining_blocks: list[object] = [] # mutable-ok: accumulator
|
||||
for block in content_seq:
|
||||
block_map = as_str_mapping(block)
|
||||
if block_map is None:
|
||||
remaining_blocks.append(block)
|
||||
continue
|
||||
btype = block_map.get("type")
|
||||
if btype == "tool_addition":
|
||||
had_tool_changes = True
|
||||
raw_tool = extract_tool_from_addition_block(block_map)
|
||||
if raw_tool is not None:
|
||||
tool_name = self._adapter_tool_name(raw_tool)
|
||||
if tool_name is not None and not any(
|
||||
self._adapter_tool_name(t) == tool_name for t in active_tools
|
||||
):
|
||||
added_tool = cast(AllAnthropicToolsValues, dict(raw_tool)) # cast-ok: tool
|
||||
active_tools = (*active_tools, added_tool)
|
||||
elif btype == "tool_removal":
|
||||
had_tool_changes = True
|
||||
tool_obj = block_map.get("tool")
|
||||
tool_map = as_str_mapping(tool_obj)
|
||||
rem_name = (
|
||||
block_map.get("tool_name")
|
||||
or block_map.get("name")
|
||||
or (tool_map.get("name") if tool_map is not None else None)
|
||||
or (tool_obj if isinstance(tool_obj, str) else None)
|
||||
)
|
||||
if rem_name:
|
||||
rem_name_str = str(rem_name)
|
||||
active_tools = tuple(t for t in active_tools if self._adapter_tool_name(t) != rem_name_str)
|
||||
else:
|
||||
remaining_blocks.append(block)
|
||||
|
||||
if remaining_blocks:
|
||||
new_m: dict[str, object] = dict(m_map) # mutable-ok: message clone
|
||||
new_m["content"] = remaining_blocks
|
||||
new_msg = cast(AllAnthropicPassThroughMessageValues, new_m) # cast-ok: msg
|
||||
cleaned_messages.append(new_msg)
|
||||
elif not had_tool_changes:
|
||||
clean_msg = cast(AllAnthropicPassThroughMessageValues, m) # cast-ok: msg
|
||||
cleaned_messages.append(clean_msg)
|
||||
|
||||
if not had_tool_changes and initial_tools is None:
|
||||
return None, tuple(cleaned_messages)
|
||||
|
||||
return (
|
||||
active_tools if active_tools else (() if initial_tools is not None or had_tool_changes else None),
|
||||
tuple(cleaned_messages),
|
||||
)
|
||||
|
||||
def translate_anthropic_to_openai(
|
||||
self,
|
||||
anthropic_message_request: AnthropicMessagesRequest,
|
||||
|
|
@ -1246,8 +1349,6 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
- tool_name_mapping maps truncated tool names back to original names
|
||||
for tools that exceeded OpenAI's 64-char limit
|
||||
"""
|
||||
# Debug: Processing Anthropic message request
|
||||
new_messages: list[AllMessageValues] = []
|
||||
tool_name_mapping: dict[str, str] = {}
|
||||
|
||||
## CONVERT ANTHROPIC MESSAGES TO OPENAI
|
||||
|
|
@ -1255,14 +1356,32 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
list[AllAnthropicPassThroughMessageValues],
|
||||
anthropic_message_request["messages"],
|
||||
)
|
||||
new_messages = self.translate_anthropic_messages_to_openai(
|
||||
request_dict: Final[dict[str, object]] = dict(anthropic_message_request) # mutable-ok: clone
|
||||
initial_tools_val: Final = list_field(request_dict, "tools") if "tools" in request_dict else None
|
||||
(
|
||||
active_tools,
|
||||
cleaned_messages,
|
||||
) = self._extract_and_apply_midturn_tool_changes(
|
||||
messages=messages_list,
|
||||
initial_tools=initial_tools_val,
|
||||
)
|
||||
if active_tools is not None:
|
||||
if active_tools:
|
||||
request_dict["tools"] = list(active_tools)
|
||||
else:
|
||||
request_dict.pop("tools", None)
|
||||
request_for_translation: Final[AnthropicMessagesRequest] = cast( # cast-ok: anthropic request type
|
||||
AnthropicMessagesRequest, request_dict
|
||||
)
|
||||
|
||||
new_messages: Final = self.translate_anthropic_messages_to_openai(
|
||||
messages=list(cleaned_messages),
|
||||
model=anthropic_message_request.get("model"),
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
preserve_midturn_system=preserve_midturn_system,
|
||||
)
|
||||
## ADD SYSTEM MESSAGE TO MESSAGES
|
||||
self._add_system_message_to_messages(new_messages, anthropic_message_request)
|
||||
self._add_system_message_to_messages(new_messages, request_for_translation)
|
||||
|
||||
new_kwargs: Final[ChatCompletionRequest] = {
|
||||
"model": anthropic_message_request["model"],
|
||||
|
|
@ -1281,7 +1400,7 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
)
|
||||
## CONVERT TOOLS
|
||||
tool_name_mapping = self._translate_tools_to_openai(
|
||||
anthropic_message_request=anthropic_message_request,
|
||||
anthropic_message_request=request_for_translation,
|
||||
new_kwargs=new_kwargs,
|
||||
)
|
||||
## CONVERT THINKING
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from typing import Final, TypeVar, cast # noqa: TID251 # a rebuilt chat row ha
|
|||
|
||||
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
|
||||
|
||||
from litellm.litellm_core_utils.core_helpers import as_str_mapping
|
||||
from litellm.types.llms.anthropic_messages.anthropic_response import AnthropicUsage
|
||||
from litellm.types.llms.openai import AllMessageValues, ResponseAPIUsage
|
||||
|
||||
|
|
@ -321,6 +322,27 @@ def anthropic_tool_name(tool: object) -> str | None:
|
|||
return names[0] if names else None
|
||||
|
||||
|
||||
def extract_tool_from_addition_block(block: object) -> Mapping[str, object] | None:
|
||||
"""Extract raw tool dict from an Anthropic tool_addition block."""
|
||||
mapping: Final = as_str_mapping(block)
|
||||
if mapping is None:
|
||||
return None
|
||||
tool_data: Final = as_str_mapping(mapping.get("tool"))
|
||||
if tool_data is not None:
|
||||
if (
|
||||
tool_data.get("type") == "tool_definition"
|
||||
and "definition" in tool_data
|
||||
and (definition := as_str_mapping(tool_data.get("definition"))) is not None
|
||||
):
|
||||
return definition
|
||||
return tool_data
|
||||
if "definition" in mapping and (definition := as_str_mapping(mapping.get("definition"))) is not None:
|
||||
return definition
|
||||
if (tool_def := as_str_mapping(mapping.get("tool_definition"))) is not None:
|
||||
return tool_def
|
||||
return None
|
||||
|
||||
|
||||
def merge_returned_tools_into_request_tools(
|
||||
request_tools: Sequence[ToolT] | None,
|
||||
returned_tools: Sequence[ToolT],
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import respx
|
|||
import litellm
|
||||
|
||||
|
||||
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
TOOL_RESULT_IMAGE_PLACEHOLDER,
|
||||
encrypted_reasoning_signature,
|
||||
|
|
@ -66,10 +65,17 @@ def test_translate_openai_response_to_anthropic_empty_choices() -> None:
|
|||
assert result["usage"]["input_tokens"] == 10
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text,count,expected_stop", [
|
||||
("", 1, "compaction"), (None, 1, "compaction"), ("Answer", 1, "max_tokens"),
|
||||
(" ", 1, "max_tokens"), ("", 2, "max_tokens"), ("", 0, "max_tokens"),
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
"text,count,expected_stop",
|
||||
[
|
||||
("", 1, "compaction"),
|
||||
(None, 1, "compaction"),
|
||||
("Answer", 1, "max_tokens"),
|
||||
(" ", 1, "max_tokens"),
|
||||
("", 2, "max_tokens"),
|
||||
("", 0, "max_tokens"),
|
||||
],
|
||||
)
|
||||
def test_native_compaction_response_roundtrip(text: str | None, count: int, expected_stop: str) -> None:
|
||||
block: Final = {"type": "compaction", "content": "Exact summary", "signature": "opaque-signature"}
|
||||
message: Final = Message(content=text, provider_specific_fields={"compaction_blocks": [block] * count})
|
||||
|
|
@ -178,9 +184,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_content_block():
|
|||
tool_calls=[
|
||||
ChatCompletionDeltaToolCall(
|
||||
id="call_d581d130-e234-4315-94e8-27e7ff7c4e55",
|
||||
function=Function(
|
||||
arguments='{"location": "Boston"}', name="get_weather"
|
||||
),
|
||||
function=Function(arguments='{"location": "Boston"}', name="get_weather"),
|
||||
type="function",
|
||||
index=0,
|
||||
)
|
||||
|
|
@ -224,9 +228,7 @@ def test_translate_streaming_openai_chunk_strips_gemini_thought_from_tool_call_i
|
|||
tool_calls=[
|
||||
ChatCompletionDeltaToolCall(
|
||||
id=combined,
|
||||
function=Function(
|
||||
arguments='{"a": 17, "b": 25}', name="add_numbers"
|
||||
),
|
||||
function=Function(arguments='{"a": 17, "b": 25}', name="add_numbers"),
|
||||
type="function",
|
||||
index=0,
|
||||
)
|
||||
|
|
@ -463,10 +465,7 @@ 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"
|
||||
|
|
@ -606,9 +605,7 @@ def test_translate_anthropic_messages_to_openai_tool_message_placement():
|
|||
|
||||
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"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -1074,9 +1071,7 @@ def test_translate_anthropic_to_openai_skips_prompt_cache_key_when_provider_lack
|
|||
|
||||
|
||||
def test_translate_anthropic_to_openai_skips_prompt_cache_key_for_chained_litellm_proxy():
|
||||
assert "prompt_cache_key" in litellm.get_supported_openai_params(
|
||||
model="xai", custom_llm_provider="litellm_proxy"
|
||||
)
|
||||
assert "prompt_cache_key" in litellm.get_supported_openai_params(model="xai", custom_llm_provider="litellm_proxy")
|
||||
openai_request = _translate_with_metadata("litellm_proxy/xai", {"user_id": CLAUDE_CODE_USER_ID}, "litellm_proxy")
|
||||
assert openai_request["user"] == CLAUDE_CODE_USER_ID
|
||||
assert "prompt_cache_key" not in openai_request
|
||||
|
|
@ -1121,7 +1116,8 @@ 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
|
||||
),
|
||||
)
|
||||
],
|
||||
|
|
@ -1136,9 +1132,7 @@ def test_translate_openai_content_to_anthropic_empty_function_arguments():
|
|||
assert result[0]["type"] == "tool_use"
|
||||
assert result[0]["id"] == "call_empty_args"
|
||||
assert result[0]["name"] == "test_function"
|
||||
assert (
|
||||
result[0]["input"] == {}
|
||||
), "Empty function arguments should result in empty dict"
|
||||
assert result[0]["input"] == {}, "Empty function arguments should result in empty dict"
|
||||
assert "provider_specific_fields" not in result[0]
|
||||
|
||||
|
||||
|
|
@ -1262,9 +1256,7 @@ def test_translate_openai_response_to_anthropic_text_and_tool_calls():
|
|||
ChatCompletionAssistantToolCall(
|
||||
id="call_tool_combo",
|
||||
type="function",
|
||||
function=Function(
|
||||
name="get_weather", arguments='{"location": "Paris"}'
|
||||
),
|
||||
function=Function(name="get_weather", arguments='{"location": "Paris"}'),
|
||||
)
|
||||
],
|
||||
),
|
||||
|
|
@ -1274,9 +1266,7 @@ def test_translate_openai_response_to_anthropic_text_and_tool_calls():
|
|||
)
|
||||
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
anthropic_response = adapter.translate_openai_response_to_anthropic(
|
||||
response=openai_response
|
||||
)
|
||||
anthropic_response = adapter.translate_openai_response_to_anthropic(response=openai_response)
|
||||
|
||||
anthropic_content = anthropic_response.get("content")
|
||||
assert anthropic_content is not None
|
||||
|
|
@ -1317,9 +1307,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_with_partial_json():
|
|||
(
|
||||
type_of_content,
|
||||
content_block_delta,
|
||||
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(
|
||||
choices=choices
|
||||
)
|
||||
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(choices=choices)
|
||||
|
||||
print("Type of content:", type_of_content)
|
||||
print("Content block delta:", content_block_delta)
|
||||
|
|
@ -1428,9 +1416,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_thinking_delta():
|
|||
(
|
||||
type_of_content,
|
||||
content_block_delta,
|
||||
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(
|
||||
choices=choices
|
||||
)
|
||||
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(choices=choices)
|
||||
|
||||
assert type_of_content == "thinking_delta"
|
||||
assert content_block_delta["type"] == "thinking_delta"
|
||||
|
|
@ -1473,9 +1459,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_with_thinking():
|
|||
(
|
||||
type_of_content,
|
||||
content_block_delta,
|
||||
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(
|
||||
choices=choices
|
||||
)
|
||||
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(choices=choices)
|
||||
|
||||
assert type_of_content == "signature_delta"
|
||||
assert content_block_delta["type"] == "signature_delta"
|
||||
|
|
@ -1579,9 +1563,7 @@ def test_translate_anthropic_messages_to_openai_user_message_with_base64_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 result[0]["content"][1]["image_url"]["url"].startswith("data:image/png;base64,")
|
||||
assert (
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
in result[0]["content"][1]["image_url"]["url"]
|
||||
|
|
@ -1619,18 +1601,14 @@ def test_translate_anthropic_messages_to_openai_user_message_with_url_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"] == "https://example.com/forest.jpg"
|
||||
)
|
||||
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"}]
|
||||
),
|
||||
AnthropicMessagesUserMessageParam(role="user", content=[{"type": "text", "text": "Take a screenshot"}]),
|
||||
AnthopicMessagesAssistantMessageParam(
|
||||
role="assistant",
|
||||
content=[
|
||||
|
|
@ -1782,9 +1760,7 @@ def test_translate_anthropic_messages_to_openai_mixed_content_with_image():
|
|||
|
||||
# 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,"
|
||||
)
|
||||
assert result[0]["content"][1]["image_url"]["url"].startswith("data:image/png;base64,")
|
||||
|
||||
# Check middle text
|
||||
assert result[0]["content"][2]["type"] == "text"
|
||||
|
|
@ -1792,9 +1768,7 @@ def test_translate_anthropic_messages_to_openai_mixed_content_with_image():
|
|||
|
||||
# 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"
|
||||
)
|
||||
assert result[0]["content"][3]["image_url"]["url"] == "https://example.com/image2.jpg"
|
||||
|
||||
# Check final text
|
||||
assert result[0]["content"][4]["type"] == "text"
|
||||
|
|
@ -1840,10 +1814,7 @@ def test_translate_anthropic_messages_to_openai_tool_use_with_signature():
|
|||
assert tool_call["id"] == "call_386f67af31f9415781bc35071405"
|
||||
assert "function" in tool_call
|
||||
assert "provider_specific_fields" in tool_call["function"]
|
||||
assert (
|
||||
tool_call["function"]["provider_specific_fields"]["thought_signature"]
|
||||
== test_signature
|
||||
)
|
||||
assert tool_call["function"]["provider_specific_fields"]["thought_signature"] == test_signature
|
||||
|
||||
|
||||
def test_translate_anthropic_messages_to_openai_tool_result_with_multiple_content_items():
|
||||
|
|
@ -1901,9 +1872,7 @@ def test_translate_anthropic_messages_to_openai_tool_result_with_multiple_conten
|
|||
result = adapter.translate_anthropic_messages_to_openai(messages=anthropic_messages)
|
||||
|
||||
# Count how many tool messages have the same tool_call_id
|
||||
tool_messages = [
|
||||
msg for msg in result if isinstance(msg, dict) and msg.get("role") == "tool"
|
||||
]
|
||||
tool_messages = [msg for msg in result if isinstance(msg, dict) and msg.get("role") == "tool"]
|
||||
tool_call_ids = [msg.get("tool_call_id") for msg in tool_messages]
|
||||
|
||||
# The critical assertion: each tool_call_id should appear only ONCE
|
||||
|
|
@ -1919,12 +1888,8 @@ def test_translate_anthropic_messages_to_openai_tool_result_with_multiple_conten
|
|||
# The content should be a list with all items combined
|
||||
tool_message = tool_messages[0]
|
||||
assert tool_message["tool_call_id"] == "toolu_016hYHBkTf4JDF3p22UoYk5C"
|
||||
assert isinstance(
|
||||
tool_message["content"], list
|
||||
), "Multiple content items should be combined into a list"
|
||||
assert (
|
||||
len(tool_message["content"]) == 3
|
||||
), f"Expected 3 content items, got {len(tool_message['content'])}"
|
||||
assert isinstance(tool_message["content"], list), "Multiple content items should be combined into a list"
|
||||
assert len(tool_message["content"]) == 3, f"Expected 3 content items, got {len(tool_message['content'])}"
|
||||
|
||||
# Verify content types
|
||||
assert tool_message["content"][0]["type"] == "text"
|
||||
|
|
@ -1973,17 +1938,14 @@ def test_translate_anthropic_messages_to_openai_tool_result_single_item_backward
|
|||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
result = adapter.translate_anthropic_messages_to_openai(messages=anthropic_messages)
|
||||
|
||||
tool_messages = [
|
||||
msg for msg in result if isinstance(msg, dict) and msg.get("role") == "tool"
|
||||
]
|
||||
tool_messages = [msg for msg in result if isinstance(msg, dict) and msg.get("role") == "tool"]
|
||||
|
||||
assert len(tool_messages) == 1
|
||||
tool_message = tool_messages[0]
|
||||
|
||||
# Single item should be a string for backward compatibility
|
||||
assert isinstance(tool_message["content"], str), (
|
||||
f"Single content item should be a string for backward compatibility, "
|
||||
f"got {type(tool_message['content'])}"
|
||||
f"Single content item should be a string for backward compatibility, got {type(tool_message['content'])}"
|
||||
)
|
||||
assert tool_message["content"] == "72°F and sunny"
|
||||
|
||||
|
|
@ -2087,15 +2049,12 @@ def test_streaming_chunk_with_text_and_empty_tool_calls_returns_text_delta():
|
|||
# ============================================================================
|
||||
|
||||
# Model constant for cache control tests
|
||||
CACHE_CONTROL_BEDROCK_CONVERSE_MODEL = (
|
||||
"bedrock/converse/global.anthropic.claude-opus-4-5-20251101-v1:0"
|
||||
)
|
||||
CACHE_CONTROL_BEDROCK_CONVERSE_MODEL = "bedrock/converse/global.anthropic.claude-opus-4-5-20251101-v1:0"
|
||||
CACHE_CONTROL_NON_ANTHROPIC_MODEL = "gpt-4"
|
||||
# Bedrock Application Inference Profile ARN: the string contains neither
|
||||
# "anthropic" nor "claude", so the model can only be recognized via its ARN shape
|
||||
CACHE_CONTROL_BEDROCK_ARN_MODEL = (
|
||||
"bedrock/converse/arn:aws:bedrock:us-east-1:123456789012:"
|
||||
"application-inference-profile/abcdef123456"
|
||||
"bedrock/converse/arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abcdef123456"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -2111,9 +2070,7 @@ def test_should_add_cache_control_for_anthropic_model():
|
|||
"vertex_ai/claude-3-sonnet@20240229",
|
||||
]:
|
||||
target = {}
|
||||
adapter._add_cache_control_if_applicable(
|
||||
{"cache_control": cache_control}, target, model
|
||||
)
|
||||
adapter._add_cache_control_if_applicable({"cache_control": cache_control}, target, model)
|
||||
assert "cache_control" in target
|
||||
assert target["cache_control"] == cache_control
|
||||
|
||||
|
|
@ -2128,9 +2085,7 @@ def test_should_not_add_cache_control_for_non_anthropic_model():
|
|||
"openai/gpt-4-turbo",
|
||||
]:
|
||||
target = {}
|
||||
adapter._add_cache_control_if_applicable(
|
||||
{"cache_control": cache_control}, target, model
|
||||
)
|
||||
adapter._add_cache_control_if_applicable({"cache_control": cache_control}, target, model)
|
||||
assert "cache_control" not in target
|
||||
|
||||
|
||||
|
|
@ -2145,9 +2100,7 @@ def test_should_add_cache_control_for_gemini_model():
|
|||
"vertex_ai/gemini-2.5-pro",
|
||||
]:
|
||||
target = {}
|
||||
adapter._add_cache_control_if_applicable(
|
||||
{"cache_control": cache_control}, target, model
|
||||
)
|
||||
adapter._add_cache_control_if_applicable({"cache_control": cache_control}, target, model)
|
||||
assert target.get("cache_control") == cache_control
|
||||
|
||||
|
||||
|
|
@ -2185,9 +2138,7 @@ def test_should_not_add_cache_control_when_none():
|
|||
{},
|
||||
]:
|
||||
target = {}
|
||||
adapter._add_cache_control_if_applicable(
|
||||
source, target, CACHE_CONTROL_BEDROCK_CONVERSE_MODEL
|
||||
)
|
||||
adapter._add_cache_control_if_applicable(source, target, CACHE_CONTROL_BEDROCK_CONVERSE_MODEL)
|
||||
assert "cache_control" not in target
|
||||
|
||||
|
||||
|
|
@ -2198,9 +2149,7 @@ def test_should_not_add_cache_control_when_model_none():
|
|||
|
||||
for model in [None, ""]:
|
||||
target = {}
|
||||
adapter._add_cache_control_if_applicable(
|
||||
{"cache_control": cache_control}, target, model
|
||||
)
|
||||
adapter._add_cache_control_if_applicable({"cache_control": cache_control}, target, model)
|
||||
assert "cache_control" not in target
|
||||
|
||||
|
||||
|
|
@ -2306,12 +2255,7 @@ def test_cache_control_fix_does_not_broaden_claude_detection():
|
|||
make is_anthropic_claude_model treat ARN profiles as Claude, which would route
|
||||
thinking params through unmodified and break non-Claude Bedrock profiles.
|
||||
"""
|
||||
assert (
|
||||
LiteLLMAnthropicMessagesAdapter.is_anthropic_claude_model(
|
||||
CACHE_CONTROL_BEDROCK_ARN_MODEL
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert LiteLLMAnthropicMessagesAdapter.is_anthropic_claude_model(CACHE_CONTROL_BEDROCK_ARN_MODEL) is False
|
||||
|
||||
|
||||
def test_thinking_preserved_for_bedrock_arn_inference_profile():
|
||||
|
|
@ -2940,9 +2884,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_reasoning_content_without
|
|||
(
|
||||
type_of_content,
|
||||
content_block_delta,
|
||||
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(
|
||||
choices=choices
|
||||
)
|
||||
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(choices=choices)
|
||||
|
||||
assert type_of_content == "thinking_delta"
|
||||
assert content_block_delta["type"] == "thinking_delta"
|
||||
|
|
@ -2974,9 +2916,7 @@ def test_translate_openai_response_to_anthropic_with_reasoning_content_only():
|
|||
)
|
||||
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
anthropic_response = adapter.translate_openai_response_to_anthropic(
|
||||
response=openai_response
|
||||
)
|
||||
anthropic_response = adapter.translate_openai_response_to_anthropic(response=openai_response)
|
||||
|
||||
anthropic_content = anthropic_response.get("content")
|
||||
assert anthropic_content is not None
|
||||
|
|
@ -2989,9 +2929,7 @@ def test_translate_openai_response_to_anthropic_with_reasoning_content_only():
|
|||
|
||||
# Second block should be text
|
||||
assert anthropic_content[1]["type"] == "text"
|
||||
assert (
|
||||
anthropic_content[1]["text"] == 'There are **3** "r"s in the word strawberry.'
|
||||
)
|
||||
assert anthropic_content[1]["text"] == 'There are **3** "r"s in the word strawberry.'
|
||||
|
||||
assert anthropic_response.get("stop_reason") == "end_turn"
|
||||
|
||||
|
|
@ -3043,9 +2981,7 @@ def test_truncate_tool_name_deterministic():
|
|||
def test_truncate_tool_name_avoids_collisions():
|
||||
"""Similar long names should produce different truncated names."""
|
||||
name1 = "process_user_data_with_validation_and_error_handling_for_production_environment"
|
||||
name2 = (
|
||||
"process_user_data_with_validation_and_error_handling_for_staging_environment"
|
||||
)
|
||||
name2 = "process_user_data_with_validation_and_error_handling_for_staging_environment"
|
||||
|
||||
result1 = truncate_tool_name(name1)
|
||||
result2 = truncate_tool_name(name2)
|
||||
|
|
@ -3065,9 +3001,7 @@ def test_create_tool_name_mapping_no_long_names():
|
|||
|
||||
def test_create_tool_name_mapping_with_long_names():
|
||||
"""Mapping should contain entries for truncated names."""
|
||||
long_name = (
|
||||
"a_very_long_tool_name_that_exceeds_the_64_character_limit_imposed_by_openai"
|
||||
)
|
||||
long_name = "a_very_long_tool_name_that_exceeds_the_64_character_limit_imposed_by_openai"
|
||||
tools = [
|
||||
{"name": "short_name"},
|
||||
{"name": long_name},
|
||||
|
|
@ -3092,9 +3026,7 @@ def test_translate_anthropic_tools_with_long_names():
|
|||
]
|
||||
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
result, tool_name_mapping = adapter.translate_anthropic_tools_to_openai(
|
||||
tools=tools, model="gpt-4"
|
||||
)
|
||||
result, tool_name_mapping = adapter.translate_anthropic_tools_to_openai(tools=tools, model="gpt-4")
|
||||
|
||||
assert len(result) == 1
|
||||
# The tool name should be truncated
|
||||
|
|
@ -3116,9 +3048,7 @@ def test_translate_anthropic_tools_mixed_names():
|
|||
]
|
||||
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
result, tool_name_mapping = adapter.translate_anthropic_tools_to_openai(
|
||||
tools=tools, model="gpt-4"
|
||||
)
|
||||
result, tool_name_mapping = adapter.translate_anthropic_tools_to_openai(tools=tools, model="gpt-4")
|
||||
|
||||
assert len(result) == 2
|
||||
# Short name unchanged
|
||||
|
|
@ -3132,9 +3062,7 @@ def test_translate_anthropic_tools_mixed_names():
|
|||
|
||||
def test_translate_openai_response_restores_tool_names():
|
||||
"""Tool names in responses should be restored to original."""
|
||||
original_name = (
|
||||
"a_very_long_tool_name_that_needs_truncation_for_openai_api_compatibility"
|
||||
)
|
||||
original_name = "a_very_long_tool_name_that_needs_truncation_for_openai_api_compatibility"
|
||||
truncated_name = truncate_tool_name(original_name)
|
||||
tool_name_mapping = {truncated_name: original_name}
|
||||
|
||||
|
|
@ -3166,9 +3094,7 @@ def test_translate_openai_response_restores_tool_names():
|
|||
)
|
||||
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
result = adapter.translate_openai_response_to_anthropic(
|
||||
response=response, tool_name_mapping=tool_name_mapping
|
||||
)
|
||||
result = adapter.translate_openai_response_to_anthropic(response=response, tool_name_mapping=tool_name_mapping)
|
||||
|
||||
# Find the tool_use block in the response
|
||||
tool_use_blocks = [c for c in result["content"] if c.get("type") == "tool_use"]
|
||||
|
|
@ -3589,9 +3515,7 @@ def test_translate_streaming_openai_response_to_anthropic_cache_tokens_with_appl
|
|||
assert message_delta["usage"]["output_tokens"] == 50
|
||||
assert message_delta["usage"]["cache_read_input_tokens"] == 30
|
||||
assert message_delta["usage"]["cache_creation_input_tokens"] == 20
|
||||
assert message_delta["context_management"]["applied_edits"][0]["type"] == (
|
||||
"compact_20260112"
|
||||
)
|
||||
assert message_delta["context_management"]["applied_edits"][0]["type"] == ("compact_20260112")
|
||||
|
||||
|
||||
# =====================================================================
|
||||
|
|
@ -3640,9 +3564,7 @@ def test_translate_anthropic_client_web_search_preserves_schema_and_choice(schem
|
|||
translated, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai(request)
|
||||
|
||||
assert "web_search_options" not in translated
|
||||
assert translated["tools"] == [
|
||||
{"type": "function", "function": {"name": "web_search", "parameters": schema}}
|
||||
]
|
||||
assert translated["tools"] == [{"type": "function", "function": {"name": "web_search", "parameters": schema}}]
|
||||
assert translated["tool_choice"] == {"type": "function", "function": {"name": "web_search"}}
|
||||
|
||||
|
||||
|
|
@ -3787,15 +3709,8 @@ class TestTranslateAnthropicOutputFormatToOpenAI:
|
|||
assert schema["required"] == ["user"]
|
||||
assert schema["properties"]["user"]["additionalProperties"] is False
|
||||
assert schema["properties"]["user"]["required"] == ["name", "address"]
|
||||
assert (
|
||||
schema["properties"]["user"]["properties"]["address"][
|
||||
"additionalProperties"
|
||||
]
|
||||
is False
|
||||
)
|
||||
assert schema["properties"]["user"]["properties"]["address"]["required"] == [
|
||||
"city"
|
||||
]
|
||||
assert schema["properties"]["user"]["properties"]["address"]["additionalProperties"] is False
|
||||
assert schema["properties"]["user"]["properties"]["address"]["required"] == ["city"]
|
||||
|
||||
def test_array_items_object_adds_additional_properties_false(self):
|
||||
output_format = {
|
||||
|
|
@ -3870,19 +3785,9 @@ class TestTranslateAnthropicOutputFormatToOpenAI:
|
|||
assert sorted(schema["required"]) == ["age", "email", "name"]
|
||||
|
||||
def test_invalid_output_format_returns_none(self):
|
||||
assert (
|
||||
self.adapter.translate_anthropic_output_format_to_openai("invalid") is None
|
||||
)
|
||||
assert (
|
||||
self.adapter.translate_anthropic_output_format_to_openai({"type": "text"})
|
||||
is None
|
||||
)
|
||||
assert (
|
||||
self.adapter.translate_anthropic_output_format_to_openai(
|
||||
{"type": "json_schema"}
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert self.adapter.translate_anthropic_output_format_to_openai("invalid") is None
|
||||
assert self.adapter.translate_anthropic_output_format_to_openai({"type": "text"}) is None
|
||||
assert self.adapter.translate_anthropic_output_format_to_openai({"type": "json_schema"}) is None
|
||||
|
||||
|
||||
class TestAnthropicStreamWrapperToolArgs:
|
||||
|
|
@ -4086,9 +3991,7 @@ def test_translate_openai_response_to_anthropic_with_polyfill_compaction_block()
|
|||
)
|
||||
response = _make_simple_openai_response(text="Hello after compaction.")
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
result = adapter.translate_openai_response_to_anthropic(
|
||||
response=response, polyfill_result=polyfill
|
||||
)
|
||||
result = adapter.translate_openai_response_to_anthropic(response=response, polyfill_result=polyfill)
|
||||
|
||||
content = result.get("content")
|
||||
assert content is not None
|
||||
|
|
@ -4120,9 +4023,7 @@ def test_translate_openai_response_to_anthropic_with_polyfill_iterations_usage()
|
|||
)
|
||||
response = _make_simple_openai_response(prompt_tokens=100, completion_tokens=30)
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
result = adapter.translate_openai_response_to_anthropic(
|
||||
response=response, polyfill_result=polyfill
|
||||
)
|
||||
result = adapter.translate_openai_response_to_anthropic(response=response, polyfill_result=polyfill)
|
||||
|
||||
usage = result.get("usage")
|
||||
assert usage is not None
|
||||
|
|
@ -4177,13 +4078,9 @@ def test_translate_openai_response_to_anthropic_with_polyfill_both_compaction_an
|
|||
{"type": "compaction", "input_tokens": 300, "output_tokens": 75},
|
||||
],
|
||||
)
|
||||
response = _make_simple_openai_response(
|
||||
text="After compaction.", prompt_tokens=120, completion_tokens=40
|
||||
)
|
||||
response = _make_simple_openai_response(text="After compaction.", prompt_tokens=120, completion_tokens=40)
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
result = adapter.translate_openai_response_to_anthropic(
|
||||
response=response, polyfill_result=polyfill
|
||||
)
|
||||
result = adapter.translate_openai_response_to_anthropic(response=response, polyfill_result=polyfill)
|
||||
|
||||
# compaction block must come first
|
||||
content = result.get("content")
|
||||
|
|
@ -4285,7 +4182,9 @@ def test_translate_anthropic_tools_to_openai_omits_unset_strict():
|
|||
assert function["parameters"]["required"] == ["query"]
|
||||
|
||||
|
||||
TOOL_RESULT_IMAGE_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
TOOL_RESULT_IMAGE_B64 = (
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
)
|
||||
TOOL_RESULT_IMAGE_URL = "https://example.com/screenshot.png"
|
||||
|
||||
|
||||
|
|
@ -4293,8 +4192,7 @@ def _anthropic_tool_use_turn(*tool_use_ids):
|
|||
return AnthopicMessagesAssistantMessageParam(
|
||||
role="assistant",
|
||||
content=[
|
||||
{"type": "tool_use", "id": tid, "name": "read_file", "input": {"path": "img.png"}}
|
||||
for tid in tool_use_ids
|
||||
{"type": "tool_use", "id": tid, "name": "read_file", "input": {"path": "img.png"}} for tid in tool_use_ids
|
||||
],
|
||||
)
|
||||
|
||||
|
|
@ -4414,9 +4312,7 @@ def test_tool_result_parallel_tool_calls_keep_tool_message_adjacency():
|
|||
result = _run_chat_completions_pipeline(
|
||||
[
|
||||
_anthropic_tool_use_turn("toolu_01", "toolu_02"),
|
||||
_anthropic_tool_result_turn(
|
||||
{"toolu_01": [_base64_image_block()], "toolu_02": [_url_image_block()]}
|
||||
),
|
||||
_anthropic_tool_result_turn({"toolu_01": [_base64_image_block()], "toolu_02": [_url_image_block()]}),
|
||||
]
|
||||
)
|
||||
|
||||
|
|
@ -4607,10 +4503,7 @@ def test_tool_result_text_and_document_reach_bedrock_converse_tool_result():
|
|||
)
|
||||
|
||||
tool_results = [
|
||||
block["toolResult"]
|
||||
for message in converse_messages
|
||||
for block in message["content"]
|
||||
if "toolResult" in block
|
||||
block["toolResult"] for message in converse_messages for block in message["content"] if "toolResult" in block
|
||||
]
|
||||
assert len(tool_results) == 1
|
||||
documents = [part["document"] for part in tool_results[0]["content"] if "document" in part]
|
||||
|
|
@ -4669,7 +4562,9 @@ def test_translate_anthropic_to_openai_without_prompt_cache_breakpoint_adds_noth
|
|||
def test_translate_anthropic_messages_to_openai_carries_midturn_system_prompt_cache_breakpoint():
|
||||
explicit = {"mode": "explicit"}
|
||||
result = LiteLLMAnthropicMessagesAdapter().translate_anthropic_messages_to_openai(
|
||||
messages=[{"role": "system", "content": [{"type": "text", "text": "fix", "prompt_cache_breakpoint": explicit}]}],
|
||||
messages=[
|
||||
{"role": "system", "content": [{"type": "text", "text": "fix", "prompt_cache_breakpoint": explicit}]}
|
||||
],
|
||||
model="gpt-5.6",
|
||||
)
|
||||
assert result == [
|
||||
|
|
@ -5419,3 +5314,322 @@ async def test_chat_bridge_tool_result_text_block_without_text_reaches_the_model
|
|||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_translate_anthropic_to_openai_midturn_tool_addition():
|
||||
"""
|
||||
Test that mid-conversation tool_addition blocks (such as Claude Code's mid-conversation-tool-changes)
|
||||
are hoisted into the request's tools and stripped from the translated messages.
|
||||
"""
|
||||
messages = [
|
||||
{"role": "user", "content": "What is the weather in Dallas?"},
|
||||
{
|
||||
"role": "system",
|
||||
"content": [
|
||||
{"type": "text", "text": "A new tool is available."},
|
||||
{
|
||||
"type": "tool_addition",
|
||||
"tool": {
|
||||
"type": "tool_definition",
|
||||
"definition": {
|
||||
"name": "get_weather",
|
||||
"description": "Get the weather for a city",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
req = {"model": "openai/some-open-model", "messages": messages}
|
||||
translated_req, _ = adapter.translate_anthropic_to_openai(req)
|
||||
|
||||
# Verify tool was added to tools
|
||||
assert "tools" in translated_req
|
||||
assert len(translated_req["tools"]) == 1
|
||||
tool = translated_req["tools"][0]
|
||||
assert tool["type"] == "function"
|
||||
assert tool["function"]["name"] == "get_weather"
|
||||
assert tool["function"]["description"] == "Get the weather for a city"
|
||||
assert tool["function"]["parameters"]["properties"]["city"]["type"] == "string"
|
||||
|
||||
# Verify tool_addition was stripped and text preserved
|
||||
assert len(translated_req["messages"]) == 2
|
||||
assert "get_weather" not in json.dumps(translated_req["messages"])
|
||||
|
||||
|
||||
def test_translate_anthropic_to_openai_midturn_tool_addition_carrier_only():
|
||||
"""
|
||||
Test that when a system message contains only tool_addition, the tool is added
|
||||
and the carrier message is dropped so no empty operator note is sent.
|
||||
"""
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{
|
||||
"role": "system",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_addition",
|
||||
"tool": {
|
||||
"type": "tool_definition",
|
||||
"definition": {
|
||||
"name": "calc",
|
||||
"description": "Calculator",
|
||||
"input_schema": {"type": "object"},
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "Calculate 2+2"},
|
||||
]
|
||||
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
req = {"model": "openai/some-open-model", "messages": messages}
|
||||
translated_req, _ = adapter.translate_anthropic_to_openai(req)
|
||||
|
||||
assert "tools" in translated_req
|
||||
assert len(translated_req["tools"]) == 1
|
||||
assert translated_req["tools"][0]["function"]["name"] == "calc"
|
||||
|
||||
# Carrier message should be dropped entirely
|
||||
assert len(translated_req["messages"]) == 2
|
||||
assert translated_req["messages"][0]["content"] == "Hello"
|
||||
assert translated_req["messages"][1]["content"] == "Calculate 2+2"
|
||||
|
||||
|
||||
def test_translate_anthropic_to_openai_midturn_tool_removal():
|
||||
"""
|
||||
Test that mid-conversation tool_removal drops the tool from the request.
|
||||
"""
|
||||
messages = [
|
||||
{"role": "user", "content": "Start"},
|
||||
{"role": "system", "content": [{"type": "tool_removal", "tool_name": "calc"}]},
|
||||
]
|
||||
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
req = {
|
||||
"model": "openai/some-open-model",
|
||||
"messages": messages,
|
||||
"tools": [
|
||||
{"name": "calc", "input_schema": {}},
|
||||
{"name": "search", "input_schema": {}},
|
||||
],
|
||||
}
|
||||
translated_req, _ = adapter.translate_anthropic_to_openai(req)
|
||||
|
||||
assert "tools" in translated_req
|
||||
tool_names = [t["function"]["name"] for t in translated_req["tools"]]
|
||||
assert tool_names == ["search"]
|
||||
|
||||
|
||||
def test_translate_anthropic_to_openai_midturn_tool_addition_deduplication():
|
||||
"""
|
||||
Test that adding a tool that is already in top-level tools skips duplicate definition.
|
||||
"""
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{
|
||||
"role": "system",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_addition",
|
||||
"tool": {
|
||||
"name": "calc",
|
||||
"description": "Calculator",
|
||||
"input_schema": {"type": "object"},
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
req = {
|
||||
"model": "openai/some-open-model",
|
||||
"messages": messages,
|
||||
"tools": [{"name": "calc", "input_schema": {}}],
|
||||
}
|
||||
translated_req, _ = adapter.translate_anthropic_to_openai(req)
|
||||
|
||||
assert len(translated_req["tools"]) == 1
|
||||
assert translated_req["tools"][0]["function"]["name"] == "calc"
|
||||
|
||||
|
||||
def test_translate_anthropic_to_openai_midturn_tool_addition_and_removal_shapes():
|
||||
"""
|
||||
Test alternative payload shapes for tool_addition and tool_removal.
|
||||
"""
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
|
||||
# 1. block with "definition" directly
|
||||
req1 = {
|
||||
"model": "openai/some-open-model",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_addition",
|
||||
"definition": {"name": "def_tool", "input_schema": {}},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
t_req1, _ = adapter.translate_anthropic_to_openai(req1)
|
||||
assert any(t["function"]["name"] == "def_tool" for t in t_req1["tools"])
|
||||
|
||||
# 2. block with "tool_definition" directly
|
||||
req2 = {
|
||||
"model": "openai/some-open-model",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_addition",
|
||||
"tool_definition": {"name": "tool_def_tool", "input_schema": {}},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
t_req2, _ = adapter.translate_anthropic_to_openai(req2)
|
||||
assert any(t["function"]["name"] == "tool_def_tool" for t in t_req2["tools"])
|
||||
|
||||
# 3. block with "tool" as dict directly
|
||||
req3 = {
|
||||
"model": "openai/some-open-model",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_addition",
|
||||
"tool": {"name": "raw_tool", "input_schema": {}},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
t_req3, _ = adapter.translate_anthropic_to_openai(req3)
|
||||
assert any(t["function"]["name"] == "raw_tool" for t in t_req3["tools"])
|
||||
|
||||
# 4. removal variations: "name", "tool.name", "tool" as string
|
||||
req4 = {
|
||||
"model": "openai/some-open-model",
|
||||
"tools": [
|
||||
{"name": "rem1", "input_schema": {}},
|
||||
{"name": "rem2", "input_schema": {}},
|
||||
{"name": "rem3", "input_schema": {}},
|
||||
{"name": "keep", "input_schema": {}},
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": [
|
||||
{"type": "tool_removal", "name": "rem1"},
|
||||
{"type": "tool_removal", "tool": {"name": "rem2"}},
|
||||
{"type": "tool_removal", "tool": "rem3"},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
t_req4, _ = adapter.translate_anthropic_to_openai(req4)
|
||||
names = [t["function"]["name"] for t in t_req4["tools"]]
|
||||
assert names == ["keep"]
|
||||
|
||||
|
||||
def test_extract_request_tool_names_includes_midturn_additions():
|
||||
"""
|
||||
Test that AnthropicMessagesHandler.extract_request_tool_names discovers tools
|
||||
defined inside mid-conversation tool_addition blocks so allowlists are enforced.
|
||||
"""
|
||||
from litellm.llms.anthropic.chat.guardrail_translation.handler import (
|
||||
AnthropicMessagesHandler,
|
||||
)
|
||||
|
||||
handler = AnthropicMessagesHandler()
|
||||
data = {
|
||||
"model": "claude-3-5-sonnet-20241022",
|
||||
"tools": [{"name": "allowed_tool", "input_schema": {}}],
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{
|
||||
"role": "system",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_addition",
|
||||
"tool": {
|
||||
"type": "tool_definition",
|
||||
"definition": {
|
||||
"name": "smuggled_tool",
|
||||
"input_schema": {},
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
extracted_names = handler.extract_request_tool_names(data)
|
||||
assert "allowed_tool" in extracted_names
|
||||
assert "smuggled_tool" in extracted_names
|
||||
|
||||
|
||||
def test_strip_disallowed_tool_additions_when_guardrail_filters_tools():
|
||||
"""
|
||||
Test that _strip_disallowed_tool_additions strips any tool_addition block
|
||||
for tools that were removed by guardrails.
|
||||
"""
|
||||
from litellm.llms.anthropic.chat.guardrail_translation.handler import (
|
||||
AnthropicMessagesHandler,
|
||||
)
|
||||
|
||||
handler = AnthropicMessagesHandler()
|
||||
data = {
|
||||
"model": "claude-3-5-sonnet-20241022",
|
||||
"tools": [{"name": "allowed_tool", "input_schema": {}}],
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Do something"},
|
||||
{
|
||||
"type": "tool_addition",
|
||||
"tool": {"name": "disallowed_tool", "input_schema": {}},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "system",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_addition",
|
||||
"tool": {"name": "allowed_tool", "input_schema": {}},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
retained_names = frozenset({"allowed_tool"})
|
||||
handler._strip_disallowed_tool_additions(data, retained_names)
|
||||
|
||||
# Disallowed tool_addition was stripped from user message, but text remains
|
||||
user_content = data["messages"][0]["content"]
|
||||
assert len(user_content) == 1
|
||||
assert user_content[0]["text"] == "Do something"
|
||||
|
||||
# Allowed tool_addition remains
|
||||
sys_content = data["messages"][1]["content"]
|
||||
assert len(sys_content) == 1
|
||||
assert sys_content[0]["tool"]["name"] == "allowed_tool"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue