mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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:
parent
833b62ca71
commit
25a5429a35
3 changed files with 73 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue