fix(bedrock_mantle): unwrap Codex's explicit functions tool namespace

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Devin AI 2026-08-07 12:07:31 +00:00
parent b66d4e6965
commit 7e1fa89b07
2 changed files with 132 additions and 3 deletions

View file

@ -15,7 +15,7 @@ role / access key / profile / web identity), signed via the shared
BaseAWSLLM._sign_request after the request body is finalized.
"""
from typing import Any, Final
from typing import Any, Final, cast
import litellm
from litellm._logging import verbose_logger
@ -48,6 +48,8 @@ _BEDROCK_MANTLE_SUPPORTED_RESPONSE_TOOL_TYPES = frozenset({"function", "mcp", "c
_BEDROCK_MANTLE_SUPPORTED_SERVICE_TIERS: Final = frozenset({"auto", "default"})
_BEDROCK_MANTLE_RESERVED_TOOL_NAMESPACE: Final = "functions"
_CODEX_ADDITIONAL_TOOLS_INPUT_ITEM_TYPE: Final = "additional_tools"
@ -100,6 +102,41 @@ class BedrockMantleResponsesAPIConfig(BedrockMantleAuthMixin, OpenAIResponsesAPI
def supports_native_websocket(self) -> bool:
return False
@staticmethod
def _reserved_namespace_nested_tools(tool: object) -> "tuple[object, ...] | None":
if not isinstance(tool, dict):
return None
entry: Final = cast("dict[str, object]", tool)
if entry.get("type") != "namespace" or entry.get("name") != _BEDROCK_MANTLE_RESERVED_TOOL_NAMESPACE:
return None
nested: Final = entry.get("tools")
return tuple(cast("list[object]", nested)) if isinstance(nested, list) else ()
@classmethod
def _flatten_reserved_namespace_tools(cls, tools: "list[object]") -> "list[object]":
"""Codex CLI >= 0.147.0 groups its plain function/custom tools into an
explicit {"type": "namespace", "name": "functions", "tools": [...]} entry.
Mantle serves top-level function tools under that same namespace already,
so it rejects the request with "Invalid Value: 'tools.namespace'.
User-defined namespace 'functions' collides with an existing tool
namespace." Unwrapping the entry back to top-level tools is identity
preserving on both ends: Mantle addresses those tools by bare name, and
Codex normalizes a missing namespace to "functions" when it routes the
resulting tool calls.
"""
unwrapped: Final = tuple((tool, cls._reserved_namespace_nested_tools(tool)) for tool in tools)
if all(nested is None for _, nested in unwrapped):
return tools
verbose_logger.debug(
"Bedrock Mantle Responses API: unwrapping the reserved %r tool namespace into top-level tools.",
_BEDROCK_MANTLE_RESERVED_TOOL_NAMESPACE,
)
return [tool for original, nested in unwrapped for tool in ((original,) if nested is None else nested)]
@classmethod
def _normalize_tools(cls, tools: "list[object]") -> "list[object]":
return cls._filter_unsupported_tools(cls._flatten_reserved_namespace_tools(tools))
@staticmethod
def _filter_unsupported_tools(tools: list[Any]) -> list[Any]:
"""Keep only tool types Mantle's Responses API accepts."""
@ -208,7 +245,7 @@ class BedrockMantleResponsesAPIConfig(BedrockMantleAuthMixin, OpenAIResponsesAPI
len(hoisted_tools),
len(additional_tools_items),
)
return remaining_input, cls._filter_unsupported_tools(hoisted_tools)
return remaining_input, cls._normalize_tools(hoisted_tools)
def map_openai_params(
self,
@ -230,7 +267,7 @@ class BedrockMantleResponsesAPIConfig(BedrockMantleAuthMixin, OpenAIResponsesAPI
return params
tools_list: Final = tools if isinstance(tools, list) else [tools]
filtered: Final = self._filter_unsupported_tools(tools_list)
filtered: Final = self._normalize_tools(tools_list)
if filtered:
params["tools"] = filtered
else:

View file

@ -632,6 +632,98 @@ class TestBedrockMantleCodexAdditionalTools:
assert "additional_tools" in str(mock_debug.call_args)
class TestBedrockMantleCodexFunctionsNamespace:
"""Codex CLI 0.147.0 canonicalizes its default function/custom tools under an
explicit "functions" namespace (openai/codex#37022). Mantle already serves
top-level function tools under that namespace, so it 400s the request with
"Invalid Value: 'tools.namespace'. User-defined namespace 'functions'
collides with an existing tool namespace." (issue #36182), and the config has
to unwrap it back to top-level tools."""
_USER_MESSAGE = {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Say hi in one word."}],
}
def _functions_namespace(self):
return {
"type": "namespace",
"name": "functions",
"tools": [_codex_exec_tool(), _codex_wait_tool()],
}
def _transform(self, input, params=None):
cfg = BedrockMantleResponsesAPIConfig()
return cfg.transform_responses_api_request(
model="openai.gpt-5.6-luna",
input=input,
response_api_optional_request_params=params if params is not None else {},
litellm_params=GenericLiteLLMParams(),
headers={},
)
def test_top_level_functions_namespace_is_unwrapped(self):
params = BedrockMantleResponsesAPIConfig().map_openai_params(
response_api_optional_params={"tools": [self._functions_namespace()]},
model="openai.gpt-5.6-luna",
drop_params=False,
)
assert params["tools"] == [_codex_exec_tool(), _codex_wait_tool()]
def test_other_namespaces_are_preserved(self):
collaboration = {"type": "namespace", "name": "collaboration", "tools": [{"type": "function", "name": "spawn"}]}
params = BedrockMantleResponsesAPIConfig().map_openai_params(
response_api_optional_params={"tools": [self._functions_namespace(), collaboration]},
model="openai.gpt-5.6-luna",
drop_params=False,
)
assert params["tools"] == [_codex_exec_tool(), _codex_wait_tool(), collaboration]
def test_unsupported_tool_types_inside_namespace_are_dropped(self):
params = BedrockMantleResponsesAPIConfig().map_openai_params(
response_api_optional_params={
"tools": [
{
"type": "namespace",
"name": "functions",
"tools": [{"type": "web_search"}, _codex_wait_tool()],
}
]
},
model="openai.gpt-5.6-luna",
drop_params=False,
)
assert params["tools"] == [_codex_wait_tool()]
def test_empty_functions_namespace_removes_tools(self):
params = BedrockMantleResponsesAPIConfig().map_openai_params(
response_api_optional_params={"tools": [{"type": "namespace", "name": "functions", "tools": []}]},
model="openai.gpt-5.6-luna",
drop_params=False,
)
assert "tools" not in params
def test_functions_namespace_hoisted_out_of_additional_tools_is_unwrapped(self):
body = self._transform(
input=[
{"type": "additional_tools", "role": "developer", "tools": [self._functions_namespace()]},
self._USER_MESSAGE,
]
)
assert body["input"] == [self._USER_MESSAGE]
assert body["tools"] == [_codex_exec_tool(), _codex_wait_tool()]
def test_request_without_functions_namespace_is_untouched(self):
tools = [_codex_wait_tool(), {"type": "namespace", "name": "collaboration", "tools": []}]
params = BedrockMantleResponsesAPIConfig().map_openai_params(
response_api_optional_params={"tools": list(tools)},
model="openai.gpt-5.6-luna",
drop_params=False,
)
assert params["tools"] == tools
class TestBedrockMantleResponsesRegistry:
def test_registry_returns_config_for_gpt_5_5(self, local_cost_map):
# gpt-5.x advertises /v1/responses in supported_endpoints (capability)