feat(utils): sanitize OpenAI tool names and restore originals via cache

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sameer Kankute 2026-05-04 16:58:20 +05:30
parent c011a7e3ba
commit d99d7dd9ec
No known key found for this signature in database
6 changed files with 264 additions and 9 deletions

View file

@ -1961,6 +1961,8 @@ if TYPE_CHECKING:
bedrock_tool_name_mappings: InMemoryCache
openai_tool_name_mappings: InMemoryCache
# Azure exception class (lazy-loaded)
from litellm.llms.azure.common_utils import AzureOpenAIError
@ -2053,6 +2055,18 @@ def __getattr__(name: str) -> Any:
_globals["bedrock_tool_name_mappings"] = _bedrock_tool_name_mappings
return _globals["bedrock_tool_name_mappings"]
if name == "openai_tool_name_mappings":
from ._lazy_imports import _get_litellm_globals
_globals = _get_litellm_globals()
if "openai_tool_name_mappings" not in _globals:
from .litellm_core_utils.openai_tool_name_mapping import (
openai_tool_name_mappings as _openai_tool_name_mappings,
)
_globals["openai_tool_name_mappings"] = _openai_tool_name_mappings
return _globals["openai_tool_name_mappings"]
# Lazy load AzureOpenAIError exception class
if name == "AzureOpenAIError":
from ._lazy_imports import _get_litellm_globals

View file

