mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
feat(utils): sanitize OpenAI tool names and restore originals via cache
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
c011a7e3ba
commit
d99d7dd9ec
6 changed files with 264 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
36
litellm/litellm_core_utils/openai_tool_name_mapping.py
Normal file
36
litellm/litellm_core_utils/openai_tool_name_mapping.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue