diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py index a18cbe13925..69a1712fcd5 100644 --- a/litellm/llms/openai/responses/guardrail_translation/handler.py +++ b/litellm/llms/openai/responses/guardrail_translation/handler.py @@ -86,6 +86,7 @@ from litellm.responses.litellm_completion_transformation.tool_search import is_s from litellm.responses.litellm_completion_transformation.transformation import ( TOOL_CALL_OUTPUT_ITEM_TYPES, LiteLLMCompletionResponsesConfig, + ResponsesInput, tool_output_to_chat_content, ) from litellm.types.llms.base import LiteLLMBaseModel @@ -588,13 +589,14 @@ def _grouped_by_call_id(pairs: Iterable[tuple[str, _Grouped]]) -> Mapping[str, t def _texts_by_tool_row( - messages: Sequence[Mapping[str, object]], input_data: "str | ResponseInputParam" + messages: Sequence[Mapping[str, object]], input_data: ResponsesInput ) -> Mapping[int, tuple[str, ...]]: if isinstance(input_data, str): return MappingProxyType({}) + input_items: Final = (input_data,) if isinstance(input_data, dict) else input_data output_texts_by_call_id: Final = _grouped_by_call_id( (str(stream_item_field(item, "call_id")), tuple(text for _, text in tool_output_texts(item))) - for item in input_data + for item in input_items if stream_item_field(item, "type") in TOOL_CALL_OUTPUT_ITEM_TYPES ) row_indices_by_call_id: Final = _grouped_by_call_id( @@ -748,10 +750,12 @@ class OpenAIResponsesHandler(BaseTranslation): `instructions` into chat completion messages. """ input_data: Final[object] = data.get("input") - if not _is_responses_input(input_data): + if not isinstance(input_data, (dict, tuple)) and not _is_responses_input(input_data): 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 @@ -767,10 +771,12 @@ class OpenAIResponsesHandler(BaseTranslation): Handles both string input and list of message objects. """ - raw_input: Final[str | ResponseInputParam | None] = data.get("input") - if not isinstance(raw_input, (str, list)): + raw_input: Final = data.get("input") + if not isinstance(raw_input, (str, dict, list, tuple)): return data - input_data: Final = cast("str | ResponseInputParam", raw_input) # cast-ok: fields read via stream_item_field + input_data: Final = cast( # cast-ok: runtime container shape is validated immediately above before Responses-specific parsing enforces each nested item type. + "ResponsesInput", raw_input + ) skip_system: Final = effective_skip_system_message_for_guardrail(guardrail_to_apply) skip_tool: Final = effective_skip_tool_message_for_guardrail(guardrail_to_apply) scan_only: Final = effective_scan_only_tool_results_for_guardrail(guardrail_to_apply) @@ -846,7 +852,7 @@ class OpenAIResponsesHandler(BaseTranslation): 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, @@ -866,7 +872,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, ) @@ -874,7 +880,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, @@ -898,7 +904,8 @@ class OpenAIResponsesHandler(BaseTranslation): if not scan_only: 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, diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 3e605e926f7..89b3e758c14 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -15,6 +15,7 @@ from typing import ( Any, Final, Literal, + NoReturn, Protocol, TypeAlias, TypeVar, @@ -28,14 +29,19 @@ 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, ResponseOutputRefusal +from openai.types.responses import ( + ResponseFunctionToolCall, + ResponseFunctionWebSearch, + ResponseInputItemParam, + ResponseOutputRefusal, +) from openai.types.responses.response_compaction_item import ResponseCompactionItem 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 import litellm from litellm._logging import verbose_logger @@ -190,6 +196,7 @@ NamespaceNameMap: TypeAlias = Mapping[str, tuple[str, str]] NamespaceTool: TypeAlias = Mapping[str, object] ResponseTools: TypeAlias = Sequence[Mapping[str, object]] | None ChatToolParam: TypeAlias = ChatCompletionToolParam | OpenAIMcpServerTool | AnthropicToolSearchToolRegex +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"]]] = ( @@ -249,6 +256,14 @@ class ResponsesReasoningChatForm: summary: str | None +@dataclass(frozen=True, slots=True) +class _InvalidResponseInputType: + input_type: str + + +_ResponseInputTransformError: TypeAlias = _InvalidResponseInputType + + class _ResponsesFilePart(ChatCompletionFileObject, total=False): cache_control: ReadOnly[object] @@ -735,6 +750,20 @@ def _without_keys(item: object, keys: frozenset[str]) -> object: return {key: value for key, value in _STR_KEY_DICT_ADAPTER.validate_python(item).items() if key not in keys} +def _as_input_item_list(input: ResponsesInput | None) -> str | ResponseInputParam: + # A single input item, or a tuple of them, is read as the list that the compaction and + # cache_control steps and the per-item transform expect. A request without input has no + # input items (streamed usage estimates read such requests). Any other type is left for + # the transform to reject. + if input is None: + return [] + if isinstance(input, dict): + return [input] + if isinstance(input, tuple): + return list(input) + return input + + def _without_item_keys(input: str | ResponseInputParam, keys: frozenset[str]) -> str | ResponseInputParam: if not keys or not isinstance(input, list): return input @@ -1186,7 +1215,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, @@ -1242,7 +1271,7 @@ class LiteLLMCompletionResponsesConfig: drops_unsupported_params(request_kwargs.get("drop_params")), routed_model_info(request_kwargs.get("model_info")), ) - request_input: Final = with_compaction_items_as_params(input) + request_input: Final = with_compaction_items_as_params(_as_input_item_list(input)) native_compaction: Final = _replays_compaction_block( request_input, context_management ) and provider_takes_compaction_blocks(model, custom_llm_provider) @@ -1333,7 +1362,7 @@ class LiteLLMCompletionResponsesConfig: @staticmethod def transform_responses_api_input_to_messages( - input: str | ResponseInputParam, + input: ResponsesInput | None, responses_api_request: ResponsesAPIOptionalRequestParams | dict, replay_reasoning: bool = False, item_cache_control: bool = False, @@ -1392,7 +1421,7 @@ class LiteLLMCompletionResponsesConfig: ) ) - dumped_input: Final = with_compaction_items_as_params(input) + dumped_input: Final = with_compaction_items_as_params(_as_input_item_list(input)) read_input: Final = _input_to_read(dumped_input, only_since_compaction, native_compaction) sent_input: Final = _without_item_keys( read_input, _dropped_item_keys(item_cache_control, tool_call_provider_fields) @@ -1414,6 +1443,8 @@ class LiteLLMCompletionResponsesConfig: if call_id_error is None: raise raise call_id_error from error + if isinstance(converted, _InvalidResponseInputType): + LiteLLMCompletionResponsesConfig._raise_response_input_transform_error(converted) messages.extend(converted) return messages @@ -1578,9 +1609,15 @@ class LiteLLMCompletionResponsesConfig: input: str | ResponseInputParam, replay_reasoning: bool = False, native_compaction: bool = False, - ) -> list[ - AllMessageValues | GenericChatCompletionMessage | ChatCompletionMessageToolCall | ChatCompletionResponseMessage - ]: + ) -> ( + list[ + AllMessageValues + | GenericChatCompletionMessage + | ChatCompletionMessageToolCall + | ChatCompletionResponseMessage + ] + | _ResponseInputTransformError + ): """ Transform a ResponseInputParam into a Chat Completion message @@ -1700,10 +1737,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 d8954c4072a..9eb0c77c9e4 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 @@ -321,6 +321,40 @@ 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_dict_tool_output_lists_its_parts_in_structured_messages(self) -> None: + guardrail: Final = RecordingMaskingGuardrail(guardrail_name="test") + request: Final = { + "input": { + "type": "function_call_output", + "call_id": "call_1", + "output": [{"type": "input_text", "text": "first "}, {"type": "input_text", "text": "output"}], + }, + "model": "gpt-4", + } + + await OpenAIResponsesHandler().process_input_messages(request, guardrail) + + assert guardrail.seen_texts == [["first ", "output"]] + assert guardrail.seen_message_contents == [ + [[{"type": "text", "text": "first "}, {"type": "text", "text": "output"}]] + ] + @pytest.mark.asyncio async def test_compaction_summary_is_scanned_and_rewritten_inside_its_item(self) -> None: affinity_prefix: Final = "litellm_enc:bW9kZWxfaWQ6ZGVwLWE=;" 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 2427e7eacb1..b4274ee060c 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 @@ -6591,6 +6591,86 @@ 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) + + +def test_transform_response_input_param_single_item_drops_cache_control_like_list_input() -> None: + item: Final = {"type": "message", "role": "user", "content": "hi", "cache_control": {"type": "ephemeral"}} + + as_item: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( + input=item, responses_api_request={} + ) + + assert as_item == LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( + input=[item], responses_api_request={} + ) + assert as_item == [{"role": "user", "content": "hi"}] + + +def test_transform_response_input_param_tuple_drops_items_before_compaction_like_list_input() -> None: + summary: Final = {"type": "compaction", "content": "Earlier turns covered the Tokyo trip."} + items: Final = ( + {"type": "message", "role": "user", "content": "Plan the Tokyo trip."}, + {"type": "compaction", "id": "cmp_litellm", "encrypted_content": json.dumps(summary)}, + {"type": "message", "role": "user", "content": "And Osaka?"}, + ) + + as_tuple: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( + input=items, responses_api_request={}, only_since_compaction=True + ) + + assert as_tuple == LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( + input=list(items), responses_api_request={}, only_since_compaction=True + ) + assert as_tuple == [ + {"role": "user", "content": "Earlier turns covered the Tokyo trip."}, + {"role": "user", "content": "And Osaka?"}, + ] + + +def test_transform_response_input_param_without_input_returns_only_instructions() -> None: + messages: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( + input=None, responses_api_request={"instructions": "Be brief."} + ) + + assert messages == [{"role": "system", "content": "Be brief."}] + + _COMPACTION_BLOCK: Final = {"type": "compaction", "content": "Earlier turns covered the Tokyo trip."}