fix(bedrock): forward Codex agent messages that carry encrypted content intact (#50457)

* fix(bedrock): forward Codex agent messages that carry encrypted content intact

Codex multi-agent v2 sends worker messages as agent_message items whose
encrypted_content block only the model server can decrypt. Bedrock Mantle and
Bedrock Runtime used to 400 on agent_message, so LiteLLM rewrote every one into
assistant text, pasting the ciphertext into the prompt and dropping the sender
and recipient. Bedrock now decrypts these items itself, so the rewrite left the
recipient model reading an opaque token instead of its task.

Agent messages with an encrypted_content block now go to Bedrock untouched on
both endpoints. The Bedrock configs declare supports_encrypted_agent_messages,
and the shared normalizer keys off that flag so the two cannot drift. Mantle's
/v1/responses route for open-weight models still rejects agent_message in any
form, so it keeps the old rewrite. Plaintext-only agent messages keep the
rewrite on every route.

* fix(bedrock): only keep worker messages locked for OpenAI GPT models

Gemma and Grok on Mantle's OpenAI route never lock Codex worker messages, so
Codex puts plain text in the locked slot and Bedrock rejected it with
invalid_encrypted_content once the route alone decided to forward it intact.
Decide from the model instead, and restore the full-value rewrite assertion.
This commit is contained in:
ryan-crabbe-berri 2026-11-06 17:47:43 -08:00 • committed by GitHub
parent 98acc01e0d
commit 6b745635c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 217 additions and 56 deletions

View file

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

View file

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

View file

@ -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.",

View file

@ -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.",

View file

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

View file

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

View file

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

View file

@ -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"},
{

View file

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

View file

@ -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"}]

View file

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