Merge pull request #38227 from BerriAI/litellm_mantle_codex_input_items

fix(bedrock_mantle): normalize Codex input item types Mantle rejects
This commit is contained in:
Mateo Wang 2026-08-25 12:04:13 -07:00 • committed by GitHub
commit a9f42f0ccf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 294 additions and 1 deletions

View file

@ -15,8 +15,12 @@ role / access key / profile / web identity), signed via the shared
BaseAWSLLM._sign_request after the request body is finalized.
"""
import json
from collections.abc import Mapping
from typing import Any, Final
from typing_extensions import ReadOnly, TypedDict
import litellm
from litellm._logging import verbose_logger
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
@ -50,6 +54,33 @@ _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_CONTEXT_COMPACTION_INPUT_ITEM_TYPE: Final = "context_compaction"
_CODEX_LOCAL_SHELL_CALL_INPUT_ITEM_TYPE: Final = "local_shell_call"
class _RewrittenOutputTextBlock(TypedDict):
type: ReadOnly[str]
text: ReadOnly[str]
class _RewrittenAssistantMessageItem(TypedDict):
type: ReadOnly[str]
role: ReadOnly[str]
content: ReadOnly[tuple[_RewrittenOutputTextBlock, ...]]
class _RewrittenCompactionItem(TypedDict):
type: ReadOnly[str]
encrypted_content: ReadOnly[str]
class _RewrittenFunctionCallItem(TypedDict):
type: ReadOnly[str]
call_id: ReadOnly[str]
name: ReadOnly[str]
arguments: ReadOnly[str]
class BedrockMantleResponsesAPIConfig(BedrockMantleAuthMixin, OpenAIResponsesAPIConfig):
def __init__(
@ -155,6 +186,7 @@ class BedrockMantleResponsesAPIConfig(BedrockMantleAuthMixin, OpenAIResponsesAPI
headers: dict,
) -> dict:
remaining_input, hoisted_tools = self._hoist_codex_additional_tools(input)
normalized_input: Final = self._normalize_codex_input_items(remaining_input)
request_params: Final = (
{
**response_api_optional_request_params,
@ -168,7 +200,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 +242,91 @@ class BedrockMantleResponsesAPIConfig(BedrockMantleAuthMixin, OpenAIResponsesAPI
)
return remaining_input, cls._filter_unsupported_tools(hoisted_tools)
@staticmethod
def _agent_message_text(item: "Mapping[str, Any]") -> str:
content: Final = item.get("content")
if not isinstance(content, list):
return ""
return "".join(
str(block.get("text") or block.get("encrypted_content") or "")
for block in content
if isinstance(block, dict)
)
@classmethod
def _normalize_agent_message_item(cls, item: "Mapping[str, Any]") -> "_RewrittenAssistantMessageItem | None":
text: Final = cls._agent_message_text(item)
if not text:
return None
rewritten: Final[_RewrittenAssistantMessageItem] = {
"type": "message",
"role": "assistant",
"content": ({"type": "output_text", "text": text},),
}
return rewritten
@staticmethod
def _normalize_context_compaction_item(item: "Mapping[str, Any]") -> "_RewrittenCompactionItem | None":
encrypted_content: Final = item.get("encrypted_content")
if not isinstance(encrypted_content, str) or not encrypted_content:
return None
rewritten: Final[_RewrittenCompactionItem] = {"type": "compaction", "encrypted_content": encrypted_content}
return rewritten
@staticmethod
def _normalize_local_shell_call_item(item: "Mapping[str, Any]") -> "_RewrittenFunctionCallItem | None":
call_id: Final = item.get("call_id")
if not isinstance(call_id, str) or not call_id:
return None
action: Final = item.get("action")
rewritten: Final[_RewrittenFunctionCallItem] = {
"type": "function_call",
"call_id": call_id,
"name": "local_shell",
"arguments": json.dumps(action) if isinstance(action, dict) else "{}",
}
return rewritten
@classmethod
def _normalize_codex_input_item(cls, item: object) -> "tuple[object, str | None]":
"""Returns (normalized item or None to drop it, original type when rewritten)."""
if not isinstance(item, dict):
return item, None
item_type: Final = item.get("type")
if item_type == _CODEX_AGENT_MESSAGE_INPUT_ITEM_TYPE:
return cls._normalize_agent_message_item(item), item_type
if item_type == _CODEX_CONTEXT_COMPACTION_INPUT_ITEM_TYPE:
return cls._normalize_context_compaction_item(item), item_type
if item_type == _CODEX_LOCAL_SHELL_CALL_INPUT_ITEM_TYPE:
return cls._normalize_local_shell_call_item(item), item_type
return item, None
@classmethod
def _normalize_codex_input_items(
cls,
input: "str | ResponseInputParam",
) -> "str | ResponseInputParam":
"""Rewrite Codex history item types Mantle rejects with 400 "Invalid
'input': value did not match any expected variant" into supported
equivalents. `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` spelling Mantle accepts,
and `local_shell_call` becomes the function_call its recorded
function_call_output already pairs with.
"""
if not isinstance(input, list):
return input
normalized: Final = tuple(cls._normalize_codex_input_item(item) for item in input)
rewritten_types: Final = sorted(frozenset(item_type for _, item_type in normalized if item_type is not None))
if rewritten_types:
verbose_logger.warning(
"Bedrock Mantle Responses API: rewrote Codex input item type(s) %s that Mantle rejects.",
rewritten_types,
)
kept: Final = [item for item, _ in normalized if item is not None] # mutable-ok: ResponseInputParam is a list
return kept # pyright: ignore[reportReturnType] # Codex passthrough items sit outside the OpenAI input union
def map_openai_params(
self,
response_api_optional_params: ResponsesAPIOptionalRequestParams,

View file

@ -9,6 +9,7 @@ gate, the URL construction for both paths, and the shared Bearer auth.
import copy
import json
import logging
from pathlib import Path
import pytest
@ -624,6 +625,181 @@ class TestBedrockMantleCodexAdditionalTools:
assert "additional_tools" in str(mock_debug.call_args)
class TestBedrockMantleCodexInputItemNormalization:
"""Mantle 400s ("Invalid 'input': value did not match any expected variant")
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."""
_USER_MESSAGE = {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Continue."}],
}
def _transform(self, input):
cfg = BedrockMantleResponsesAPIConfig()
return cfg.transform_responses_api_request(
model="openai.gpt-5.6-sol",
input=input,
response_api_optional_request_params={},
litellm_params=GenericLiteLLMParams(),
headers={},
)
def test_plaintext_agent_message_becomes_assistant_output_text_message(self):
body = self._transform(
input=[
self._USER_MESSAGE,
{
"type": "agent_message",
"id": "amsg_1",
"author": "/root/arithmetic",
"recipient": "/root",
"content": [{"type": "input_text", "text": "Message Type: FINAL_ANSWER\nPayload:\n2+2 is 4."}],
},
]
)
assert body["input"] == [
self._USER_MESSAGE,
{
"type": "message",
"role": "assistant",
"content": ({"type": "output_text", "text": "Message Type: FINAL_ANSWER\nPayload:\n2+2 is 4."},),
},
]
def test_agent_message_encrypted_content_payload_is_preserved(self):
body = self._transform(
input=[
{
"type": "agent_message",
"author": "/root",
"recipient": "/root/arithmetic",
"content": [
{"type": "input_text", "text": "Message Type: NEW_TASK\nPayload:\n"},
{"type": "encrypted_content", "encrypted_content": "Answer the question 'what is 2+2'."},
],
},
self._USER_MESSAGE,
]
)
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'.",
},
),
}
def test_agent_message_without_any_text_is_dropped(self):
body = self._transform(
input=[
{"type": "agent_message", "author": "/root", "recipient": "/root/a", "content": []},
self._USER_MESSAGE,
]
)
assert body["input"] == [self._USER_MESSAGE]
def test_context_compaction_becomes_compaction_with_same_ciphertext(self):
body = self._transform(
input=[
{"type": "context_compaction", "id": "cc_1", "encrypted_content": "smry_abc123"},
self._USER_MESSAGE,
]
)
assert body["input"] == [
{"type": "compaction", "encrypted_content": "smry_abc123"},
self._USER_MESSAGE,
]
def test_context_compaction_without_ciphertext_is_dropped(self):
body = self._transform(
input=[
{"type": "context_compaction", "id": "cc_1"},
self._USER_MESSAGE,
]
)
assert body["input"] == [self._USER_MESSAGE]
def test_local_shell_call_becomes_function_call_keeping_call_id_pairing(self):
body = self._transform(
input=[
{
"type": "local_shell_call",
"id": "lsh_1",
"call_id": "call_1",
"status": "completed",
"action": {"type": "exec", "command": ["echo", "hi"]},
},
{"type": "function_call_output", "call_id": "call_1", "output": "hi\n"},
self._USER_MESSAGE,
]
)
assert body["input"] == [
{
"type": "function_call",
"call_id": "call_1",
"name": "local_shell",
"arguments": '{"type": "exec", "command": ["echo", "hi"]}',
},
{"type": "function_call_output", "call_id": "call_1", "output": "hi\n"},
self._USER_MESSAGE,
]
def test_local_shell_call_without_call_id_is_dropped(self):
body = self._transform(
input=[
{"type": "local_shell_call", "status": "completed", "action": {"type": "exec", "command": ["ls"]}},
self._USER_MESSAGE,
]
)
assert body["input"] == [self._USER_MESSAGE]
def test_mantle_supported_item_types_pass_through_untouched(self):
supported_items = [
self._USER_MESSAGE,
{"type": "compaction", "encrypted_content": "smry_abc123"},
{"type": "function_call", "name": "shell", "arguments": "{}", "call_id": "call_2"},
{"type": "function_call_output", "call_id": "call_2", "output": "ok"},
{"type": "tool_search_call", "call_id": "call_3", "execution": "server", "arguments": {"query": "x"}},
{"type": "tool_search_output", "call_id": "call_3", "status": "completed", "execution": "server", "tools": []},
{"type": "compaction_trigger"},
]
body = self._transform(input=copy.deepcopy(supported_items))
assert body["input"] == supported_items
def test_string_input_passes_through(self):
body = self._transform(input="Say hi.")
assert body["input"] == "Say hi."
def test_rewrite_is_logged_as_warning_naming_the_types(self, caplog):
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
body = self._transform(
input=[
{"type": "agent_message", "author": "a", "recipient": "b", "content": [{"type": "input_text", "text": "hi"}]},
self._USER_MESSAGE,
]
)
assert body["input"][0]["role"] == "assistant"
rewrite_warnings = [
record.getMessage()
for record in caplog.records
if record.levelno == logging.WARNING and "rewrote Codex input item type" in record.getMessage()
]
assert rewrite_warnings == [
"Bedrock Mantle Responses API: rewrote Codex input item type(s) ['agent_message'] that Mantle rejects."
]
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)