From daaedbadf554b8d7bd8446798bd1a57adf10ede3 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 | 27 +++++--- .../transformation.py | 66 ++++++++++++++++--- ...test_openai_responses_guardrail_handler.py | 15 +++++ .../test_litellm_completion_responses.py | 38 +++++++++++ 4 files changed, 126 insertions(+), 20 deletions(-) diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py index 61e0127f5b1..ed5b942b544 100644 --- a/litellm/llms/openai/responses/guardrail_translation/handler.py +++ b/litellm/llms/openai/responses/guardrail_translation/handler.py @@ -65,6 +65,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.base import LiteLLMBaseModel from litellm.types.llms.openai import ( @@ -533,10 +534,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 @@ -552,9 +555,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 + ) skip_system: Final = effective_skip_system_message_for_guardrail(guardrail_to_apply) structured_messages: Final = self.get_structured_messages(data) scoped_indices: Final = scoped_structured_message_indices( @@ -571,7 +577,7 @@ class OpenAIResponsesHandler(BaseTranslation): 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, skip_system=skip_system + data, typed_input, flattened_tool_groups, skip_system=skip_system ) if not extracted.inputs.get("texts"): return data @@ -601,14 +607,14 @@ class OpenAIResponsesHandler(BaseTranslation): else: data["instructions"] = written_back.instructions # rebind-ok: data is an out-param else: - await self._apply_guardrailed_texts(data, input_data, extracted, guardrail_to_apply, guardrailed_inputs) + await self._apply_guardrailed_texts(data, typed_input, extracted, guardrail_to_apply, guardrailed_inputs) verbose_proxy_logger.debug("OpenAI Responses API: Processed input messages: %s", data.get("input")) return data async def _apply_guardrailed_texts( self, data: dict[str, object], - input_data: "str | ResponseInputParam", + input_data: ResponsesInput, extracted: _ExtractedInputs, guardrail_to_apply: "CustomGuardrail", guardrailed_inputs: GenericGuardrailAPIInputs, @@ -628,7 +634,7 @@ class OpenAIResponsesHandler(BaseTranslation): data["input"] = input_texts[0] # rebind-ok: data is an out-param return await self._apply_guardrail_responses_to_input( - messages=input_data, + messages=(input_data,) if isinstance(input_data, dict) else input_data, responses=input_texts, task_mappings=extracted.task_mappings, ) @@ -636,7 +642,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]]], *, skip_system: bool = False, @@ -657,7 +663,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): if role_out_of_guardrail_scope( _input_item_role(message), skip_system_message=skip_system, skip_tool_message=False ): diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index c2cc876970c..526ba555060 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 @@ -116,6 +121,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" NAMESPACE_MEMBER_TYPES_WITH_CHAT_TOOLS: Final = frozenset({"function", "custom"}) _INCOMPLETE_REASON_BY_FINISH_REASON: Final[Mapping[str, Literal["max_output_tokens", "content_filter"]]] = ( @@ -175,6 +181,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, @@ -446,7 +460,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, @@ -532,7 +546,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[ @@ -569,12 +583,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 @@ -656,11 +673,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 @@ -676,7 +699,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 = ( @@ -779,10 +809,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/unit/llms/openai/responses/test_openai_responses_guardrail_handler.py b/tests/unit/llms/openai/responses/test_openai_responses_guardrail_handler.py index 8bd242308f7..b7dd5281121 100644 --- a/tests/unit/llms/openai/responses/test_openai_responses_guardrail_handler.py +++ b/tests/unit/llms/openai/responses/test_openai_responses_guardrail_handler.py @@ -242,6 +242,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/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 7006feb22b2..4de7d75c325 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -5191,3 +5191,41 @@ def test_transform_request_reads_service_tier_from_responses_api_request() -> No ) assert result["service_tier"] == "priority" + + +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)