mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
fix(responses): keep the qualified name for a namespace member that shares its namespace's name
A namespace "search" with a member "search" is advertised as the chat tool "search__search" and comes back as name == namespace == "search". The echo rule from the previous commit skipped the join for every namespace == name, so that replay reached the provider as "search", a tool it was never given. The bare-name echo now applies only to the request's deferred top-level tools (deferred_top_level_names), and the reply map uses the same set. A deferred top-level name that a self-named namespace member also uses is left out of both directions, so the namespace member keeps its round trip. The lookup reads defer_loading is True instead of validating it, so callers that only read the input (token counting under drop_params) do not raise on a malformed flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ba9f3a8170
commit
c6ab757c24
2 changed files with 115 additions and 18 deletions
|
|
@ -1401,6 +1401,9 @@ class LiteLLMCompletionResponsesConfig:
|
|||
LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message,
|
||||
replay_reasoning=replay_reasoning,
|
||||
native_compaction=native_compaction,
|
||||
deferred_top_level_names=LiteLLMCompletionResponsesConfig.deferred_top_level_names(
|
||||
responses_api_request.get("tools")
|
||||
),
|
||||
)
|
||||
try:
|
||||
converted: Final = convert_input(sent_input)
|
||||
|
|
@ -1578,6 +1581,7 @@ class LiteLLMCompletionResponsesConfig:
|
|||
input: str | ResponseInputParam,
|
||||
replay_reasoning: bool = False,
|
||||
native_compaction: bool = False,
|
||||
deferred_top_level_names: frozenset[str] = frozenset(),
|
||||
) -> list[
|
||||
AllMessageValues | GenericChatCompletionMessage | ChatCompletionMessageToolCall | ChatCompletionResponseMessage
|
||||
]:
|
||||
|
|
@ -1604,6 +1608,7 @@ class LiteLLMCompletionResponsesConfig:
|
|||
input_item=_input,
|
||||
replay_reasoning=replay_reasoning,
|
||||
native_compaction=native_compaction,
|
||||
deferred_top_level_names=deferred_top_level_names,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -2304,6 +2309,7 @@ class LiteLLMCompletionResponsesConfig:
|
|||
input_item: Mapping[str, object],
|
||||
replay_reasoning: bool = False,
|
||||
native_compaction: bool = False,
|
||||
deferred_top_level_names: frozenset[str] = frozenset(),
|
||||
) -> list[AllMessageValues | GenericChatCompletionMessage | ChatCompletionResponseMessage]:
|
||||
"""
|
||||
Transform a Responses API input item into a Chat Completion message
|
||||
|
|
@ -2352,7 +2358,8 @@ class LiteLLMCompletionResponsesConfig:
|
|||
return LiteLLMCompletionResponsesConfig._transform_responses_api_function_call_to_chat_completion_message(
|
||||
function_call=cast( # cast-ok: callee coerces every field it reads with `or ""` / str()
|
||||
Mapping[str, str], input_item
|
||||
)
|
||||
),
|
||||
deferred_top_level_names=deferred_top_level_names,
|
||||
)
|
||||
elif input_item.get("type") == "compaction":
|
||||
return list(
|
||||
|
|
@ -2616,6 +2623,7 @@ class LiteLLMCompletionResponsesConfig:
|
|||
@staticmethod
|
||||
def _transform_responses_api_function_call_to_chat_completion_message(
|
||||
function_call: Mapping[str, str],
|
||||
deferred_top_level_names: frozenset[str] = frozenset(),
|
||||
) -> list[AllMessageValues | GenericChatCompletionMessage | ChatCompletionResponseMessage]:
|
||||
"""
|
||||
Transform a Responses API function_call into a Chat Completion message with tool calls
|
||||
|
|
@ -2645,9 +2653,11 @@ class LiteLLMCompletionResponsesConfig:
|
|||
raw_arguments = json.dumps({"content": raw_input}) if raw_input else ""
|
||||
raw_name: Final = function_call.get("name") or ""
|
||||
namespace: Final = function_call.get("namespace") or ""
|
||||
# namespace == name is the deferred top-level shape (see namespace_tool_name_map): the chat
|
||||
# tool is called by its bare name, so joining would send "<name>__<name>" upstream.
|
||||
qualify: Final = bool(namespace) and namespace != raw_name and function_call.get("type") != "custom_tool_call"
|
||||
# namespace == name on a deferred top-level tool of this request is the shape
|
||||
# namespace_tool_name_map gives it: the chat tool is called by its bare name, so joining
|
||||
# would send "<name>__<name>" upstream. A namespace whose member shares its name still joins.
|
||||
deferred_top_level: Final = namespace == raw_name and raw_name in deferred_top_level_names
|
||||
qualify: Final = bool(namespace) and not deferred_top_level and function_call.get("type") != "custom_tool_call"
|
||||
tool_call_id: Final = function_call.get("call_id") or function_call.get("id") or ""
|
||||
tool_call_function: Final = ChatCompletionToolCallFunctionChunk(
|
||||
name=f"{namespace}__{raw_name}" if qualify else raw_name,
|
||||
|
|
@ -3032,6 +3042,34 @@ class LiteLLMCompletionResponsesConfig:
|
|||
result.append(dict(tool))
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def deferred_top_level_names(tools: ResponseTools) -> frozenset[str]:
|
||||
"""Names of deferred top-level function tools that travel as namespace == name.
|
||||
|
||||
A name is left out when a namespace of the same name has a member of that name too: both
|
||||
would come back as namespace == name, so that call keeps the qualified form instead.
|
||||
Only ``defer_loading is True`` counts here: the request path rejects other values, and
|
||||
callers that only read the input (token counting under drop_params) must not raise.
|
||||
"""
|
||||
self_named_namespace_members: Final = frozenset(
|
||||
str(tool.get("name") or "")
|
||||
for tool in tools or ()
|
||||
if tool.get("type") == "namespace"
|
||||
for namespace_tools in (tool.get("tools"),)
|
||||
if isinstance(namespace_tools, Sequence) and not isinstance(namespace_tools, (str, bytes))
|
||||
for namespace_tool in namespace_tools
|
||||
if isinstance(namespace_tool, Mapping)
|
||||
and namespace_tool.get("type") == "function"
|
||||
and namespace_tool.get("name") == tool.get("name")
|
||||
)
|
||||
return frozenset(
|
||||
name
|
||||
for tool in tools or ()
|
||||
if tool.get("type") == "function" and tool.get("defer_loading") is True
|
||||
for name in (str(tool.get("name") or ""),)
|
||||
if name and name not in self_named_namespace_members
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def namespace_tool_name_map(tools: ResponseTools) -> NamespaceNameMap:
|
||||
namespace_entries: Final = tuple(
|
||||
|
|
@ -3064,11 +3102,7 @@ class LiteLLMCompletionResponsesConfig:
|
|||
# OpenAI Agents SDK reserves for it, `agents/_tool_identity.py`). Without it the SDK cannot
|
||||
# match the call to its tool and fails with "Tool <name> not found".
|
||||
deferred_top_level_entries: Final = tuple(
|
||||
(name, (name, name))
|
||||
for tool in tools or ()
|
||||
if tool.get("type") == "function" and validated_defer_loading(tool.get("defer_loading"))
|
||||
for name in (str(tool.get("name") or ""),)
|
||||
if name
|
||||
(name, (name, name)) for name in LiteLLMCompletionResponsesConfig.deferred_top_level_names(tools)
|
||||
)
|
||||
return MappingProxyType(dict(qualified_entries + unambiguous_entries + deferred_top_level_entries))
|
||||
|
||||
|
|
|
|||
|
|
@ -2936,17 +2936,80 @@ class TestToolTransformation:
|
|||
"""The Agents SDK echoes a deferred top-level call with namespace == name. The provider
|
||||
was given the bare tool name, so the outbound chat tool_call must not become
|
||||
"get_reporting_categories__get_reporting_categories"."""
|
||||
messages = LiteLLMCompletionResponsesConfig._transform_responses_api_function_call_to_chat_completion_message(
|
||||
function_call={
|
||||
"type": "function_call",
|
||||
"name": "get_reporting_categories",
|
||||
"namespace": "get_reporting_categories",
|
||||
"call_id": "call_3",
|
||||
"arguments": '{"market": "KR-MFDS"}',
|
||||
}
|
||||
messages = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
|
||||
input=[
|
||||
{
|
||||
"type": "function_call",
|
||||
"name": "get_reporting_categories",
|
||||
"namespace": "get_reporting_categories",
|
||||
"call_id": "call_3",
|
||||
"arguments": '{"market": "KR-MFDS"}',
|
||||
}
|
||||
],
|
||||
responses_api_request={
|
||||
"tools": [{"type": "function", "name": "get_reporting_categories", "defer_loading": True}]
|
||||
},
|
||||
)
|
||||
|
||||
assert messages[0]["tool_calls"][0]["function"]["name"] == "get_reporting_categories"
|
||||
assert [call["function"]["name"] for call in messages[0]["tool_calls"]] == ["get_reporting_categories"]
|
||||
|
||||
def test_function_call_echo_from_namespace_with_member_of_same_name_keeps_qualified_name(self):
|
||||
"""A namespace "search" with a member "search" is advertised as the chat tool
|
||||
"search__search" and comes back as name == namespace == "search". Its echo must
|
||||
still join, or the provider gets a call to a tool it was never given."""
|
||||
tools = [
|
||||
{
|
||||
"type": "namespace",
|
||||
"name": "search",
|
||||
"description": "Search tools",
|
||||
"tools": [{"type": "function", "name": "search", "defer_loading": True}],
|
||||
}
|
||||
]
|
||||
messages = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
|
||||
input=[
|
||||
{
|
||||
"type": "function_call",
|
||||
"name": "search",
|
||||
"namespace": "search",
|
||||
"call_id": "call_4",
|
||||
"arguments": "{}",
|
||||
}
|
||||
],
|
||||
responses_api_request={"tools": tools},
|
||||
)
|
||||
|
||||
assert [call["function"]["name"] for call in messages[0]["tool_calls"]] == ["search__search"]
|
||||
assert LiteLLMCompletionResponsesConfig.deferred_top_level_names(tools) == frozenset()
|
||||
|
||||
def test_deferred_top_level_name_shared_with_self_named_namespace_member_stays_qualified(self):
|
||||
"""When a deferred top-level "search" and a namespace "search" with a member "search"
|
||||
are both in the request, namespace == name cannot tell them apart. Neither direction
|
||||
uses that shape for the top-level tool, so the namespace member keeps its round trip."""
|
||||
tools = [
|
||||
{"type": "function", "name": "search", "defer_loading": True},
|
||||
{"type": "function", "name": "lookup", "defer_loading": True},
|
||||
{
|
||||
"type": "namespace",
|
||||
"name": "search",
|
||||
"description": "Search tools",
|
||||
"tools": [{"type": "function", "name": "search"}],
|
||||
},
|
||||
]
|
||||
|
||||
assert LiteLLMCompletionResponsesConfig.deferred_top_level_names(tools) == frozenset({"lookup"})
|
||||
assert dict(LiteLLMCompletionResponsesConfig.namespace_tool_name_map(tools)) == {
|
||||
"search__search": ("search", "search"),
|
||||
"lookup": ("lookup", "lookup"),
|
||||
}
|
||||
messages = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
|
||||
input=[
|
||||
{"type": "function_call", "name": "search", "namespace": "search", "call_id": "c5", "arguments": "{}"},
|
||||
{"type": "function_call", "name": "lookup", "namespace": "lookup", "call_id": "c6", "arguments": "{}"},
|
||||
],
|
||||
responses_api_request={"tools": tools},
|
||||
)
|
||||
|
||||
assert [call["function"]["name"] for call in messages[0]["tool_calls"]] == ["search__search", "lookup"]
|
||||
|
||||
def test_custom_tool_call_echo_keeps_short_name(self):
|
||||
"""Custom tools stay advertised under their short name, so a namespace on
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue