diff --git a/litellm/litellm_core_utils/openai_tool_name_mapping.py b/litellm/litellm_core_utils/openai_tool_name_mapping.py index 2994db371ac..dfce305ce87 100644 --- a/litellm/litellm_core_utils/openai_tool_name_mapping.py +++ b/litellm/litellm_core_utils/openai_tool_name_mapping.py @@ -1,13 +1,17 @@ """ -Per-request mapping from OpenAI-safe tool names back to client-supplied names. +Per-request mapping between client-supplied tool names and their OpenAI-safe forms. OpenAI's Chat Completions API requires tools[].function.name to match ``^[a-zA-Z0-9_-]+$``. For providers that enforce this, we rewrite outbound tool -names and keep sanitized -> original in a ContextVar dict for this completion only -(never a process-wide cache). +names and keep two ContextVar dicts for this completion only (never a process-wide +cache): -Restore in ``convert_dict_to_response`` only affects names present in the current -request's mapping, so other providers and concurrent requests are unaffected. + _CTX – sanitized → original (used on the response path) + _CTX_REV – original → sanitized (used to rewrite tool_choice before the request) + +Using the pre-built reverse map for tool_choice guarantees that collision suffixes +(e.g. "a_b_1") added by _make_unique_openai_tool_name are respected instead of +being recomputed independently. """ from __future__ import annotations @@ -18,6 +22,9 @@ from typing import Dict, Optional _CTX: contextvars.ContextVar[Optional[Dict[str, str]]] = contextvars.ContextVar( "litellm_openai_tool_name_mapping", default=None ) +_CTX_REV: contextvars.ContextVar[Optional[Dict[str, str]]] = contextvars.ContextVar( + "litellm_openai_tool_name_mapping_rev", default=None +) # Providers where the upstream Chat Completions API applies OpenAI tool-name validation. _OPENAI_TOOL_NAME_VALIDATION_PROVIDERS = frozenset( @@ -44,8 +51,9 @@ def should_sanitize_openai_tool_names(litellm_provider: str) -> bool: def begin_openai_tool_name_mapping_scope() -> None: - """Reset mapping for this completion (call once at the start of completion()).""" + """Reset both mappings for this completion (call once at the start of completion()).""" _CTX.set({}) + _CTX_REV.set({}) def _store(sanitized: str, original: str) -> None: @@ -56,6 +64,11 @@ def _store(sanitized: str, original: str) -> None: m = {} _CTX.set(m) m[sanitized] = original + r = _CTX_REV.get() + if r is None: + r = {} + _CTX_REV.set(r) + r[original] = sanitized def get_openai_tool_name(response_tool_name: str) -> str: @@ -74,3 +87,16 @@ def restore_openai_tool_name_for_user(sanitized_name: Optional[str]) -> Optional def store_openai_tool_name_mapping(sanitized: str, original: str) -> None: """Record a rewrite when ``validate_and_fix_openai_tools`` sanitizes a name.""" _store(sanitized, original) + + +def get_sanitized_tool_name(original_name: str) -> str: + """Return the sanitized (outbound) name for *original_name* if it was rewritten. + + Used to rewrite ``tool_choice.function.name`` after ``validate_and_fix_openai_tools`` + has run, so that collision suffixes added by ``_make_unique_openai_tool_name`` are + respected rather than recomputed independently. + """ + r = _CTX_REV.get() + if r is not None and original_name in r: + return r[original_name] + return original_name diff --git a/litellm/main.py b/litellm/main.py index d47b625f6b3..5d881bdfc8b 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -1189,9 +1189,11 @@ def completion( # type: ignore # noqa: PLR0915 ): _fn = tool_choice.get("function") if isinstance(_fn, dict) and isinstance(_fn.get("name"), str): - from litellm.utils import _sanitize_openai_function_tool_name as _san_name + from litellm.litellm_core_utils.openai_tool_name_mapping import ( + get_sanitized_tool_name as _get_sanitized, + ) - _sanitized_tc_name = _san_name(_fn["name"], -1) + _sanitized_tc_name = _get_sanitized(_fn["name"]) if _sanitized_tc_name != _fn["name"]: tool_choice = { **tool_choice, diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index bfbc216e2da..601650c3836 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -4117,8 +4117,9 @@ def test_convert_to_model_response_restores_openai_tool_call_names_when_mapped() def test_tool_choice_function_name_sanitized_with_tools(): from litellm.litellm_core_utils.openai_tool_name_mapping import ( begin_openai_tool_name_mapping_scope, + get_sanitized_tool_name, ) - from litellm.utils import _sanitize_openai_function_tool_name, validate_and_fix_openai_tools + from litellm.utils import validate_and_fix_openai_tools begin_openai_tool_name_mapping_scope() validate_and_fix_openai_tools( @@ -4133,11 +4134,44 @@ def test_tool_choice_function_name_sanitized_with_tools(): ], sanitize_openai_function_tool_names=True, ) - # Simulate what main.py does for tool_choice + # main.py uses get_sanitized_tool_name (reverse map) — not a re-computation tool_choice = {"type": "function", "function": {"name": "my.tool"}} _fn = tool_choice["function"] - sanitized = _sanitize_openai_function_tool_name(_fn["name"], -1) + sanitized = get_sanitized_tool_name(_fn["name"]) if sanitized != _fn["name"]: tool_choice = {**tool_choice, "function": {**_fn, "name": sanitized}} assert tool_choice["function"]["name"] == "my_tool" + + +def test_tool_choice_function_name_uses_collision_suffix(): + """tool_choice pointing at the second of two colliding names must use the '_1' suffix.""" + from litellm.litellm_core_utils.openai_tool_name_mapping import ( + begin_openai_tool_name_mapping_scope, + get_sanitized_tool_name, + ) + from litellm.utils import validate_and_fix_openai_tools + + begin_openai_tool_name_mapping_scope() + validate_and_fix_openai_tools( + tools=[ + { + "type": "function", + "function": { + "name": "a.b", + "parameters": {"type": "object", "properties": {}}, + }, + }, + { + "type": "function", + "function": { + "name": "a/b", + "parameters": {"type": "object", "properties": {}}, + }, + }, + ], + sanitize_openai_function_tool_names=True, + ) + # "a.b" → "a_b"; "a/b" also sanitizes to "a_b" but gets suffix → "a_b_1" + assert get_sanitized_tool_name("a.b") == "a_b" + assert get_sanitized_tool_name("a/b") == "a_b_1"