fix(responses): validate and guard structured input

Signed-off-by: Shikhar Goel <223222024+sgoel2be24-cyber@users.noreply.github.com>
This commit is contained in:
Shikhar Goel 2026-08-22 17:15:19 +05:30
parent c2c2a623c0
commit 70b87910fc
4 changed files with 123 additions and 18 deletions

View file

@ -60,6 +60,7 @@ from litellm.llms.base_llm.guardrail_translation.utils import (
from litellm.llms.openai.responses.guardrail_translation.tool_merge import merge_guardrailed_tools
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
ResponsesInput,
)
from litellm.types.llms.openai import (
AllMessageValues,
@ -442,10 +443,12 @@ class OpenAIResponsesHandler(BaseTranslation):
`instructions` into chat completion messages.
"""
input_data: Final = data.get("input")
if input_data is None:
if not isinstance(input_data, (str, dict, list, tuple)):
return None
messages: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=input_data,
input=cast( # cast-ok: runtime container shape is validated immediately above before Responses-specific parsing enforces each nested item type.
"ResponsesInput", input_data
),
responses_api_request=data,
)
return cast(list[AllMessageValues], messages) if messages else None
@ -461,9 +464,12 @@ class OpenAIResponsesHandler(BaseTranslation):
Handles both string input and list of message objects.
"""
input_data: Final[str | ResponseInputParam | None] = data.get("input")
if not isinstance(input_data, (str, list)):
input_data: Final = data.get("input")
if not isinstance(input_data, (str, dict, list, tuple)):
return data
typed_input: Final = cast( # cast-ok: runtime container shape is validated immediately above before Responses-specific parsing enforces each nested item type.
"ResponsesInput", input_data
)
structured_messages: Final = self.get_structured_messages(data)
raw_tools: Final = data.get("tools")
original_tools: Final[tuple[Mapping[str, object], ...]] = (
@ -472,7 +478,7 @@ class OpenAIResponsesHandler(BaseTranslation):
flattened_tool_groups: Final = tuple(
form.chat_tools for form in LiteLLMCompletionResponsesConfig.responses_tools_to_chat_forms(original_tools)
)
extracted: Final = self._extract_guardrail_inputs(data, input_data, flattened_tool_groups)
extracted: Final = self._extract_guardrail_inputs(data, typed_input, flattened_tool_groups)
if not extracted.inputs.get("texts"):
return data
if structured_messages:
@ -502,8 +508,9 @@ class OpenAIResponsesHandler(BaseTranslation):
from litellm.proxy.policy_engine.pipeline_executor import UnappliableRequestRewrite
raise UnappliableRequestRewrite(guardrail_to_apply.guardrail_name or "unknown")
input_messages: Final = (input_data,) if isinstance(input_data, dict) else input_data
await self._apply_guardrail_responses_to_input(
messages=input_data,
messages=input_messages,
responses=rewritten_texts,
task_mappings=extracted.task_mappings,
)
@ -513,7 +520,7 @@ class OpenAIResponsesHandler(BaseTranslation):
def _extract_guardrail_inputs(
self,
data: Mapping[str, object],
input_data: "str | ResponseInputParam",
input_data: ResponsesInput,
flattened_tool_groups: Sequence[Sequence[Mapping[str, object]]],
) -> _ExtractedInputs:
texts_to_check: Final[list[str]] = []
@ -531,7 +538,8 @@ class OpenAIResponsesHandler(BaseTranslation):
if isinstance(input_data, str):
texts_to_check.append(input_data)
else:
for msg_idx, message in enumerate(input_data):
input_messages: Final = (input_data,) if isinstance(input_data, dict) else input_data
for msg_idx, message in enumerate(input_messages):
self._extract_input_text_and_images(
message=message,
msg_idx=msg_idx,

View file

@ -13,6 +13,7 @@ from typing import (
Any,
Final,
Literal,
NoReturn,
Protocol,
TypeAlias,
cast,
@ -25,13 +26,17 @@ from openai.types.chat.chat_completion_named_tool_choice_param import (
from openai.types.chat.chat_completion_named_tool_choice_param import (
Function as NamedToolChoiceFunction,
)
from openai.types.responses import ResponseFunctionToolCall, ResponseFunctionWebSearch
from openai.types.responses import (
ResponseFunctionToolCall,
ResponseFunctionWebSearch,
ResponseInputItemParam,
)
from openai.types.responses.response_create_params import ResponseInputParam
from openai.types.responses.tool_choice_custom_param import ToolChoiceCustomParam
from openai.types.responses.tool_choice_function_param import ToolChoiceFunctionParam
from openai.types.responses.tool_param import FunctionToolParam
from pydantic import TypeAdapter, ValidationError
from typing_extensions import ReadOnly, TypedDict
from typing_extensions import ReadOnly, TypedDict, assert_never
from litellm._logging import verbose_logger
from litellm.caching import InMemoryCache
@ -108,6 +113,7 @@ NamespaceNameMap: TypeAlias = Mapping[str, tuple[str, str]]
NamespaceTool: TypeAlias = Mapping[str, object]
ResponseTools: TypeAlias = Sequence[Mapping[str, object]] | None
ChatToolParam: TypeAlias = ChatCompletionToolParam | OpenAIMcpServerTool
ResponsesInput: TypeAlias = str | ResponseInputItemParam | ResponseInputParam | tuple[ResponseInputItemParam, ...]
NAMESPACE_DESCRIPTION_SEPARATOR: Final = "\n\n"
@ -125,6 +131,14 @@ class ResponsesReasoningChatForm:
summary: str | None
@dataclass(frozen=True, slots=True)
class _InvalidResponseInputType:
input_type: str
_ResponseInputTransformError: TypeAlias = _InvalidResponseInputType
if TYPE_CHECKING:
from openai.types.responses.response_apply_patch_tool_call import (
ResponseApplyPatchToolCall,
@ -393,7 +407,7 @@ class LiteLLMCompletionResponsesConfig:
@staticmethod
def transform_responses_api_request_to_chat_completion_request(
model: str,
input: str | ResponseInputParam,
input: ResponsesInput,
responses_api_request: ResponsesAPIOptionalRequestParams,
custom_llm_provider: str | None = None,
stream: bool | None = None,
@ -477,7 +491,7 @@ class LiteLLMCompletionResponsesConfig:
@staticmethod
def transform_responses_api_input_to_messages(
input: str | ResponseInputParam,
input: ResponsesInput,
responses_api_request: ResponsesAPIOptionalRequestParams | dict,
replay_reasoning: bool = False,
) -> list[
@ -514,12 +528,15 @@ class LiteLLMCompletionResponsesConfig:
)
)
messages.extend(
transformed_input: Final = (
LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message(
input=input,
replay_reasoning=replay_reasoning,
)
)
if isinstance(transformed_input, _InvalidResponseInputType):
LiteLLMCompletionResponsesConfig._raise_response_input_transform_error(transformed_input)
messages.extend(transformed_input)
return messages
@ -593,11 +610,17 @@ class LiteLLMCompletionResponsesConfig:
@staticmethod
def _transform_response_input_param_to_chat_completion_message(
input: str | ResponseInputParam,
input: ResponsesInput,
replay_reasoning: bool = False,
) -> list[
AllMessageValues | GenericChatCompletionMessage | ChatCompletionMessageToolCall | ChatCompletionResponseMessage
]:
) -> (
list[
AllMessageValues
| GenericChatCompletionMessage
| ChatCompletionMessageToolCall
| ChatCompletionResponseMessage
]
| _ResponseInputTransformError
):
"""
Transform a ResponseInputParam into a Chat Completion message
@ -613,7 +636,14 @@ class LiteLLMCompletionResponsesConfig:
if isinstance(input, str):
messages.append(ChatCompletionUserMessage(role="user", content=input))
elif isinstance(input, list):
elif isinstance(input, dict):
messages.extend(
LiteLLMCompletionResponsesConfig._transform_responses_api_input_item_to_chat_completion_message(
input_item=input,
replay_reasoning=replay_reasoning,
)
)
elif isinstance(input, (list, tuple)):
existing_tool_call_ids: Final[set[str]] = set()
for _input in input:
chat_completion_messages = (
@ -716,10 +746,26 @@ class LiteLLMCompletionResponsesConfig:
continue
messages.extend(chat_completion_messages)
else:
return _InvalidResponseInputType(input_type=type(input).__name__)
if not replay_reasoning:
return messages
return LiteLLMCompletionResponsesConfig._merge_reasoning_only_assistant_messages(messages)
@staticmethod
def _raise_response_input_transform_error(error: _ResponseInputTransformError) -> NoReturn:
import litellm
match error:
case _InvalidResponseInputType(input_type=input_type):
raise litellm.BadRequestError(
message=f"Invalid input type: {input_type}. Expected str, dict, list, or tuple of message items.",
model="",
llm_provider="",
)
case _:
assert_never(error)
@staticmethod
def _reasoning_only_assistant_message(
reasoning_text: str | None,

View file

@ -191,6 +191,21 @@ class TestOpenAIResponsesHandlerInputProcessing:
assert result["input"][1]["content"] == "World [GUARDRAILED]"
assert result["model"] == "gpt-4"
@pytest.mark.asyncio
async def test_process_input_dict_with_string_content(self):
handler = OpenAIResponsesHandler()
guardrail = MockGuardrail(guardrail_name="test")
data = {
"input": {"role": "user", "content": "Hello", "type": "message"},
"model": "gpt-4",
}
result = await handler.process_input_messages(data, guardrail)
result_input = result["input"]
assert isinstance(result_input, dict)
assert result_input["content"] == "Hello [GUARDRAILED]"
@pytest.mark.asyncio
async def test_process_input_list_with_multimodal_content(self):
"""Test processing list input with multimodal content"""

View file

@ -4736,3 +4736,39 @@ class TestStreamingSnapshotItemIds:
reasoning_items = _bridged_output_items(completed_event.response, "reasoning")
assert len(reasoning_items) == 1
assert reasoning_items[0].id == streamed_event.item_id
def test_transform_response_input_param_dict_input():
"""Verify that a single dictionary input item is correctly converted to a chat completion message."""
dict_input = {"type": "message", "role": "user", "content": "hello from dict"}
messages = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=dict_input,
responses_api_request={},
)
assert len(messages) == 1
assert messages[0]["role"] == "user"
assert messages[0]["content"] == "hello from dict"
def test_transform_response_input_param_tuple_input():
"""Verify that tuple input items are accepted like list input items."""
tuple_input = (
{"type": "message", "role": "user", "content": "first"},
{"type": "message", "role": "user", "content": "second"},
)
messages = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=tuple_input,
responses_api_request={},
)
assert [message["content"] for message in messages] == ["first", "second"]
def test_transform_response_input_param_invalid_type_raises_bad_request():
"""Verify that passing invalid input types like integer raises BadRequestError."""
import litellm
with pytest.raises(litellm.BadRequestError) as exc_info:
LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=123,
responses_api_request={},
)
assert "Invalid input type" in str(exc_info.value)
assert "tuple" in str(exc_info.value)