fix(anthropic-adapter): truncate tool names exceeding OpenAI's 64-char limit (#20107)

When using LiteLLM's Anthropic /v1/messages endpoint to route requests to
OpenAI models, requests fail if any tool name exceeds OpenAI's 64-character
limit. Anthropic API has no such limit, causing compatibility issues.

Changes:
- Add truncate_tool_name() function using {55-char-prefix}_{8-char-hash} format
- Modify translate_anthropic_tools_to_openai() to truncate and return mapping
- Modify translate_anthropic_tool_choice_to_openai() to truncate tool name
- Restore original tool names in responses using the mapping
- Support tool name restoration in streaming responses
- Add backwards-compatible API (existing methods still work)

The fix only applies when routing Anthropic requests to OpenAI models.
Native Anthropic/Claude requests pass through unchanged.
This commit is contained in:
Cesar Garcia
2026-01-31 15:51:40 -03:00
committed by Sameer Kankute
parent 72e5193451
commit 61a84e9fdb
4 changed files with 383 additions and 31 deletions
@@ -6,6 +6,7 @@ from typing import (
Dict,
List,
Optional,
Tuple,
Union,
cast,
)
@@ -47,8 +48,14 @@ class LiteLLMMessagesToCompletionTransformationHandler:
top_p: Optional[float] = None,
output_format: Optional[Dict] = None,
extra_kwargs: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Prepare kwargs for litellm.completion/acompletion"""
) -> Tuple[Dict[str, Any], Dict[str, str]]:
"""Prepare kwargs for litellm.completion/acompletion.
Returns:
Tuple of (completion_kwargs, tool_name_mapping)
- tool_name_mapping maps truncated tool names back to original names
for tools that exceeded OpenAI's 64-char limit
"""
from litellm.litellm_core_utils.litellm_logging import (
Logging as LiteLLMLoggingObject,
)
@@ -80,7 +87,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
if output_format:
request_data["output_format"] = output_format
openai_request = ANTHROPIC_ADAPTER.translate_completion_input_params(
openai_request, tool_name_mapping = ANTHROPIC_ADAPTER.translate_completion_input_params_with_tool_mapping(
request_data
)
@@ -116,7 +123,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
):
completion_kwargs[key] = value
return completion_kwargs
return completion_kwargs, tool_name_mapping
@staticmethod
async def async_anthropic_messages_handler(
@@ -137,7 +144,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
**kwargs,
) -> Union[AnthropicMessagesResponse, AsyncIterator]:
"""Handle non-Anthropic models asynchronously using the adapter"""
completion_kwargs = (
completion_kwargs, tool_name_mapping = (
LiteLLMMessagesToCompletionTransformationHandler._prepare_completion_kwargs(
max_tokens=max_tokens,
messages=messages,
@@ -164,6 +171,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
ANTHROPIC_ADAPTER.translate_completion_output_params_streaming(
completion_response,
model=model,
tool_name_mapping=tool_name_mapping,
)
)
if transformed_stream is not None:
@@ -172,7 +180,8 @@ class LiteLLMMessagesToCompletionTransformationHandler:
else:
anthropic_response = (
ANTHROPIC_ADAPTER.translate_completion_output_params(
cast(ModelResponse, completion_response)
cast(ModelResponse, completion_response),
tool_name_mapping=tool_name_mapping,
)
)
if anthropic_response is not None:
@@ -222,7 +231,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
**kwargs,
)
completion_kwargs = (
completion_kwargs, tool_name_mapping = (
LiteLLMMessagesToCompletionTransformationHandler._prepare_completion_kwargs(
max_tokens=max_tokens,
messages=messages,
@@ -249,6 +258,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
ANTHROPIC_ADAPTER.translate_completion_output_params_streaming(
completion_response,
model=model,
tool_name_mapping=tool_name_mapping,
)
)
if transformed_stream is not None:
@@ -257,7 +267,8 @@ class LiteLLMMessagesToCompletionTransformationHandler:
else:
anthropic_response = (
ANTHROPIC_ADAPTER.translate_completion_output_params(
cast(ModelResponse, completion_response)
cast(ModelResponse, completion_response),
tool_name_mapping=tool_name_mapping,
)
)
if anthropic_response is not None:
@@ -3,7 +3,7 @@
import json
import traceback
from collections import deque
from typing import TYPE_CHECKING, Any, AsyncIterator, Iterator, Literal, Optional
from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, Iterator, Literal, Optional
from litellm import verbose_logger
from litellm._uuid import uuid
@@ -44,9 +44,16 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
pending_new_content_block: bool = False
chunk_queue: deque = deque() # Queue for buffering multiple chunks
def __init__(self, completion_stream: Any, model: str):
def __init__(
self,
completion_stream: Any,
model: str,
tool_name_mapping: Optional[Dict[str, str]] = None,
):
super().__init__(completion_stream)
self.model = model
# Mapping of truncated tool names to original names (for OpenAI's 64-char limit)
self.tool_name_mapping = tool_name_mapping or {}
def _create_initial_usage_delta(self) -> UsageDelta:
"""
@@ -401,6 +408,12 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
choices=chunk.choices # type: ignore
)
# Restore original tool name if it was truncated for OpenAI's 64-char limit
if block_type == "tool_use" and content_block_start.get("name"):
truncated_name = content_block_start.get("name", "")
original_name = self.tool_name_mapping.get(truncated_name, truncated_name)
content_block_start["name"] = original_name
if block_type != self.current_content_block_type:
self.current_content_block_type = block_type
self.current_content_block_start = content_block_start
@@ -1,3 +1,4 @@
import hashlib
import json
from typing import (
TYPE_CHECKING,
@@ -12,6 +13,54 @@ from typing import (
cast,
)
# OpenAI has a 64-character limit for function/tool names
# Anthropic does not have this limit, so we need to truncate long names
OPENAI_MAX_TOOL_NAME_LENGTH = 64
TOOL_NAME_HASH_LENGTH = 8
TOOL_NAME_PREFIX_LENGTH = OPENAI_MAX_TOOL_NAME_LENGTH - TOOL_NAME_HASH_LENGTH - 1 # 55
def truncate_tool_name(name: str) -> str:
"""
Truncate tool names that exceed OpenAI's 64-character limit.
Uses format: {55-char-prefix}_{8-char-hash} to avoid collisions
when multiple tools have similar long names.
Args:
name: The original tool name
Returns:
The original name if <= 64 chars, otherwise truncated with hash
"""
if len(name) <= OPENAI_MAX_TOOL_NAME_LENGTH:
return name
# Create deterministic hash from full name to avoid collisions
name_hash = hashlib.sha256(name.encode()).hexdigest()[:TOOL_NAME_HASH_LENGTH]
return f"{name[:TOOL_NAME_PREFIX_LENGTH]}_{name_hash}"
def create_tool_name_mapping(
tools: List[Dict[str, Any]],
) -> Dict[str, str]:
"""
Create a mapping of truncated tool names to original names.
Args:
tools: List of tool definitions with 'name' field
Returns:
Dict mapping truncated names to original names (only for truncated tools)
"""
mapping: Dict[str, str] = {}
for tool in tools:
original_name = tool.get("name", "")
truncated_name = truncate_tool_name(original_name)
if truncated_name != original_name:
mapping[truncated_name] = original_name
return mapping
from openai.types.chat.chat_completion_chunk import Choice as OpenAIStreamingChoice
from litellm.litellm_core_utils.prompt_templates.common_utils import (
@@ -77,8 +126,29 @@ class AnthropicAdapter:
self, kwargs
) -> Optional[ChatCompletionRequest]:
"""
Translate Anthropic request params to OpenAI format.
- translate params, where needed
- pass rest, as is
Note: Use translate_completion_input_params_with_tool_mapping() if you need
the tool name mapping for restoring original names in responses.
"""
result, _ = self.translate_completion_input_params_with_tool_mapping(kwargs)
return result
def translate_completion_input_params_with_tool_mapping(
self, kwargs
) -> Tuple[Optional[ChatCompletionRequest], Dict[str, str]]:
"""
Translate Anthropic request params to OpenAI format, returning tool name mapping.
This method handles truncation of tool names that exceed OpenAI's 64-character
limit. The mapping allows restoring original names when translating responses.
Returns:
Tuple of (openai_request, tool_name_mapping)
- tool_name_mapping maps truncated tool names back to original names
"""
#########################################################
@@ -102,26 +172,51 @@ class AnthropicAdapter:
model=model, messages=messages, **kwargs
)
translated_body = (
translated_body, tool_name_mapping = (
LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai(
anthropic_message_request=request_body
)
)
return translated_body
return translated_body, tool_name_mapping
def translate_completion_output_params(
self, response: ModelResponse
self,
response: ModelResponse,
tool_name_mapping: Optional[Dict[str, str]] = None,
) -> Optional[AnthropicMessagesResponse]:
"""
Translate OpenAI response to Anthropic format.
Args:
response: The OpenAI ModelResponse
tool_name_mapping: Optional mapping of truncated tool names to original names.
Used to restore original names for tools that exceeded
OpenAI's 64-char limit.
"""
return LiteLLMAnthropicMessagesAdapter().translate_openai_response_to_anthropic(
response=response
response=response,
tool_name_mapping=tool_name_mapping,
)
def translate_completion_output_params_streaming(
self, completion_stream: Any, model: str
self,
completion_stream: Any,
model: str,
tool_name_mapping: Optional[Dict[str, str]] = None,
) -> Union[AsyncIterator[bytes], None]:
"""
Translate OpenAI streaming response to Anthropic format.
Args:
completion_stream: The OpenAI streaming response
model: The model name
tool_name_mapping: Optional mapping of truncated tool names to original names.
"""
anthropic_wrapper = AnthropicStreamWrapper(
completion_stream=completion_stream, model=model
completion_stream=completion_stream,
model=model,
tool_name_mapping=tool_name_mapping,
)
# Return the SSE-wrapped version for proper event formatting
return anthropic_wrapper.async_anthropic_sse_wrapper()
@@ -417,8 +512,10 @@ 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", ""))
function_chunk: ChatCompletionToolCallFunctionChunk = {
"name": content.get("name", ""),
"name": tool_name,
"arguments": json.dumps(content.get("input", {})),
}
signature = (
@@ -587,8 +684,11 @@ class LiteLLMAnthropicMessagesAdapter:
elif tool_choice["type"] == "auto":
return "auto"
elif tool_choice["type"] == "tool":
# Truncate tool name if it exceeds OpenAI's 64-char limit
original_name = tool_choice.get("name", "")
truncated_name = truncate_tool_name(original_name)
tc_function_param = ChatCompletionToolChoiceFunctionParam(
name=tool_choice.get("name", "")
name=truncated_name
)
return ChatCompletionToolChoiceObjectParam(
type="function", function=tc_function_param
@@ -600,12 +700,28 @@ class LiteLLMAnthropicMessagesAdapter:
def translate_anthropic_tools_to_openai(
self, tools: List[AllAnthropicToolsValues], model: Optional[str] = None
) -> List[ChatCompletionToolParam]:
) -> Tuple[List[ChatCompletionToolParam], Dict[str, str]]:
"""
Translate Anthropic tools to OpenAI format.
Returns:
Tuple of (translated_tools, tool_name_mapping)
- tool_name_mapping maps truncated names back to original names
for tools that exceeded OpenAI's 64-char limit
"""
new_tools: List[ChatCompletionToolParam] = []
tool_name_mapping: Dict[str, str] = {}
mapped_tool_params = ["name", "input_schema", "description", "cache_control"]
for tool in tools:
original_name = tool["name"]
truncated_name = truncate_tool_name(original_name)
# Store mapping if name was truncated
if truncated_name != original_name:
tool_name_mapping[truncated_name] = original_name
function_chunk = ChatCompletionToolParamFunctionChunk(
name=tool["name"],
name=truncated_name,
)
if "input_schema" in tool:
function_chunk["parameters"] = tool["input_schema"] # type: ignore
@@ -619,7 +735,7 @@ class LiteLLMAnthropicMessagesAdapter:
self._add_cache_control_if_applicable(tool, tool_param, model)
new_tools.append(tool_param) # type: ignore[arg-type]
return new_tools # type: ignore[return-value]
return new_tools, tool_name_mapping # type: ignore[return-value]
def translate_anthropic_output_format_to_openai(
self, output_format: Any
@@ -694,12 +810,18 @@ class LiteLLMAnthropicMessagesAdapter:
def translate_anthropic_to_openai(
self, anthropic_message_request: AnthropicMessagesRequest
) -> ChatCompletionRequest:
) -> Tuple[ChatCompletionRequest, Dict[str, str]]:
"""
This is used by the beta Anthropic Adapter, for translating anthropic `/v1/messages` requests to the openai format.
Returns:
Tuple of (openai_request, tool_name_mapping)
- 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
messages_list: List[
@@ -750,7 +872,7 @@ class LiteLLMAnthropicMessagesAdapter:
if "tools" in anthropic_message_request:
tools = anthropic_message_request["tools"]
if tools:
new_kwargs["tools"] = self.translate_anthropic_tools_to_openai(
new_kwargs["tools"], tool_name_mapping = self.translate_anthropic_tools_to_openai(
tools=cast(List[AllAnthropicToolsValues], tools),
model=new_kwargs.get("model"),
)
@@ -784,7 +906,7 @@ class LiteLLMAnthropicMessagesAdapter:
if k not in translatable_params: # pass remaining params as is
new_kwargs[k] = v # type: ignore
return new_kwargs
return new_kwargs, tool_name_mapping
def _translate_anthropic_image_to_openai(self, image_source: dict) -> Optional[str]:
"""
@@ -813,7 +935,11 @@ class LiteLLMAnthropicMessagesAdapter:
return None
def _translate_openai_content_to_anthropic(self, choices: List[Choices]) -> List[
def _translate_openai_content_to_anthropic(
self,
choices: List[Choices],
tool_name_mapping: Optional[Dict[str, str]] = None,
) -> List[
Union[
AnthropicResponseContentBlockText,
AnthropicResponseContentBlockToolUse,
@@ -895,13 +1021,21 @@ class LiteLLMAnthropicMessagesAdapter:
if signature:
provider_specific_fields["signature"] = signature
# Restore original tool name if it was truncated
truncated_name = tool_call.function.name or ""
original_name = (
tool_name_mapping.get(truncated_name, truncated_name)
if tool_name_mapping
else truncated_name
)
tool_use_block = AnthropicResponseContentBlockToolUse(
type="tool_use",
id=tool_call.id,
name=tool_call.function.name or "",
name=original_name,
input=parse_tool_call_arguments(
tool_call.function.arguments,
tool_name=tool_call.function.name,
tool_name=original_name,
context="Anthropic pass-through adapter",
),
)
@@ -926,10 +1060,24 @@ class LiteLLMAnthropicMessagesAdapter:
return "end_turn"
def translate_openai_response_to_anthropic(
self, response: ModelResponse
self,
response: ModelResponse,
tool_name_mapping: Optional[Dict[str, str]] = None,
) -> AnthropicMessagesResponse:
"""
Translate OpenAI response to Anthropic format.
Args:
response: The OpenAI ModelResponse
tool_name_mapping: Optional mapping of truncated tool names to original names.
Used to restore original names for tools that exceeded
OpenAI's 64-char limit.
"""
## translate content block
anthropic_content = self._translate_openai_content_to_anthropic(choices=response.choices) # type: ignore
anthropic_content = self._translate_openai_content_to_anthropic(
choices=response.choices, # type: ignore
tool_name_mapping=tool_name_mapping,
)
## extract finish reason
anthropic_finish_reason = self._translate_openai_finish_reason_to_anthropic(
openai_finish_reason=response.choices[0].finish_reason # type: ignore
@@ -8,7 +8,10 @@ sys.path.insert(0, os.path.abspath("../../../../.."))
from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import (
OPENAI_MAX_TOOL_NAME_LENGTH,
LiteLLMAnthropicMessagesAdapter,
create_tool_name_mapping,
truncate_tool_name,
)
from litellm.types.llms.anthropic import (
AnthopicMessagesAssistantMessageParam,
@@ -1388,12 +1391,13 @@ def test_cache_control_preserved_in_tools_for_claude():
]
adapter = LiteLLMAnthropicMessagesAdapter()
result = adapter.translate_anthropic_tools_to_openai(
result, tool_name_mapping = adapter.translate_anthropic_tools_to_openai(
tools=tools, model=CACHE_CONTROL_BEDROCK_CONVERSE_MODEL
)
assert len(result) == 1
assert result[0]["cache_control"] == {"type": "ephemeral"}
assert tool_name_mapping == {} # No truncation needed for short names
def test_cache_control_not_preserved_in_tools_for_non_claude():
@@ -1408,7 +1412,7 @@ def test_cache_control_not_preserved_in_tools_for_non_claude():
]
adapter = LiteLLMAnthropicMessagesAdapter()
result = adapter.translate_anthropic_tools_to_openai(
result, tool_name_mapping = adapter.translate_anthropic_tools_to_openai(
tools=tools, model=CACHE_CONTROL_NON_ANTHROPIC_MODEL
)
@@ -1527,3 +1531,179 @@ def test_translate_openai_response_to_anthropic_with_reasoning_content_only():
assert cast(Any, anthropic_content[1]).text == "There are **3** \"r\"s in the word strawberry."
assert anthropic_response.get("stop_reason") == "end_turn"
assert tool_name_mapping == {} # No truncation needed for short names
# =====================================================================
# Tool Name Truncation Tests (Issue #17904)
# OpenAI has a 64-character limit for function/tool names
# =====================================================================
def test_truncate_tool_name_short_name():
"""Short tool names should not be truncated."""
short_name = "get_weather"
result = truncate_tool_name(short_name)
assert result == short_name
assert len(result) <= OPENAI_MAX_TOOL_NAME_LENGTH
def test_truncate_tool_name_exactly_64_chars():
"""Tool names exactly 64 chars should not be truncated."""
name_64_chars = "a" * 64
result = truncate_tool_name(name_64_chars)
assert result == name_64_chars
assert len(result) == 64
def test_truncate_tool_name_long_name():
"""Long tool names should be truncated with hash suffix."""
long_name = "computer_tool_with_very_long_name_that_exceeds_openai_64_character_limit_and_keeps_going"
result = truncate_tool_name(long_name)
assert len(result) == OPENAI_MAX_TOOL_NAME_LENGTH
assert result != long_name
# Should have format: {55-char-prefix}_{8-char-hash}
assert "_" in result
parts = result.rsplit("_", 1)
assert len(parts[0]) == 55
assert len(parts[1]) == 8
def test_truncate_tool_name_deterministic():
"""Truncation should be deterministic (same input = same output)."""
long_name = "a_very_long_tool_name_that_needs_to_be_truncated_for_openai_compatibility_reasons"
result1 = truncate_tool_name(long_name)
result2 = truncate_tool_name(long_name)
assert result1 == result2
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"
result1 = truncate_tool_name(name1)
result2 = truncate_tool_name(name2)
assert result1 != result2 # Different hashes prevent collision
def test_create_tool_name_mapping_no_long_names():
"""Mapping should be empty when no names need truncation."""
tools = [
{"name": "get_weather"},
{"name": "search_web"},
]
mapping = create_tool_name_mapping(tools)
assert mapping == {}
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"
tools = [
{"name": "short_name"},
{"name": long_name},
]
mapping = create_tool_name_mapping(tools)
assert len(mapping) == 1
truncated = truncate_tool_name(long_name)
assert truncated in mapping
assert mapping[truncated] == long_name
def test_translate_anthropic_tools_with_long_names():
"""Tools with long names should be truncated and mapped."""
long_name = "computer_tool_with_very_long_descriptive_name_that_exceeds_openai_limit_completely"
tools = [
{
"name": long_name,
"description": "A tool with a very long name",
"input_schema": {"type": "object", "properties": {}},
}
]
adapter = LiteLLMAnthropicMessagesAdapter()
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
truncated_name = result[0]["function"]["name"]
assert len(truncated_name) <= 64
assert truncated_name != long_name
# Mapping should have the reverse lookup
assert truncated_name in tool_name_mapping
assert tool_name_mapping[truncated_name] == long_name
def test_translate_anthropic_tools_mixed_names():
"""Mix of short and long names should work correctly."""
short_name = "get_weather"
long_name = "process_complex_data_transformation_with_validation_and_error_handling_pipeline"
tools = [
{"name": short_name, "input_schema": {"type": "object"}},
{"name": long_name, "input_schema": {"type": "object"}},
]
adapter = LiteLLMAnthropicMessagesAdapter()
result, tool_name_mapping = adapter.translate_anthropic_tools_to_openai(
tools=tools, model="gpt-4"
)
assert len(result) == 2
# Short name unchanged
assert result[0]["function"]["name"] == short_name
# Long name truncated
assert result[1]["function"]["name"] != long_name
assert len(result[1]["function"]["name"]) <= 64
# Only long name in mapping
assert len(tool_name_mapping) == 1
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"
truncated_name = truncate_tool_name(original_name)
tool_name_mapping = {truncated_name: original_name}
# Create a mock OpenAI response with the truncated name
response = ModelResponse(
id="test-id",
choices=[
Choices(
index=0,
finish_reason="tool_calls",
message=Message(
role="assistant",
content=None,
tool_calls=[
ChatCompletionAssistantToolCall(
id="call_123",
type="function",
function=Function(
name=truncated_name,
arguments='{"arg": "value"}',
),
)
],
),
)
],
model="gpt-4",
usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15),
)
adapter = LiteLLMAnthropicMessagesAdapter()
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 getattr(c, "type", None) == "tool_use"]
assert len(tool_use_blocks) == 1
# Name should be restored to original
assert getattr(tool_use_blocks[0], "name", None) == original_name