fix(anthropic): guard against empty tool_use names in messages adapter

When translating Anthropic messages to OpenAI format, a tool_use block whose
name is missing or blank was passed through verbatim, producing an empty
function name. OpenAI-compatible backends such as Mistral via vLLM reject those
with InvalidFunctionCallException, and a None name crashed outright in
truncate_tool_name with a TypeError. The tool definitions path already guarded
this case by substituting a placeholder; this applies the same guard to the
tool_use path via a shared resolve_tool_name helper

Fixes #30515
This commit is contained in:
Ritika shrestha 2026-06-29 14:18:44 -07:00
parent 20dabb781a
commit b0bf29170b
2 changed files with 78 additions and 7 deletions

View file

@ -47,6 +47,19 @@ def truncate_tool_name(name: str) -> str:
return f"{name[:TOOL_NAME_PREFIX_LENGTH]}_{name_hash}"
def resolve_tool_name(raw_name: Optional[str], fallback: str) -> str:
"""
Return a usable tool name, substituting ``fallback`` when the name is missing or blank.
OpenAI-compatible backends (e.g. Mistral via vLLM) reject tool calls whose function name
is empty, so an Anthropic tool_use block or tool definition without a usable name must be
given a non-empty placeholder rather than passed through as an empty string.
"""
if raw_name is not None and str(raw_name).strip():
return str(raw_name)
return fallback
def create_tool_name_mapping(
tools: List[Dict[str, Any]],
) -> Dict[str, str]:
@ -529,8 +542,8 @@ class LiteLLMAnthropicMessagesAdapter:
has_cache_control_in_text = True
assistant_content_list.append(text_block)
elif content.get("type") == "tool_use":
# Truncate tool name for OpenAI's 64-char limit
tool_name = truncate_tool_name(content.get("name", ""))
original_name = resolve_tool_name(content.get("name"), "litellm_unnamed_tool")
tool_name = truncate_tool_name(original_name)
function_chunk: ChatCompletionToolCallFunctionChunk = {
"name": tool_name,
"arguments": json.dumps(content.get("input", {})),
@ -750,11 +763,7 @@ class LiteLLMAnthropicMessagesAdapter:
new_tools.append(tool) # type: ignore[arg-type]
continue
raw_name = tool.get("name")
if raw_name is None or (isinstance(raw_name, str) and not str(raw_name).strip()):
original_name = f"litellm_unnamed_tool_{idx}"
else:
original_name = str(raw_name)
original_name = resolve_tool_name(tool.get("name"), f"litellm_unnamed_tool_{idx}")
truncated_name = truncate_tool_name(original_name)
# Store mapping if name was truncated

View file

@ -1075,6 +1075,68 @@ def test_translate_anthropic_messages_to_openai_tool_use_with_signature():
)
@pytest.mark.parametrize("tool_use_name", ["", " ", None])
def test_translate_anthropic_messages_to_openai_tool_use_without_name(tool_use_name):
"""Regression test for https://github.com/BerriAI/litellm/issues/30515.
A tool_use block whose name is missing or blank must not produce an empty OpenAI
function name; OpenAI-compatible backends such as Mistral via vLLM reject those.
Before the fix an empty name was passed through verbatim and a None name crashed
with a TypeError inside truncate_tool_name.
"""
messages = cast(
Any,
[
{"role": "user", "content": [{"type": "text", "text": "hi"}]},
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_1",
"name": tool_use_name,
"input": {"q": "x"},
}
],
},
],
)
adapter = LiteLLMAnthropicMessagesAdapter()
result = adapter.translate_anthropic_messages_to_openai(messages=messages)
tool_calls = result[1]["tool_calls"]
assert len(tool_calls) == 1
function_name = tool_calls[0]["function"]["name"]
assert function_name and function_name.strip()
def test_translate_anthropic_messages_to_openai_tool_use_preserves_name():
"""A valid tool_use name must be passed through unchanged."""
messages = cast(
Any,
[
{"role": "user", "content": [{"type": "text", "text": "hi"}]},
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_1",
"name": "get_weather",
"input": {"location": "London"},
}
],
},
],
)
adapter = LiteLLMAnthropicMessagesAdapter()
result = adapter.translate_anthropic_messages_to_openai(messages=messages)
assert result[1]["tool_calls"][0]["function"]["name"] == "get_weather"
def test_translate_anthropic_messages_to_openai_tool_result_with_multiple_content_items():
"""
Test that tool_result with multiple content items creates a single tool message