mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
fix(anthropic): support mid-conversation tool_addition and tool_removal for non-Anthropic models (#47531)
This commit is contained in:
parent
77880a0653
commit
45106e7078
2 changed files with 255 additions and 5 deletions
|
|
@ -474,7 +474,8 @@ 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(cleaned_input_messages)
|
||||
leading_count: Final = next(
|
||||
(i for i, m in enumerate(replayable_messages) if not is_system_role_message(m)),
|
||||
len(replayable_messages),
|
||||
|
|
@ -1230,6 +1231,93 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
if k not in translatable_params: # pass remaining params as is
|
||||
new_kwargs[k] = v
|
||||
|
||||
def _extract_and_apply_midturn_tool_changes(
|
||||
self,
|
||||
messages: Sequence[Mapping[str, object] | AllAnthropicPassThroughMessageValues],
|
||||
initial_tools: Sequence[AllAnthropicToolsValues] | None = None,
|
||||
) -> tuple[list[AllAnthropicToolsValues] | None, list[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 list with additions applied and removals removed,
|
||||
or None if no tools exist and none were added.
|
||||
- cleaned_messages: Messages list with tool_addition and tool_removal blocks stripped,
|
||||
and carrier messages left without content dropped.
|
||||
"""
|
||||
active_tools: list[AllAnthropicToolsValues] = list(initial_tools) if initial_tools else []
|
||||
had_tool_changes = False
|
||||
cleaned_messages: list[AllAnthropicPassThroughMessageValues] = []
|
||||
|
||||
for m in messages:
|
||||
if not isinstance(m, dict):
|
||||
cleaned_messages.append(m) # type: ignore[arg-type]
|
||||
continue
|
||||
content = m.get("content")
|
||||
if not isinstance(content, list):
|
||||
cleaned_messages.append(cast(AllAnthropicPassThroughMessageValues, m))
|
||||
continue
|
||||
|
||||
remaining_blocks: list[dict[str, Any]] = []
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
remaining_blocks.append(block)
|
||||
continue
|
||||
btype = block.get("type")
|
||||
if btype == "tool_addition":
|
||||
had_tool_changes = True
|
||||
tool_data = block.get("tool")
|
||||
if isinstance(tool_data, dict):
|
||||
if (
|
||||
tool_data.get("type") == "tool_definition"
|
||||
and "definition" in tool_data
|
||||
and isinstance(tool_data["definition"], dict)
|
||||
):
|
||||
raw_tool = tool_data["definition"]
|
||||
else:
|
||||
raw_tool = tool_data
|
||||
elif "definition" in block and isinstance(block["definition"], dict):
|
||||
raw_tool = block["definition"]
|
||||
elif isinstance(block.get("tool_definition"), dict):
|
||||
raw_tool = block["tool_definition"]
|
||||
else:
|
||||
raw_tool = block
|
||||
|
||||
if isinstance(raw_tool, dict) and raw_tool.get("name"):
|
||||
tool_name = str(raw_tool["name"])
|
||||
if not any(isinstance(t, dict) and t.get("name") == tool_name for t in active_tools):
|
||||
active_tools.append(cast(AllAnthropicToolsValues, raw_tool))
|
||||
elif btype == "tool_removal":
|
||||
had_tool_changes = True
|
||||
rem_name = (
|
||||
block.get("tool_name")
|
||||
or block.get("name")
|
||||
or (block.get("tool", {}).get("name") if isinstance(block.get("tool"), dict) else None)
|
||||
or (block.get("tool") if isinstance(block.get("tool"), str) else None)
|
||||
)
|
||||
if rem_name:
|
||||
rem_name_str = str(rem_name)
|
||||
active_tools = [
|
||||
t for t in active_tools
|
||||
if not (isinstance(t, dict) and t.get("name") == rem_name_str)
|
||||
]
|
||||
else:
|
||||
remaining_blocks.append(block)
|
||||
|
||||
if remaining_blocks:
|
||||
new_m = dict(m)
|
||||
new_m["content"] = remaining_blocks
|
||||
cleaned_messages.append(cast(AllAnthropicPassThroughMessageValues, new_m))
|
||||
elif not had_tool_changes:
|
||||
cleaned_messages.append(cast(AllAnthropicPassThroughMessageValues, m))
|
||||
|
||||
if not had_tool_changes and initial_tools is None:
|
||||
return None, cleaned_messages
|
||||
|
||||
return active_tools if active_tools else ([] if initial_tools is not None or had_tool_changes else None), cleaned_messages
|
||||
|
||||
def translate_anthropic_to_openai(
|
||||
self,
|
||||
anthropic_message_request: AnthropicMessagesRequest,
|
||||
|
|
@ -1255,14 +1343,30 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
list[AllAnthropicPassThroughMessageValues],
|
||||
anthropic_message_request["messages"],
|
||||
)
|
||||
new_messages = self.translate_anthropic_messages_to_openai(
|
||||
(
|
||||
active_tools,
|
||||
cleaned_messages,
|
||||
) = self._extract_and_apply_midturn_tool_changes(
|
||||
messages=messages_list,
|
||||
model=anthropic_message_request.get("model"),
|
||||
initial_tools=anthropic_message_request.get("tools"),
|
||||
)
|
||||
request_for_translation: Final[AnthropicMessagesRequest] = cast(
|
||||
AnthropicMessagesRequest, dict(anthropic_message_request)
|
||||
)
|
||||
if active_tools is not None:
|
||||
if active_tools:
|
||||
request_for_translation["tools"] = active_tools
|
||||
else:
|
||||
request_for_translation.pop("tools", None)
|
||||
|
||||
new_messages = self.translate_anthropic_messages_to_openai(
|
||||
messages=cleaned_messages,
|
||||
model=request_for_translation.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 +1385,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
|
||||
|
|
|
|||
|
|
@ -5419,3 +5419,149 @@ 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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue