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:
Homen Shum 2026-11-04 02:20:44 -08:00
parent ba9f3a8170
commit c6ab757c24
2 changed files with 115 additions and 18 deletions

View file

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

View file

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