mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
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:
commit
a9f42f0ccf
2 changed files with 294 additions and 1 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue