From c6ab757c248ec160024ea439b5faa5cecde2d3a2 Mon Sep 17 00:00:00 2001 From: Homen Shum <317650355+homenshum-cheiron@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:20:44 -0800 Subject: [PATCH] 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 --- .../transformation.py | 52 +++++++++--- .../test_litellm_completion_responses.py | 81 ++++++++++++++++--- 2 files changed, 115 insertions(+), 18 deletions(-) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index e771de9b152..f45b655a9f7 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -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 "__" 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 "__" 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 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)) diff --git a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 442e4ac0907..d1b79349bc2 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -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