From be9ae8f3abb0dc1596ad6b5ce51e9ec89949b70f Mon Sep 17 00:00:00 2001 From: Luis Gallego Ledesma Date: Tue, 11 Aug 2026 12:46:45 +0200 Subject: [PATCH] fix(bedrock_mantle): normalize Codex agent message inputs --- .../responses/transformation.py | 74 +++++++++++- ...bedrock_mantle_responses_transformation.py | 111 ++++++++++++++++++ 2 files changed, 184 insertions(+), 1 deletion(-) diff --git a/litellm/llms/bedrock_mantle/responses/transformation.py b/litellm/llms/bedrock_mantle/responses/transformation.py index 92da5835b2d..e54bc05a43b 100644 --- a/litellm/llms/bedrock_mantle/responses/transformation.py +++ b/litellm/llms/bedrock_mantle/responses/transformation.py @@ -49,6 +49,9 @@ _BEDROCK_MANTLE_SUPPORTED_RESPONSE_TOOL_TYPES = frozenset({"function", "mcp", "c _BEDROCK_MANTLE_SUPPORTED_SERVICE_TIERS: Final = frozenset({"auto", "default"}) _CODEX_ADDITIONAL_TOOLS_INPUT_ITEM_TYPE: Final = "additional_tools" +_CODEX_AGENT_MESSAGE_INPUT_ITEM_TYPE: Final = "agent_message" +_CODEX_AGENT_MESSAGE_INPUT_TEXT_CONTENT_TYPE: Final = "input_text" +_CODEX_AGENT_MESSAGE_ENCRYPTED_CONTENT_TYPE: Final = "encrypted_content" class BedrockMantleResponsesAPIConfig(BedrockMantleAuthMixin, OpenAIResponsesAPIConfig): @@ -155,6 +158,7 @@ class BedrockMantleResponsesAPIConfig(BedrockMantleAuthMixin, OpenAIResponsesAPI headers: dict, ) -> dict: remaining_input, hoisted_tools = self._hoist_codex_additional_tools(input) + normalized_input: Final = self._normalize_codex_agent_messages(remaining_input) request_params: Final = ( { **response_api_optional_request_params, @@ -168,7 +172,7 @@ class BedrockMantleResponsesAPIConfig(BedrockMantleAuthMixin, OpenAIResponsesAPI ) return super().transform_responses_api_request( model=model, - input=remaining_input, + input=normalized_input, response_api_optional_request_params=request_params, litellm_params=litellm_params, headers=headers, @@ -210,6 +214,74 @@ class BedrockMantleResponsesAPIConfig(BedrockMantleAuthMixin, OpenAIResponsesAPI ) return remaining_input, cls._filter_unsupported_tools(hoisted_tools) + @staticmethod + def _is_codex_agent_message_item(item: object) -> bool: + return isinstance(item, dict) and item.get("type") == _CODEX_AGENT_MESSAGE_INPUT_ITEM_TYPE + + @staticmethod + def _encrypted_content_block_count(item: "dict[str, Any]") -> int: + content: Final = item.get("content") + if not isinstance(content, list): + return 0 + return sum( + isinstance(block, dict) and block.get("type") == _CODEX_AGENT_MESSAGE_ENCRYPTED_CONTENT_TYPE + for block in content + ) + + @staticmethod + def _normalize_codex_agent_message_item(item: "dict[str, Any]") -> "dict[str, Any] | None": + content: Final = item.get("content") + if not isinstance(content, list): + return None + text_parts: Final = [ + block["text"] + for block in content + if isinstance(block, dict) + and block.get("type") == _CODEX_AGENT_MESSAGE_INPUT_TEXT_CONTENT_TYPE + and isinstance(block.get("text"), str) + ] + if not text_parts: + return None + return { + "type": "message", + "role": "assistant", + "content": [{"type": _CODEX_AGENT_MESSAGE_INPUT_TEXT_CONTENT_TYPE, "text": "\n".join(text_parts)}], + } + + @classmethod + def _normalize_codex_agent_messages( + cls, + input: "str | ResponseInputParam", + ) -> "str | ResponseInputParam": + if not isinstance(input, list): + return input + + agent_message_items: Final = [item for item in input if cls._is_codex_agent_message_item(item)] + if not agent_message_items: + return input + + normalized_items: Final = tuple( + cls._normalize_codex_agent_message_item(item) if cls._is_codex_agent_message_item(item) else item + for item in input + ) + normalized_agent_message_items: Final = tuple( + cls._normalize_codex_agent_message_item(item) for item in agent_message_items + ) + normalized_input: Final = [item for item in normalized_items if item is not None] + dropped_item_count: Final = sum(item is None for item in normalized_agent_message_items) + encrypted_content_block_count: Final = sum( + cls._encrypted_content_block_count(item) for item in agent_message_items + ) + verbose_logger.warning( + "Bedrock Mantle Responses API: converted %d unsupported Codex 'agent_message' item(s) to " + "'message'; dropped %d item(s) without readable text and %d encrypted_content block(s) Mantle " + "cannot process.", + len(agent_message_items) - dropped_item_count, + dropped_item_count, + encrypted_content_block_count, + ) + return normalized_input + def map_openai_params( self, response_api_optional_params: ResponsesAPIOptionalRequestParams, diff --git a/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_responses_transformation.py b/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_responses_transformation.py index bea979aec64..de9740cf9e2 100644 --- a/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_responses_transformation.py +++ b/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_responses_transformation.py @@ -632,6 +632,117 @@ class TestBedrockMantleCodexAdditionalTools: assert "additional_tools" in str(mock_debug.call_args) +class TestBedrockMantleCodexAgentMessages: + _USER_MESSAGE = { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Continue the task."}], + } + + def _transform(self, input): + cfg = BedrockMantleResponsesAPIConfig() + return cfg.transform_responses_api_request( + model="openai.gpt-5.6-terra", + input=input, + response_api_optional_request_params={}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + def test_plain_agent_message_is_normalized_to_assistant_message(self): + body = self._transform( + input=[ + self._USER_MESSAGE, + { + "type": "agent_message", + "id": "amsg_123", + "author": "worker", + "recipient": "parent", + "content": [{"type": "input_text", "text": "The subtask is complete."}], + }, + ] + ) + + assert body["input"] == [ + self._USER_MESSAGE, + { + "type": "message", + "role": "assistant", + "content": [{"type": "input_text", "text": "The subtask is complete."}], + }, + ] + + def test_encrypted_agent_message_keeps_readable_text_only(self): + body = self._transform( + input=[ + { + "type": "agent_message", + "author": "worker", + "recipient": "parent", + "content": [ + {"type": "input_text", "text": "Message Type: MESSAGE\nSender: worker\nPayload:\n"}, + {"type": "encrypted_content", "encrypted_content": "opaque-payload"}, + ], + } + ] + ) + + assert body["input"] == [ + { + "type": "message", + "role": "assistant", + "content": [ + {"type": "input_text", "text": "Message Type: MESSAGE\nSender: worker\nPayload:\n"} + ], + } + ] + + def test_agent_message_without_readable_text_is_removed(self): + body = self._transform( + input=[ + self._USER_MESSAGE, + { + "type": "agent_message", + "author": "worker", + "recipient": "parent", + "content": [{"type": "encrypted_content", "encrypted_content": "opaque-payload"}], + }, + ] + ) + + assert body["input"] == [self._USER_MESSAGE] + + def test_agent_message_normalization_is_logged_at_warning_level(self): + from unittest.mock import patch + + with patch( + "litellm.llms.bedrock_mantle.responses.transformation.verbose_logger.warning" + ) as mock_warning: + self._transform( + input=[ + { + "type": "agent_message", + "content": [{"type": "input_text", "text": "Done."}], + } + ] + ) + + assert mock_warning.call_count == 1 + assert "agent_message" in str(mock_warning.call_args) + + def test_non_agent_items_remain_unchanged(self): + input_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=input_items) + + assert body["input"] == input_items + + 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)