From 434c04e7bc02e11800be4a5b41ea56d53986de44 Mon Sep 17 00:00:00 2001 From: yucheng Date: Sat, 8 Aug 2026 03:26:21 +0000 Subject: [PATCH 1/2] fix(responses-bridge): expand namespace tools instead of dropping them Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../namespace_tools.py | 27 +++++++ .../transformation.py | 5 +- .../test_litellm_completion_responses.py | 79 +++++++++++++++++++ 3 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 litellm/responses/litellm_completion_transformation/namespace_tools.py diff --git a/litellm/responses/litellm_completion_transformation/namespace_tools.py b/litellm/responses/litellm_completion_transformation/namespace_tools.py new file mode 100644 index 00000000000..2624d4238e9 --- /dev/null +++ b/litellm/responses/litellm_completion_transformation/namespace_tools.py @@ -0,0 +1,27 @@ +""" +Utilities for handling OpenAI Responses API 'namespace' tools when bridging to Chat +Completions providers. + +A namespace tool is a grouping container: it carries no callable schema of its own and +holds its callable tools under ``tools``. Chat Completions has no equivalent container, +so the bridge replaces each namespace with the tools it contains, which then go through +the same conversion as any top level tool. +""" + +from collections.abc import Sequence +from typing import TypeAlias + +from openai.types.responses.tool_param import FunctionToolParam + +from litellm.types.llms.openai import OpenAIMcpServerTool + +ResponsesAPITool: TypeAlias = FunctionToolParam | OpenAIMcpServerTool + + +def flatten_namespace_tools(tools: Sequence[ResponsesAPITool]) -> tuple[ResponsesAPITool, ...]: + """Replace every namespace tool with its nested tools, recursively.""" + return tuple( + nested + for tool in tools + for nested in (flatten_namespace_tools(tool.get("tools") or ()) if tool.get("type") == "namespace" else (tool,)) + ) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index f54023836e5..3fb63029968 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -77,6 +77,7 @@ from .custom_tools import ( is_custom_tool_call, unwrap_custom_tool_arguments, ) +from .namespace_tools import flatten_namespace_tools ########### Initialize Classes used for Responses API ########### TOOL_CALLS_CACHE: Final = InMemoryCache() @@ -1278,7 +1279,7 @@ class LiteLLMCompletionResponsesConfig: return [], None chat_completion_tools: Final[list[ChatCompletionToolParam | OpenAIMcpServerTool]] = [] web_search_options: OpenAIWebSearchOptions | None = None - for tool in tools: + for tool in flatten_namespace_tools(tools): if tool.get("type") == "mcp": chat_completion_tools.append(cast(OpenAIMcpServerTool, tool)) elif tool.get("type") == "web_search_preview" or tool.get("type") == "web_search": @@ -1323,7 +1324,7 @@ class LiteLLMCompletionResponsesConfig: chat_completion_tools.append(converted) else: _tool_type = tool.get("type") - if _tool_type in ("computer_use", "image_generation", "namespace", "shell"): + if _tool_type in ("computer_use", "image_generation", "shell"): # Drop unsupported Responses-API-only tool types that have no # Chat Completions equivalent. Passing them through verbatim # causes providers to reject the request with "'function' is a diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 51f757c9eaf..ca743a655d3 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -1554,6 +1554,85 @@ class TestToolTransformation: == "string" ) + def test_transform_namespace_tool_expands_nested_function_tools(self): + """ + Regression for issue 2 of #35878: a namespace tool is a grouping container whose + nested tools are ordinary function tools. Dropping the container dropped every + nested tool with it, so a bridged client lost the sub-agent tools it declared. + """ + namespace_tool = { + "type": "namespace", + "name": "collaboration", + "description": "sub-agent management", + "tools": [ + { + "type": "function", + "name": "spawn_agent", + "description": "start a sub-agent", + "parameters": { + "type": "object", + "properties": {"task": {"type": "string"}}, + "required": ["task"], + }, + }, + { + "type": "function", + "name": "close_agent", + "description": "stop a sub-agent", + }, + ], + } + + ( + result_tools, + web_search_options, + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=[namespace_tool] + ) + + assert [tool["type"] for tool in result_tools] == ["function", "function"] + assert [tool["function"]["name"] for tool in result_tools] == [ + "spawn_agent", + "close_agent", + ] + assert result_tools[0]["function"]["parameters"]["properties"] == { + "task": {"type": "string"} + } + assert result_tools[1]["function"]["parameters"] == {"type": "object"} + assert not any(tool.get("type") == "namespace" for tool in result_tools) + assert web_search_options is None + + def test_transform_namespace_tool_alongside_top_level_tools(self): + """Nested tools land in the flat list next to top-level tools, and unsupported + built-ins inside the namespace are still dropped rather than passed through.""" + tools = [ + { + "type": "function", + "name": "shell_command", + "parameters": {"type": "object", "properties": {}}, + }, + { + "type": "namespace", + "name": "codex_app", + "tools": [ + {"type": "function", "name": "read_file"}, + {"type": "image_generation", "output_format": "png"}, + ], + }, + ] + + ( + result_tools, + _, + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools + ) + + assert [tool["function"]["name"] for tool in result_tools] == [ + "shell_command", + "read_file", + ] + def test_bedrock_anthropic_drops_derived_web_search_options(self): """ Regression for LIT-3858: a Responses web_search tool becomes a derived From 82df8a948938084926d42d3e333cbdbfc3c5853d Mon Sep 17 00:00:00 2001 From: yucheng Date: Sat, 8 Aug 2026 03:37:38 +0000 Subject: [PATCH 2/2] fix(responses-bridge): flatten namespace tools without recursion Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../namespace_tools.py | 10 +++---- .../transformation.py | 2 +- .../test_litellm_completion_responses.py | 28 +++++++++++++++++++ 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/litellm/responses/litellm_completion_transformation/namespace_tools.py b/litellm/responses/litellm_completion_transformation/namespace_tools.py index 2624d4238e9..5e1ad9fced8 100644 --- a/litellm/responses/litellm_completion_transformation/namespace_tools.py +++ b/litellm/responses/litellm_completion_transformation/namespace_tools.py @@ -5,7 +5,9 @@ Completions providers. A namespace tool is a grouping container: it carries no callable schema of its own and holds its callable tools under ``tools``. Chat Completions has no equivalent container, so the bridge replaces each namespace with the tools it contains, which then go through -the same conversion as any top level tool. +the same conversion as any top level tool. Namespaces are flat in practice, and a +namespace that somehow contains another one keeps the inner container, which the +conversion then drops as an unsupported type. """ from collections.abc import Sequence @@ -19,9 +21,7 @@ ResponsesAPITool: TypeAlias = FunctionToolParam | OpenAIMcpServerTool def flatten_namespace_tools(tools: Sequence[ResponsesAPITool]) -> tuple[ResponsesAPITool, ...]: - """Replace every namespace tool with its nested tools, recursively.""" + """Replace every namespace tool with the tools it contains.""" return tuple( - nested - for tool in tools - for nested in (flatten_namespace_tools(tool.get("tools") or ()) if tool.get("type") == "namespace" else (tool,)) + nested for tool in tools for nested in (tool.get("tools") or () if tool.get("type") == "namespace" else (tool,)) ) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 3fb63029968..f54cb4322ce 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -1324,7 +1324,7 @@ class LiteLLMCompletionResponsesConfig: chat_completion_tools.append(converted) else: _tool_type = tool.get("type") - if _tool_type in ("computer_use", "image_generation", "shell"): + if _tool_type in ("computer_use", "image_generation", "namespace", "shell"): # Drop unsupported Responses-API-only tool types that have no # Chat Completions equivalent. Passing them through verbatim # causes providers to reject the request with "'function' is a diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index ca743a655d3..85118c76f91 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -1633,6 +1633,34 @@ class TestToolTransformation: "read_file", ] + def test_transform_nested_namespace_container_is_dropped(self): + """A namespace inside a namespace is not something clients send, but its inner + container must never reach the provider as a tool without a function schema.""" + tools = [ + { + "type": "namespace", + "name": "outer", + "tools": [ + {"type": "function", "name": "outer_tool"}, + { + "type": "namespace", + "name": "inner", + "tools": [{"type": "function", "name": "inner_tool"}], + }, + ], + } + ] + + ( + result_tools, + _, + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools + ) + + assert [tool["function"]["name"] for tool in result_tools] == ["outer_tool"] + assert not any(tool.get("type") == "namespace" for tool in result_tools) + def test_bedrock_anthropic_drops_derived_web_search_options(self): """ Regression for LIT-3858: a Responses web_search tool becomes a derived