diff --git a/litellm/__init__.py b/litellm/__init__.py index 28112f5c12a..2874ff88b5d 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -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 diff --git a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py index 5fd42fe0d36..10cf5c58079 100644 --- a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py +++ b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py @@ -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( diff --git a/litellm/litellm_core_utils/openai_tool_name_mapping.py b/litellm/litellm_core_utils/openai_tool_name_mapping.py new file mode 100644 index 00000000000..029364f8acf --- /dev/null +++ b/litellm/litellm_core_utils/openai_tool_name_mapping.py @@ -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) diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index fe7c62c3842..54ae0327b0a 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -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 diff --git a/litellm/utils.py b/litellm/utils.py index 8c1b6452ced..77a6a6305f4 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -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 diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index f28fe3ed258..529dfc838d4 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -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"