@ -71,6 +71,20 @@ def _normalize_images_for_message(
return normalized
def _map_tool_call_dict_openai_names_to_user(tc: dict) -> dict:
"""Restore client tool names when LiteLLM sanitized outbound tools for OpenAI."""
from litellm.litellm_core_utils.openai_tool_name_mapping import (
restore_openai_tool_name_for_user,
)
fn = tc.get("function")
if isinstance(fn, dict) and fn.get("name"):
restored = restore_openai_tool_name_for_user(fn["name"])
if restored != fn["name"]:
return {**tc, "function": {**fn, "name": restored}}
return tc
def _safe_convert_created_field(created_value) -> int:
"""
Safely convert a 'created' field value to an integer.
@ -546,6 +560,8 @@ def convert_to_model_response_object( # noqa: PLR0915
if tool_calls is not None:
_openai_tool_calls = []
for _tc in tool_calls:
if isinstance(_tc, dict):
_tc = _map_tool_call_dict_openai_names_to_user(_tc)
_openai_tc = ChatCompletionMessageToolCall(**_tc)
_openai_tool_calls.append(_openai_tc)
fixed_tool_calls = _handle_invalid_parallel_tool_calls(

View file

@ -0,0 +1,36 @@
"""
Mapping from OpenAI-safe tool names back to client-supplied names.
OpenAI requires tools[].function.name to match ^[a-zA-Z0-9_-]+$. LiteLLM sanitizes
outbound requests and stores sanitized -> original in an in-memory cache (same pattern
as litellm.bedrock_tool_name_mappings / make_valid_bedrock_tool_name).
"""
from __future__ import annotations
from typing import Optional
from litellm.caching.in_memory_cache import InMemoryCache
# Mirrors bedrock_tool_name_mappings in llms/bedrock/chat/invoke_handler.py
openai_tool_name_mappings: InMemoryCache = InMemoryCache(
max_size_in_memory=50, default_ttl=600
)
def get_openai_tool_name(response_tool_name: str) -> str:
"""
If LiteLLM sanitized the outbound tool name, map the API response name back to the original.
Same idea as get_bedrock_tool_name for Bedrock toolSpec names.
"""
if response_tool_name in openai_tool_name_mappings.cache_dict:
response_tool_name = openai_tool_name_mappings.cache_dict[response_tool_name]
return response_tool_name
def restore_openai_tool_name_for_user(sanitized_name: Optional[str]) -> Optional[str]:
"""Nullable wrapper used when normalizing tool_calls from responses."""
if sanitized_name is None:
return None
return get_openai_tool_name(sanitized_name)

View file

@ -6,6 +6,9 @@ from litellm.types.llms.openai import (
ChatCompletionAssistantContentValue,
ChatCompletionAudioDelta,
)
from litellm.litellm_core_utils.openai_tool_name_mapping import (
restore_openai_tool_name_for_user,
)
from litellm.types.utils import (
ChatCompletionAudioResponse,
ChatCompletionMessageToolCall,
@ -293,10 +296,12 @@ class ChunkProcessor:
if tool_call_data["id"] and tool_call_data["name"]:
combined_arguments = "".join(tool_call_data["arguments"]) or "{}"
display_name = restore_openai_tool_name_for_user(tool_call_data["name"])
# Build function - provider_specific_fields should be on tool_call level, not function level
function = Function(
arguments=combined_arguments,
name=tool_call_data["name"],
name=display_name,
)
# Prepare params for ChatCompletionMessageToolCall

View file

@ -7874,18 +7874,82 @@ def validate_and_fix_openai_messages(messages: List):
return validate_chat_completion_user_messages(messages=new_messages)
_OPENAI_FUNCTION_TOOL_NAME_MAX_LENGTH = 64
def _sanitize_openai_function_tool_name(name: str, index: int) -> str:
"""
Normalize function.name to match OpenAI's pattern ^[a-zA-Z0-9_-]+$ and length cap.
See: OpenAI Chat Completions tools[].function.name validation.
"""
if name is None or (isinstance(name, str) and not str(name).strip()):
return f"litellm_unnamed_tool_{index}"
cleaned = re.sub(r"[^a-zA-Z0-9_-]", "_", str(name).strip())
if not cleaned:
return f"litellm_unnamed_tool_{index}"
return cleaned[:_OPENAI_FUNCTION_TOOL_NAME_MAX_LENGTH]
def _make_unique_openai_tool_name(base: str, used_names: set[str]) -> str:
"""Disambiguate sanitized names when multiple tools map to the same string."""
candidate = base
n = 0
while candidate in used_names:
n += 1
suffix = f"_{n}"
room = _OPENAI_FUNCTION_TOOL_NAME_MAX_LENGTH - len(suffix)
if room < 1:
candidate = suffix[:_OPENAI_FUNCTION_TOOL_NAME_MAX_LENGTH]
else:
candidate = (base[:room] + suffix)[:_OPENAI_FUNCTION_TOOL_NAME_MAX_LENGTH]
used_names.add(candidate)
return candidate
def _maybe_fix_openai_function_tool_name(
tool_dict: dict, index: int, used_names: set[str]
) -> None:
from litellm.litellm_core_utils.openai_tool_name_mapping import (
openai_tool_name_mappings,
)
fn = tool_dict.get("function")
if not isinstance(fn, dict):
return
tool_type = tool_dict.get("type")
if tool_type is not None and tool_type != "function":
return
raw = fn.get("name")
raw_original = str(raw) if raw is not None else ""
base = _sanitize_openai_function_tool_name(
str(raw) if raw is not None else "", index
)
unique = _make_unique_openai_tool_name(base, used_names)
fn["name"] = unique
if unique != raw_original:
openai_tool_name_mappings.set_cache(key=unique, value=raw_original)
def validate_and_fix_openai_tools(tools: Optional[List]) -> Optional[List[dict]]:
"""
Ensure tools is List[dict] and not List[BaseModel]
Ensure tools is List[dict] and not List[BaseModel].
Sanitizes OpenAI function tool names to match ^[a-zA-Z0-9_-]+$ (max 64 chars),
disambiguates collisions, and does not mutate caller-provided dicts.
"""
new_tools = []
if tools is None:
return tools
for tool in tools:
new_tools: List[dict] = []
used_names: set[str] = set()
for idx, tool in enumerate(tools):
if isinstance(tool, BaseModel):
new_tools.append(tool.model_dump())
tool_dict = tool.model_dump()
elif isinstance(tool, dict):
new_tools.append(tool)
tool_dict = copy.deepcopy(tool)
else:
continue
_maybe_fix_openai_function_tool_name(tool_dict, idx, used_names)
new_tools.append(tool_dict)
return new_tools

View file

@ -2904,9 +2904,9 @@ def test_gemini_embedding_2_ga_in_cost_map():
assert info.get("input_cost_per_audio_per_second") == 0.00016
assert info.get("input_cost_per_video_per_second") == 0.00079
if provider in ("vertex_ai-embedding-models", "vertex_ai"):
assert info.get("uses_embed_content") is True, (
f"{key} must have uses_embed_content=true for correct Vertex AI routing"
)
assert (
info.get("uses_embed_content") is True
), f"{key} must have uses_embed_content=true for correct Vertex AI routing"
def test_gemini_lyria_3_preview_models_in_cost_map():
@ -3983,3 +3983,123 @@ class TestValidateAndFixThinkingParam:
validate_and_fix_thinking_param(thinking=thinking)
assert "budgetTokens" in thinking
assert "budget_tokens" not in thinking
def test_validate_and_fix_openai_tools_sanitizes_invalid_names():
from litellm.utils import validate_and_fix_openai_tools
tools_in = [
{
"type": "function",
"function": {
"name": "invalid.name",
"description": "x",
"parameters": {"type": "object", "properties": {}},
},
}
]
original_name = tools_in[0]["function"]["name"]
out = validate_and_fix_openai_tools(tools=tools_in)
assert out is not None
assert out[0]["function"]["name"] == "invalid_name"
assert original_name == "invalid.name"
def test_validate_and_fix_openai_tools_dedupes_colliding_names():
from litellm.utils import validate_and_fix_openai_tools
tools_in = [
{
"type": "function",
"function": {
"name": "a.b",
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "a/b",
"parameters": {"type": "object", "properties": {}},
},
},
]
out = validate_and_fix_openai_tools(tools=tools_in)
assert out[0]["function"]["name"] == "a_b"
assert out[1]["function"]["name"] == "a_b_1"
def test_openai_tool_name_mapping_restore_roundtrip():
from litellm.litellm_core_utils.openai_tool_name_mapping import (
restore_openai_tool_name_for_user,
)
from litellm.utils import validate_and_fix_openai_tools
tools_in = [
{
"type": "function",
"function": {
"name": "invalid.name.at.index.71",
"parameters": {"type": "object", "properties": {}},
},
}
]
out = validate_and_fix_openai_tools(tools=tools_in)
assert out[0]["function"]["name"] == "invalid_name_at_index_71"
assert (
restore_openai_tool_name_for_user("invalid_name_at_index_71")
== "invalid.name.at.index.71"
)
def test_convert_to_model_response_restores_openai_tool_call_names():
from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import (
convert_to_model_response_object,
)
from litellm.types.utils import ModelResponse
from litellm.utils import validate_and_fix_openai_tools
validate_and_fix_openai_tools(
tools=[
{
"type": "function",
"function": {
"name": "plugin.subtool",
"parameters": {"type": "object", "properties": {}},
},
}
]
)
response_object = {
"id": "test",
"object": "chat.completion",
"created": 0,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "plugin_subtool",
"arguments": "{}",
},
}
],
},
"finish_reason": "tool_calls",
}
],
}
out = convert_to_model_response_object(
response_object=response_object,
model_response_object=ModelResponse(),
response_type="completion",
)
assert out.choices[0].message.tool_calls is not None
assert out.choices[0].message.tool_calls[0].function.name == "plugin.subtool"