From 2fad30d210243f724c032c27be9f7d8f60a04cfd 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 | 15 +++-- .../transformation.py | 63 ++++++++++++++++--- ...test_openai_responses_guardrail_handler.py | 15 +++++ .../test_litellm_completion_responses.py | 36 +++++++++++ 4 files changed, 113 insertions(+), 16 deletions(-) diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py index 7c5d8ac99ad..3b75cde19d2 100644 --- a/litellm/llms/openai/responses/guardrail_translation/handler.py +++ b/litellm/llms/openai/responses/guardrail_translation/handler.py @@ -62,7 +62,6 @@ if TYPE_CHECKING: from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.proxy._types import UserAPIKeyAuth - from litellm.types.llms.openai import ResponseInputParam from litellm.types.utils import ResponsesAPIResponse @@ -99,7 +98,7 @@ 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, @@ -118,7 +117,7 @@ class OpenAIResponsesHandler(BaseTranslation): Handles both string input and list of message objects. """ - input_data: Final[str | ResponseInputParam | None] = data.get("input") + input_data: Final = data.get("input") tools_to_check: Final[list[ChatCompletionToolParam]] = [] if input_data is None: return data @@ -155,8 +154,8 @@ class OpenAIResponsesHandler(BaseTranslation): verbose_proxy_logger.debug("OpenAI Responses API: Processed string input") return data - # Handle list input (ResponseInputParam) - if not isinstance(input_data, list): + input_messages: Final = (input_data,) if isinstance(input_data, dict) else input_data + if not isinstance(input_messages, (list, tuple)): return data texts_to_check: Final[list[str]] = [] @@ -165,7 +164,7 @@ class OpenAIResponsesHandler(BaseTranslation): original_tools_list: Final[list[dict[str, object]]] = list(data.get("tools") or []) # Step 1: Extract all text content, images, and tools - for msg_idx, message in enumerate(input_data): + for msg_idx, message in enumerate(input_messages): self._extract_input_text_and_images( message=message, msg_idx=msg_idx, @@ -207,12 +206,12 @@ class OpenAIResponsesHandler(BaseTranslation): # Step 3: Map guardrail responses back to original input structure await self._apply_guardrail_responses_to_input( - messages=input_data, + messages=input_messages, responses=guardrailed_texts, task_mappings=task_mappings, ) - verbose_proxy_logger.debug("OpenAI Responses API: Processed input messages: %s", input_data) + verbose_proxy_logger.debug("OpenAI Responses API: Processed input messages: %s", input_messages) return data diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index ec0b425dee6..2ab05e59ffe 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -6,14 +6,17 @@ import json import re import uuid from collections.abc import Iterable, Iterator, Mapping, Sequence +from dataclasses import dataclass from types import MappingProxyType from typing import ( TYPE_CHECKING, Any, Final, Literal, + NoReturn, Protocol, TypeAlias, + assert_never, cast, runtime_checkable, ) @@ -24,7 +27,7 @@ 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 +from openai.types.responses import ResponseFunctionToolCall, ResponseInputItemParam from openai.types.responses.response_create_params import ResponseInputParam from openai.types.responses.tool_param import FunctionToolParam from pydantic import TypeAdapter @@ -100,6 +103,15 @@ from .custom_tools import ( NamespaceNameMap: TypeAlias = Mapping[str, tuple[str, str]] NamespaceTool: TypeAlias = Mapping[str, object] ResponseTools: TypeAlias = Sequence[Mapping[str, object]] | None +ResponsesInput: TypeAlias = str | ResponseInputItemParam | ResponseInputParam | tuple[ResponseInputItemParam, ...] + + +@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 ( @@ -366,7 +378,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[ @@ -403,12 +415,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 @@ -482,11 +497,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 @@ -502,7 +523,15 @@ class LiteLLMCompletionResponsesConfig: if isinstance(input, str): messages.append(ChatCompletionUserMessage(role="user", content=input)) - elif isinstance(input, list): + elif isinstance(input, dict): + chat_completion_messages = ( + LiteLLMCompletionResponsesConfig._transform_responses_api_input_item_to_chat_completion_message( + input_item=input, + replay_reasoning=replay_reasoning, + ) + ) + messages.extend(chat_completion_messages) + elif isinstance(input, (list, tuple)): existing_tool_call_ids: Final[set[str]] = set() for _input in input: chat_completion_messages = ( @@ -604,6 +633,8 @@ 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) @@ -759,6 +790,22 @@ class LiteLLMCompletionResponsesConfig: return merged + @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 _merged_trailing_assistant_message( messages: Sequence[ 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 447175b09a6..856895c5d6c 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 @@ -121,6 +121,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 b96d2eb5322..59b2436b2d3 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 @@ -4015,3 +4015,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)