From 70b87910fca54a75d9e8b4596e1d76216ddffea8 Mon Sep 17 00:00:00 2001 From: Shikhar Goel <223222024+sgoel2be24-cyber@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:15:19 +0530 Subject: [PATCH] fix(responses): validate and guard structured input Signed-off-by: Shikhar Goel <223222024+sgoel2be24-cyber@users.noreply.github.com> --- .../guardrail_translation/handler.py | 24 ++++--- .../transformation.py | 66 ++++++++++++++++--- ...test_openai_responses_guardrail_handler.py | 15 +++++ .../test_litellm_completion_responses.py | 36 ++++++++++ 4 files changed, 123 insertions(+), 18 deletions(-) diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py index 2fe11d9f7bd..bfc7d44954a 100644 --- a/litellm/llms/openai/responses/guardrail_translation/handler.py +++ b/litellm/llms/openai/responses/guardrail_translation/handler.py @@ -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, diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index e8aacac9e67..56057426ced 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -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, diff --git a/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py b/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py index a4f0a77a9b6..b6a68fa930a 100644 --- a/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py +++ b/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py @@ -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""" diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 3c78bbf79d7..e4b6352ffc8 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -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)