mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +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
c2c2a623c0
commit
70b87910fc
4 changed files with 123 additions and 18 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue