mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
fix(responses): validate and guard structured input
Signed-off-by: Shikhar Goel <223222024+sgoel2be24-cyber@users.noreply.github.com>
This commit is contained in:
parent
40423e6ec0
commit
2fad30d210
4 changed files with 113 additions and 16 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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[
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue