From b0bf29170bc6a0ac8ff6a6abb5e4f7cf83b29d90 Mon Sep 17 00:00:00 2001 From: Ritika shrestha <87307821+ritsth@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:18:44 -0700 Subject: [PATCH] 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 --- .../adapters/transformation.py | 23 ++++--- ...al_pass_through_adapters_transformation.py | 62 +++++++++++++++++++ 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 02625605f37..2c7108ce22f 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -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 diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index 45820b9833f..9fbc44106e8 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -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