fix(anthropic): support mid-conversation tool_addition and tool_removal for non-Anthropic models (#47531)

This commit is contained in:
Rahul Kumar 2026-10-20 15:45:29 +05:30
parent 77880a0653
commit 45106e7078
2 changed files with 255 additions and 5 deletions

View file

@ -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

View file

@ -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"