fix: use reverse mapping for tool_choice sanitization to respect collision suffixes

_sanitize_openai_function_tool_name has no knowledge of the _1/_2 suffix
added by _make_unique_openai_tool_name for colliding names. Storing a
per-request original→sanitized reverse map (_CTX_REV) during
validate_and_fix_openai_tools and looking it up in main.py ensures
tool_choice.function.name uses the exact same final name (including any
collision suffix) that was written into the tools array.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sameer Kankute 2026-05-04 17:55:17 +05:30
parent 833b62ca71
commit 25a5429a35
No known key found for this signature in database
3 changed files with 73 additions and 11 deletions

View file

@ -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

View file

@ -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,

View file

@ -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"