mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
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:
parent
98acc01e0d
commit
6b745635c8
11 changed files with 217 additions and 56 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"}]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue