diff --git a/litellm/llms/base_llm/responses/codex_compat.py b/litellm/llms/base_llm/responses/codex_compat.py index fd769832217..62036e0a830 100644 --- a/litellm/llms/base_llm/responses/codex_compat.py +++ b/litellm/llms/base_llm/responses/codex_compat.py @@ -10,6 +10,11 @@ variant``. Both Amazon Bedrock endpoints reject them: They are *history* items, so they only appear from the second turn of a session onward -- a first-turn request succeeds and hides the problem entirely. +An ``agent_message`` content block's ``encrypted_content`` is ciphertext that only the +backend that issued it can decrypt for the recipient model. A backend that declares +``supports_encrypted_agent_messages`` gets those items intact, since turning them into +text would hand the model ciphertext it believes it wrote. + Codex also sends a ``web_search`` tool on every turn. api.openai.com runs that tool itself; a backend with no server-side tools rejects the whole request over it, so the same providers drop the tool types their backend does not accept. @@ -24,6 +29,7 @@ from typing import Final from typing_extensions import ReadOnly, TypedDict +from litellm.litellm_core_utils.core_helpers import as_object_list, as_str_mapping from litellm.types.llms.openai import ResponseInputParam AGENT_MESSAGE_INPUT_ITEM_TYPE: Final = "agent_message" @@ -63,6 +69,15 @@ def _agent_message_text(item: "Mapping[str, object]") -> str: ) +def _is_encrypted_block(block: object) -> bool: + mapping: Final = as_str_mapping(block) + return mapping is not None and bool(mapping.get("encrypted_content")) + + +def _carries_encrypted_content(item: "Mapping[str, object]") -> bool: + return any(_is_encrypted_block(block) for block in as_object_list(item.get("content")) or ()) + + def _normalize_agent_message_item(item: "Mapping[str, object]") -> "_RewrittenAssistantMessageItem | None": text: Final = _agent_message_text(item) if not text: @@ -97,28 +112,35 @@ def _normalize_local_shell_call_item(item: "Mapping[str, object]") -> "_Rewritte return rewritten -def _normalize_input_item(item: object) -> "tuple[object, str | None]": +def _normalize_input_item(item: object, keep_encrypted_agent_messages: bool) -> "tuple[object, str | None]": """Returns (normalized item, or None to drop it; original type when rewritten).""" - if not isinstance(item, dict): + mapping: Final = as_str_mapping(item) + if mapping is None: return item, None - item_type: Final = item.get("type") + item_type: Final = mapping.get("type") if item_type == AGENT_MESSAGE_INPUT_ITEM_TYPE: - return _normalize_agent_message_item(item), item_type + if keep_encrypted_agent_messages and _carries_encrypted_content(mapping): + return item, None + return _normalize_agent_message_item(mapping), AGENT_MESSAGE_INPUT_ITEM_TYPE if item_type == CONTEXT_COMPACTION_INPUT_ITEM_TYPE: - return _normalize_context_compaction_item(item), item_type + return _normalize_context_compaction_item(mapping), CONTEXT_COMPACTION_INPUT_ITEM_TYPE if item_type == LOCAL_SHELL_CALL_INPUT_ITEM_TYPE: - return _normalize_local_shell_call_item(item), item_type + return _normalize_local_shell_call_item(mapping), LOCAL_SHELL_CALL_INPUT_ITEM_TYPE return item, None def normalize_codex_input_items( input: "str | ResponseInputParam", + *, + keep_encrypted_agent_messages: bool, ) -> "tuple[str | ResponseInputParam, tuple[str, ...]]": """Rewrite the Codex history item types a Responses backend rejects. - ``agent_message`` (Codex multi-agent traffic; its ``encrypted_content`` slot - carries the plaintext payload when the model never issued encrypted args) - becomes an assistant message, ``context_compaction`` becomes the ``compaction`` + ``agent_message`` (Codex multi-agent traffic) carrying an ``encrypted_content`` + block is forwarded intact when ``keep_encrypted_agent_messages`` is set, because + only the backend can read it. Otherwise it becomes an assistant message: a backend + that never encrypts gets the plaintext payload Codex puts in that slot when the + model issued no encrypted args. ``context_compaction`` becomes the ``compaction`` spelling these backends accept, and ``local_shell_call`` becomes the ``function_call`` its recorded ``function_call_output`` already pairs with. @@ -127,7 +149,7 @@ def normalize_codex_input_items( """ if not isinstance(input, list): return input, () - normalized: Final = tuple(_normalize_input_item(item) for item in input) + normalized: Final = tuple(_normalize_input_item(item, keep_encrypted_agent_messages) for item in input) rewritten_types: Final = tuple(sorted(frozenset(item_type for _, item_type in normalized if item_type is not None))) kept: Final = [i for i, _ in normalized if i is not None] # Codex passthrough items sit outside the OpenAI input union. diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index de0132ec98c..85e2c5fe1a1 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -2836,3 +2836,13 @@ class CommonBatchFilesUtils: Get Bedrock-specific error class. """ return BedrockError(status_code=status_code, message=error_message, headers=headers) + + +def bedrock_model_encrypts_agent_messages(model: str) -> bool: + """Only OpenAI's GPT models on Bedrock lock Codex worker messages. + + Open-weight models (gpt-oss, Gemma, Grok) never lock them, so Codex puts plain text in the locked + slot, which Bedrock rejects with ``invalid_encrypted_content`` unless it is rewritten to text first. + """ + name: Final = f".{model.rsplit('/', 1)[-1]}" + return ".openai.gpt-" in name and ".openai.gpt-oss" not in name diff --git a/litellm/llms/bedrock/responses/transformation.py b/litellm/llms/bedrock/responses/transformation.py index ff44d973d97..96854f7e601 100644 --- a/litellm/llms/bedrock/responses/transformation.py +++ b/litellm/llms/bedrock/responses/transformation.py @@ -54,6 +54,7 @@ from litellm.llms.bedrock.common_utils import ( BEDROCK_CHAT_COMPLETIONS_ROUTE_PREFIX, DROP_DISABLED_REASONING_EFFORT_WARNING, BedrockError, + bedrock_model_encrypts_agent_messages, bedrock_reasoning_effort_disabled, bedrock_supports_openai_responses, ) @@ -187,10 +188,12 @@ class BedrockOpenAIResponsesConfig(BaseAWSLLM, OpenAIResponsesAPIConfig): self, fetch_image: "Callable[[str], str]" = convert_url_to_base64, async_fetch_image: "Callable[[str], Awaitable[str]]" = async_convert_url_to_base64, + encrypted_agent_messages: bool = False, ) -> None: super().__init__() self.fetch_image = fetch_image self.async_fetch_image = async_fetch_image + self.encrypted_agent_messages = encrypted_agent_messages @classmethod def for_model(cls, model: str | None) -> "BedrockOpenAIResponsesConfig | None": @@ -207,7 +210,7 @@ class BedrockOpenAIResponsesConfig(BaseAWSLLM, OpenAIResponsesAPIConfig): _without_chat_completions_route(model), litellm.model_cost ): return None - return cls() + return cls(encrypted_agent_messages=bedrock_model_encrypts_agent_messages(model)) @property def custom_llm_provider(self) -> LlmProviders: @@ -245,6 +248,9 @@ class BedrockOpenAIResponsesConfig(BaseAWSLLM, OpenAIResponsesAPIConfig): def supports_native_file_search(self) -> bool: return False + def supports_encrypted_agent_messages(self) -> bool: + return self.encrypted_agent_messages + def validate_environment( self, headers: dict, # mutable-ok: signature fixed by the BaseResponsesAPIConfig override contract @@ -358,7 +364,9 @@ class BedrockOpenAIResponsesConfig(BaseAWSLLM, OpenAIResponsesAPIConfig): litellm_params: GenericLiteLLMParams, headers: dict, # mutable-ok: same ) -> dict: # mutable-ok: same - normalized_input, rewritten_types = normalize_codex_input_items(input) + normalized_input, rewritten_types = normalize_codex_input_items( + input, keep_encrypted_agent_messages=self.supports_encrypted_agent_messages() + ) if rewritten_types: verbose_logger.warning( "Bedrock Runtime Responses API: rewrote Codex input item type(s) %s that the endpoint rejects.", diff --git a/litellm/llms/bedrock_mantle/responses/transformation.py b/litellm/llms/bedrock_mantle/responses/transformation.py index 7190a4f28c6..46419d7a5b1 100644 --- a/litellm/llms/bedrock_mantle/responses/transformation.py +++ b/litellm/llms/bedrock_mantle/responses/transformation.py @@ -75,10 +75,12 @@ class BedrockMantleResponsesAPIConfig(BedrockMantleAuthMixin, OpenAIResponsesAPI self, aws_signer: BaseAWSLLM | None = None, use_openai_path: bool = True, + encrypted_agent_messages: bool = False, ): super().__init__() self._aws_signer = aws_signer or BaseAWSLLM() self.use_openai_path = use_openai_path + self.encrypted_agent_messages = encrypted_agent_messages @property def custom_llm_provider(self) -> LlmProviders: @@ -87,6 +89,9 @@ class BedrockMantleResponsesAPIConfig(BedrockMantleAuthMixin, OpenAIResponsesAPI def auto_reasoning_summary(self) -> ReasoningSummaryLevel: return "auto" if self.use_openai_path else super().auto_reasoning_summary() + def supports_encrypted_agent_messages(self) -> bool: + return self.use_openai_path and self.encrypted_agent_messages + def get_error_class( self, error_message: str, status_code: int, headers: dict[str, object] | httpx.Headers ) -> BaseLLMException: @@ -215,7 +220,9 @@ class BedrockMantleResponsesAPIConfig(BedrockMantleAuthMixin, OpenAIResponsesAPI hoisted: Final = hoist_additional_tools( input, params.get("tools"), drop_params=drops_unsupported_params(litellm_params.drop_params) ) - normalized_input, rewritten_types = normalize_codex_input_items(hoisted.input) + normalized_input, rewritten_types = normalize_codex_input_items( + hoisted.input, keep_encrypted_agent_messages=self.supports_encrypted_agent_messages() + ) if rewritten_types: verbose_logger.warning( "Bedrock Mantle Responses API: rewrote Codex input item type(s) %s that Mantle rejects.", diff --git a/litellm/utils.py b/litellm/utils.py index 2c1a3892894..f2ed47b7210 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -10707,8 +10707,8 @@ class ProviderConfigManager: # given model is on it; None keeps the chat-completions bridge. return litellm.BedrockOpenAIResponsesConfig.for_model(model) elif litellm.LlmProviders.BEDROCK_MANTLE == provider: - # Both decisions are data-driven from the model's price-map entry, with - # no model-name logic. Capability (can it serve Responses?) comes from + # Capability and wire path are data-driven from the model's price-map + # entry, with no model-name logic. Capability (can it serve Responses?) comes from # mantle_supports_responses (supported_endpoints / mode); # chat-only models (gpt-oss safeguard, nvidia, ...) return None and keep # the chat-completions emulation (responses/main.py "config is None"). @@ -10716,6 +10716,7 @@ class ProviderConfigManager: # use_openai_responses_path flag: gpt-5.x and gemma-4-* on # /openai/v1/responses, everything else (incl. gpt-oss) on # /v1/responses. + from litellm.llms.bedrock.common_utils import bedrock_model_encrypts_agent_messages from litellm.llms.bedrock_mantle.common_utils import ( mantle_base_segment, mantle_supports_responses, @@ -10724,7 +10725,8 @@ class ProviderConfigManager: if not model or not mantle_supports_responses(model, litellm.model_cost): return None return litellm.BedrockMantleResponsesAPIConfig( - use_openai_path=mantle_base_segment(model, litellm.model_cost) == "openai/v1" + use_openai_path=mantle_base_segment(model, litellm.model_cost) == "openai/v1", + encrypted_agent_messages=bedrock_model_encrypts_agent_messages(model), ) return None diff --git a/tests/integration/providers/test_bedrock_gpt_responses_native_wire.py b/tests/integration/providers/test_bedrock_gpt_responses_native_wire.py index 0788b9ac85a..159097c80ee 100644 --- a/tests/integration/providers/test_bedrock_gpt_responses_native_wire.py +++ b/tests/integration/providers/test_bedrock_gpt_responses_native_wire.py @@ -121,3 +121,28 @@ async def test_async_openai_sdk_responses_stream_is_served_by_the_native_respons request: Final = _native_request(wire) assert _body(request) == {"model": GPT, "input": _prompt(marker), "stream": True}, request.body assert _spend_row(completed.response.id) == _success_row(model) + + +def test_codex_agent_message_with_encrypted_content_reaches_the_native_responses_route_intact( + gateway: Gateway, +) -> None: + marker: Final = uuid.uuid4().hex + agent_message: Final[JsonValue] = { + "type": "agent_message", + "author": "/root/reviewer", + "recipient": "/root", + "content": [ + {"type": "input_text", "text": "Worker message:\n"}, + {"type": "encrypted_content", "encrypted_content": f"gAAAAABsynthetic-agent-ciphertext-{marker}=="}, + ], + } + user_turn: Final[JsonValue] = {"type": "message", "role": "user", "content": _prompt(marker)} + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire) + response: Final = gateway.request( + "POST", + "/v1/responses", + {"model": model, "input": [agent_message, user_turn], "cache": {"no-cache": True}}, + ) + assert response.status_code == 200, response.text + assert _body(_native_request(wire))["input"] == [agent_message, user_turn] diff --git a/tests/integration/providers/test_bedrock_mantle_codex_input_wire.py b/tests/integration/providers/test_bedrock_mantle_codex_input_wire.py index aa66e82475b..1383910f08c 100644 --- a/tests/integration/providers/test_bedrock_mantle_codex_input_wire.py +++ b/tests/integration/providers/test_bedrock_mantle_codex_input_wire.py @@ -88,6 +88,35 @@ def test_codex_agent_message_context_compaction_and_local_shell_call_reach_mantl assert JSON_OBJECT.validate_json(forwarded[0].body)["input"] == expected_input, forwarded[0].body +def test_codex_agent_message_with_encrypted_content_reaches_mantle_intact(gateway: Gateway) -> None: + marker: Final = uuid.uuid4().hex + agent_message: Final[JsonValue] = { + "type": "agent_message", + "author": "/root/reviewer", + "recipient": "/root", + "content": [ + {"type": "input_text", "text": "Worker message:\n"}, + {"type": "encrypted_content", "encrypted_content": f"gAAAAABsynthetic-agent-ciphertext-{marker}=="}, + ], + } + expected_input: Final = [agent_message, user_turn(f"next turn {marker}")] + + def mantle_peer(request: Request) -> Reply: + assert request.method == "POST" and request.target == "/openai/v1/responses", request.target + assert JSON_OBJECT.validate_json(request.body)["input"] == expected_input, request.body + return Reply(body=RESPONSE) + + with wire_server(mantle_peer) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=MODEL, api_key=TOKEN, api_base=wire.url, aws_region_name="us-east-2") + response: Final = gateway.request( + "POST", "/v1/responses", {"model": model, "input": expected_input, "store": False} + ) + assert response.status_code == 200, response.text + forwarded: Final = wire.drain() + assert len(forwarded) == 1, forwarded + assert JSON_OBJECT.validate_json(forwarded[0].body)["input"] == expected_input, forwarded[0].body + + SHELL_TOOL: Final[JsonValue] = { "type": "function", "name": "shell", diff --git a/tests/integration/providers/test_bedrock_mantle_responses_wire.py b/tests/integration/providers/test_bedrock_mantle_responses_wire.py index 3bd83b5019b..84340132fbc 100644 --- a/tests/integration/providers/test_bedrock_mantle_responses_wire.py +++ b/tests/integration/providers/test_bedrock_mantle_responses_wire.py @@ -44,7 +44,7 @@ def _codex_history(marker: str) -> list[JsonValue]: { "type": "agent_message", "id": "msg_agent", - "content": [{"type": "text", "text": "sub-agent said "}, {"type": "text", "encrypted_content": "hello"}], + "content": [{"type": "input_text", "text": "sub-agent said "}, {"type": "input_text", "text": "hello"}], }, {"type": "context_compaction", "id": "cmp_1", "encrypted_content": "compacted-history"}, { diff --git a/tests/unit/llms/base_llm/responses/test_codex_compat.py b/tests/unit/llms/base_llm/responses/test_codex_compat.py index be811b17a82..2e66e986aa3 100644 --- a/tests/unit/llms/base_llm/responses/test_codex_compat.py +++ b/tests/unit/llms/base_llm/responses/test_codex_compat.py @@ -7,11 +7,14 @@ smoke test passes and hides the problem entirely. """ import json +from functools import partial import pytest from litellm.llms.base_llm.responses.codex_compat import normalize_codex_input_items +normalize = partial(normalize_codex_input_items, keep_encrypted_agent_messages=True) + USER = {"role": "user", "content": "hi"} @@ -22,7 +25,7 @@ class TestAgentMessage: "role": "assistant", "content": [{"type": "output_text", "text": "prior turn"}], } - out, types = normalize_codex_input_items([item, USER]) + out, types = normalize([item, USER]) assert types == ("agent_message",) assert out[0] == { "type": "message", @@ -30,40 +33,55 @@ class TestAgentMessage: "content": ({"type": "output_text", "text": "prior turn"},), } - def test_encrypted_content_slot_is_used_as_text(self): + @pytest.mark.parametrize( + "opaque_block", + [{"type": "encrypted_content", "encrypted_content": "ciphertext"}, {"encrypted_content": "ciphertext"}], + ) + def test_encrypted_item_reaches_a_decrypting_backend_intact(self, opaque_block): + item = { + "type": "agent_message", + "id": "amsg_1", + "author": "/root/reviewer", + "recipient": "/root", + "content": [{"type": "input_text", "text": "Worker message:\n"}, opaque_block], + } + out, types = normalize([item, USER]) + assert out == [item, USER] + assert types == () + + def test_encrypted_content_slot_is_used_as_text_when_the_backend_never_encrypts(self): """Codex puts the plaintext payload there when the model issued no encrypted args.""" item = {"type": "agent_message", "content": [{"encrypted_content": "plain"}]} - out, _ = normalize_codex_input_items([item, USER]) + out, types = normalize([item, USER], keep_encrypted_agent_messages=False) assert out[0]["content"] == ({"type": "output_text", "text": "plain"},) + assert types == ("agent_message",) def test_non_list_content_yields_no_text_and_drops_the_item(self): - out, types = normalize_codex_input_items([{"type": "agent_message", "content": "not a list"}, USER]) + out, types = normalize([{"type": "agent_message", "content": "not a list"}, USER]) assert out == [USER] assert types == ("agent_message",) def test_textless_item_is_dropped(self): - out, types = normalize_codex_input_items([{"type": "agent_message", "content": []}, USER]) + out, types = normalize([{"type": "agent_message", "content": []}, USER]) assert out == [USER] assert types == ("agent_message",) class TestContextCompaction: def test_becomes_compaction(self): - out, types = normalize_codex_input_items([{"type": "context_compaction", "encrypted_content": "abc"}, USER]) + out, types = normalize([{"type": "context_compaction", "encrypted_content": "abc"}, USER]) assert out[0] == {"type": "compaction", "encrypted_content": "abc"} assert types == ("context_compaction",) @pytest.mark.parametrize("bad", [{}, {"encrypted_content": ""}, {"encrypted_content": 7}]) def test_without_usable_content_is_dropped(self, bad): - out, _ = normalize_codex_input_items([{"type": "context_compaction", **bad}, USER]) + out, _ = normalize([{"type": "context_compaction", **bad}, USER]) assert out == [USER] class TestLocalShellCall: def test_becomes_the_function_call_its_output_pairs_with(self): - out, types = normalize_codex_input_items( - [{"type": "local_shell_call", "call_id": "c1", "action": {"command": ["ls"]}}, USER] - ) + out, types = normalize([{"type": "local_shell_call", "call_id": "c1", "action": {"command": ["ls"]}}, USER]) assert out[0] == { "type": "function_call", "call_id": "c1", @@ -73,21 +91,21 @@ class TestLocalShellCall: assert types == ("local_shell_call",) def test_missing_action_yields_empty_arguments(self): - out, _ = normalize_codex_input_items([{"type": "local_shell_call", "call_id": "c1"}, USER]) + out, _ = normalize([{"type": "local_shell_call", "call_id": "c1"}, USER]) assert out[0]["arguments"] == "{}" def test_without_call_id_is_dropped(self): - out, _ = normalize_codex_input_items([{"type": "local_shell_call"}, USER]) + out, _ = normalize([{"type": "local_shell_call"}, USER]) assert out == [USER] class TestPassthroughAndShape: def test_string_input_untouched(self): - assert normalize_codex_input_items("just a prompt") == ("just a prompt", ()) + assert normalize("just a prompt") == ("just a prompt", ()) def test_unrelated_items_untouched_and_no_types_reported(self): items = [USER, {"type": "message", "role": "assistant", "content": []}] - out, types = normalize_codex_input_items(items) + out, types = normalize(items) assert out == items assert types == () @@ -96,7 +114,7 @@ class TestPassthroughAndShape: per-item return protocol, so it is dropped. Other non-mapping entries pass through untouched. This matches the behaviour before the normalizer moved out of the bedrock_mantle config.""" - out, types = normalize_codex_input_items(["a string", 42, None, USER]) + out, types = normalize(["a string", 42, None, USER]) assert out == ["a string", 42, USER] assert types == () @@ -106,11 +124,11 @@ class TestPassthroughAndShape: {"type": "agent_message", "content": [{"text": "x"}]}, {"type": "local_shell_call", "call_id": "c2"}, ] - _, types = normalize_codex_input_items(items) + _, types = normalize(items) assert types == ("agent_message", "local_shell_call") def test_returns_a_list_not_a_tuple(self): """The input->messages conversion downstream narrows on isinstance(input, list); a tuple silently yields zero messages and the provider rejects the request.""" - out, _ = normalize_codex_input_items([{"type": "agent_message", "content": [{"text": "x"}]}, USER]) + out, _ = normalize([{"type": "agent_message", "content": [{"text": "x"}]}, USER]) assert isinstance(out, list) diff --git a/tests/unit/llms/bedrock/responses/test_bedrock_openai_responses.py b/tests/unit/llms/bedrock/responses/test_bedrock_openai_responses.py index a1466cfdae4..8844eae97bb 100644 --- a/tests/unit/llms/bedrock/responses/test_bedrock_openai_responses.py +++ b/tests/unit/llms/bedrock/responses/test_bedrock_openai_responses.py @@ -296,6 +296,28 @@ class TestCodexHistoryNormalization: "user", ] + def test_agent_message_with_encrypted_content_reaches_the_endpoint_intact(self): + """Only the endpoint can decrypt the block for the recipient model; as assistant text it is gibberish.""" + agent_message = { + "type": "agent_message", + "author": "/root/reviewer", + "recipient": "/root", + "content": [ + {"type": "input_text", "text": "Worker message:\n"}, + {"type": "encrypted_content", "encrypted_content": "gAAAAABpZ3ciphertext=="}, + ], + } + cfg = BedrockOpenAIResponsesConfig.for_model(MODEL) + assert cfg is not None + body = cfg.transform_responses_api_request( + model=MODEL, + input=[json.loads(json.dumps(agent_message)), {"role": "user", "content": "carry on"}], + response_api_optional_request_params={}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert body["input"] == [agent_message, {"role": "user", "content": "carry on"}] + def test_a_first_turn_request_is_untouched(self): """The rejected types are history items, so turn one exercises none of this.""" original = [{"role": "user", "content": "first turn"}] diff --git a/tests/unit/llms/bedrock_mantle/test_bedrock_mantle_responses_transformation.py b/tests/unit/llms/bedrock_mantle/test_bedrock_mantle_responses_transformation.py index f8f18852543..9e9e46af26c 100644 --- a/tests/unit/llms/bedrock_mantle/test_bedrock_mantle_responses_transformation.py +++ b/tests/unit/llms/bedrock_mantle/test_bedrock_mantle_responses_transformation.py @@ -24,6 +24,7 @@ from litellm.llms.bedrock_mantle.responses.transformation import ( ) from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import LlmProviders +from litellm.utils import ProviderConfigManager LOOKALIKE_MANTLE_HOSTS = ( "https://bedrock-mantle.us-east-1.api.aws.internal.example.com", @@ -902,11 +903,10 @@ class TestBedrockMantleCodexInputItemNormalization: on the Codex history item types agent_message, context_compaction, and local_shell_call (verified against bedrock-mantle.us-east-1.api.aws with openai.gpt-5.6-sol), so the config must rewrite them into supported - equivalents. agent_message is what every Codex multi-agent v2 session sends, - and its encrypted_content slot carries the verbatim plaintext payload when - the upstream model never issued encrypted args, so that slot must be - preserved, not dropped. Mantle also rejects assistant messages with - input_text content, so the rewrite must use output_text.""" + equivalents. agent_message is what every Codex multi-agent v2 session sends. + One carrying an encrypted_content block is ciphertext only Mantle can decrypt, + so it is forwarded intact. Mantle also rejects assistant messages with + input_text content, so the plaintext rewrite must use output_text.""" _USER_MESSAGE = { "type": "message", @@ -914,10 +914,11 @@ class TestBedrockMantleCodexInputItemNormalization: "content": [{"type": "input_text", "text": "Continue."}], } - def _transform(self, input): - cfg = BedrockMantleResponsesAPIConfig() + def _transform(self, input, model="openai.gpt-5.6-sol"): + cfg = ProviderConfigManager.get_provider_responses_api_config(LlmProviders.BEDROCK_MANTLE, model) + assert isinstance(cfg, BedrockMantleResponsesAPIConfig) return cfg.transform_responses_api_request( - model="openai.gpt-5.6-sol", + model=model, input=input, response_api_optional_request_params={}, litellm_params=GenericLiteLLMParams(), @@ -946,31 +947,48 @@ class TestBedrockMantleCodexInputItemNormalization: }, ] - def test_agent_message_encrypted_content_payload_is_preserved(self): + def test_agent_message_with_encrypted_content_reaches_mantle_intact_and_unlogged(self, caplog): + """Only Mantle can decrypt the block for the recipient model; as assistant text it is gibberish.""" + agent_message = { + "type": "agent_message", + "author": "/root/reviewer", + "recipient": "/root", + "content": [ + {"type": "input_text", "text": "Worker message:\n"}, + {"type": "encrypted_content", "encrypted_content": "gAAAAABpZ3ciphertext=="}, + ], + } + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + body = self._transform(input=[copy.deepcopy(agent_message), self._USER_MESSAGE]) + assert body["input"] == [agent_message, self._USER_MESSAGE] + assert not [record for record in caplog.records if "rewrote Codex input item" in record.getMessage()] + + @pytest.mark.parametrize("model", ["openai.gpt-oss-120b", "google.gemma-4-31b", "xai.grok-4.3"]) + def test_models_that_never_lock_worker_messages_keep_the_text_rewrite(self, model): + """Codex puts plain text in the locked slot for these models, which Mantle rejects unless rewritten.""" body = self._transform( input=[ { "type": "agent_message", "author": "/root", - "recipient": "/root/arithmetic", + "recipient": "/root/codeword", "content": [ {"type": "input_text", "text": "Message Type: NEW_TASK\nPayload:\n"}, - {"type": "encrypted_content", "encrypted_content": "Answer the question 'what is 2+2'."}, + {"type": "encrypted_content", "encrypted_content": "Reply with ZEPHYR-1."}, ], }, self._USER_MESSAGE, - ] + ], + model=model, ) - assert body["input"][0] == { - "type": "message", - "role": "assistant", - "content": ( - { - "type": "output_text", - "text": "Message Type: NEW_TASK\nPayload:\nAnswer the question 'what is 2+2'.", - }, - ), - } + assert body["input"] == [ + { + "type": "message", + "role": "assistant", + "content": ({"type": "output_text", "text": "Message Type: NEW_TASK\nPayload:\nReply with ZEPHYR-1."},), + }, + self._USER_MESSAGE, + ] def test_agent_message_without_any_text_is_dropped(self): body = self._transform(