Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion litellm/llms/bedrock_mantle/responses/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@
)
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams
from litellm.types.llms.openai import (
ResponseInputParam,
ResponsesAPIOptionalRequestParams,
)
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import LlmProviders

Expand All @@ -42,6 +45,8 @@
# Per Bedrock Mantle Responses API validation errors.
_BEDROCK_MANTLE_SUPPORTED_RESPONSE_TOOL_TYPES = frozenset({"function", "mcp", "custom", "namespace", "tool_search"})

_CODEX_ADDITIONAL_TOOLS_INPUT_ITEM_TYPE = "additional_tools"


class BedrockMantleResponsesAPIConfig(BedrockMantleAuthMixin, OpenAIResponsesAPIConfig):
def __init__(
Expand Down Expand Up @@ -116,6 +121,70 @@ def _filter_unsupported_tools(tools: List[Any]) -> List[Any]:

return kept

def transform_responses_api_request(
self,
model: str,
input: "str | ResponseInputParam",
response_api_optional_request_params: dict,
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> dict:
remaining_input, hoisted_tools = self._hoist_codex_additional_tools(input)
request_params = (
{
**response_api_optional_request_params,
"tools": [
*(response_api_optional_request_params.get("tools") or []),
*hoisted_tools,
],
}
if hoisted_tools
else response_api_optional_request_params
)
return super().transform_responses_api_request(
model=model,
input=remaining_input,
response_api_optional_request_params=request_params,
litellm_params=litellm_params,
headers=headers,
)

@staticmethod
def _is_codex_additional_tools_item(item: Any) -> bool:
return isinstance(item, dict) and item.get("type") == _CODEX_ADDITIONAL_TOOLS_INPUT_ITEM_TYPE

@staticmethod
def _tools_of_additional_tools_item(item: "dict[str, Any]") -> "list[Any]":
tools = item.get("tools")
return tools if isinstance(tools, list) else []

@classmethod
def _hoist_codex_additional_tools(
cls,
input: "str | ResponseInputParam",
) -> "tuple[str | ResponseInputParam, list[Any]]":
"""Codex's "responses lite" wire mode ships tool definitions inside
`input` as {"type": "additional_tools", "role": "developer",
"tools": [...]} items. api.openai.com accepts that item type; Mantle
rejects the whole request with 400 "Invalid 'input': value did not
match any expected variant" but accepts the same tools at the top
level, so move them there and strip the items from `input`.
"""
if not isinstance(input, list):
return input, []
additional_tools_items = [item for item in input if cls._is_codex_additional_tools_item(item)]
Comment thread
veria-ai[bot] marked this conversation as resolved.
if not additional_tools_items:
return input, []
remaining_input = [item for item in input if not cls._is_codex_additional_tools_item(item)]
hoisted_tools = [tool for item in additional_tools_items for tool in cls._tools_of_additional_tools_item(item)]
verbose_logger.debug(
"Bedrock Mantle Responses API: hoisting %d tool(s) out of %d 'additional_tools' input item(s) "
"into the top-level tools param (Mantle rejects that input item type).",
len(hoisted_tools),
len(additional_tools_items),
)
return remaining_input, cls._filter_unsupported_tools(hoisted_tools)

def map_openai_params(
self,
response_api_optional_params: ResponsesAPIOptionalRequestParams,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,144 @@ def test_dropped_tools_are_logged_at_warning_level(self):
assert "web_search" in str(mock_warning.call_args)


class TestBedrockMantleCodexAdditionalTools:
"""Codex CLI's "responses lite" wire mode ships tool definitions inside
`input` as {"type": "additional_tools", "role": "developer", "tools": [...]}
items instead of the top-level `tools` param. api.openai.com accepts that
item; Mantle 400s the whole request with "Invalid 'input': value did not
match any expected variant" but accepts the same tools at the top level
(verified against bedrock-mantle.us-east-2.api.aws with openai.gpt-5.6-sol),
so the config must hoist them."""

_USER_MESSAGE = {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Say hi in one word."}],
}
_DEVELOPER_MESSAGE = {
"type": "message",
"role": "developer",
"content": [{"type": "input_text", "text": "You are Codex."}],
}
_CODEX_TOOLS = [
{"type": "custom", "name": "exec", "format": {"type": "grammar", "syntax": "lark", "definition": "start: X"}},
{"type": "function", "name": "wait", "parameters": {"type": "object"}},
{"type": "namespace", "name": "collaboration", "tools": [{"type": "function", "name": "spawn_agent"}]},
]

def _transform(self, input, params=None):
cfg = BedrockMantleResponsesAPIConfig()
return cfg.transform_responses_api_request(
model="openai.gpt-5.6-sol",
input=input,
response_api_optional_request_params=params if params is not None else {},
litellm_params=GenericLiteLLMParams(),
headers={},
)

def test_additional_tools_item_hoisted_to_top_level_tools(self):
body = self._transform(
input=[
{"type": "additional_tools", "role": "developer", "tools": self._CODEX_TOOLS},
self._DEVELOPER_MESSAGE,
self._USER_MESSAGE,
]
)
assert body["input"] == [self._DEVELOPER_MESSAGE, self._USER_MESSAGE]
assert body["tools"] == self._CODEX_TOOLS

def test_hoisted_tools_append_after_existing_tools(self):
existing_tool = {"type": "function", "name": "preexisting"}
body = self._transform(
input=[
{"type": "additional_tools", "role": "developer", "tools": self._CODEX_TOOLS},
self._USER_MESSAGE,
],
params={"tools": [existing_tool]},
)
assert body["tools"] == [existing_tool, *self._CODEX_TOOLS]

def test_unsupported_hoisted_tool_types_are_dropped(self):
body = self._transform(
input=[
{
"type": "additional_tools",
"role": "developer",
"tools": [
{"type": "web_search"},
{"type": "function", "name": "wait"},
],
},
self._USER_MESSAGE,
]
)
assert body["tools"] == [{"type": "function", "name": "wait"}]

def test_item_stripped_even_when_no_hoisted_tool_survives(self):
body = self._transform(
input=[
{"type": "additional_tools", "role": "developer", "tools": [{"type": "web_search"}]},
self._USER_MESSAGE,
]
)
assert body["input"] == [self._USER_MESSAGE]
assert "tools" not in body

def test_multiple_additional_tools_items_merge_in_order(self):
first = {"type": "function", "name": "first"}
second = {"type": "function", "name": "second"}
body = self._transform(
input=[
{"type": "additional_tools", "role": "developer", "tools": [first]},
self._USER_MESSAGE,
{"type": "additional_tools", "role": "developer", "tools": [second]},
]
)
assert body["input"] == [self._USER_MESSAGE]
assert body["tools"] == [first, second]

def test_string_input_passes_through(self):
body = self._transform(input="hello")
assert body["input"] == "hello"
assert "tools" not in body

def test_input_without_additional_tools_is_unchanged(self):
codex_agentic_items = [
self._USER_MESSAGE,
{"type": "reasoning", "summary": [], "encrypted_content": "gAAAA=="},
{"type": "function_call", "name": "wait", "arguments": "{}", "call_id": "call_1"},
{"type": "function_call_output", "call_id": "call_1", "output": "done"},
]
body = self._transform(input=list(codex_agentic_items))
assert body["input"] == codex_agentic_items
assert "tools" not in body

def test_malformed_additional_tools_item_without_tools_list_is_stripped(self):
body = self._transform(
input=[
{"type": "additional_tools", "role": "developer"},
self._USER_MESSAGE,
]
)
assert body["input"] == [self._USER_MESSAGE]
assert "tools" not in body

def test_hoist_is_logged_at_debug_level(self):
from unittest.mock import patch

with patch(
"litellm.llms.bedrock_mantle.responses.transformation.verbose_logger.debug"
) as mock_debug:
self._transform(
input=[
{"type": "additional_tools", "role": "developer", "tools": self._CODEX_TOOLS},
self._USER_MESSAGE,
]
)
assert mock_debug.call_count == 1
assert "additional_tools" in str(mock_debug.call_args)


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)
Expand Down
Loading