fix(responses-bridge): expand namespace tools instead of dropping them

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-08-08 03:26:21 +00:00
parent 0a606cb258
commit 434c04e7bc
3 changed files with 109 additions and 2 deletions

View file

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

View file

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

View file

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