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 40423e6ec0
commit 2fad30d210
4 changed files with 113 additions and 16 deletions

View file

@ -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

View file

@ -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[

View file

@ -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"""

View file

@ -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)