From 6f86b117b541a91eb71c94f97eafc0092795a761 Mon Sep 17 00:00:00 2001 From: Kerry Lu Date: Sat, 31 Oct 2026 12:05:13 -0700 Subject: [PATCH] fix(moderations): stop the router image check from 500ing on a list input (#49454) * fix(moderations): stop the router image check from 500ing on a list input * fix(moderations): give the list cast its cast-ok reason * fix(moderations): type the responses input guard and keep SDK compaction items * fix(moderations): import the compaction item through litellm.types and mark the TypeIs import * fix(moderations): sort the compaction item import --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../guardrail_translation/handler.py | 20 ++++++++++++++++--- ...test_openai_responses_guardrail_handler.py | 20 +++++++++++++++++++ tests/unit/test_router/test_router.py | 1 + 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py index ddda9e7fb46..a905bff3521 100644 --- a/litellm/llms/openai/responses/guardrail_translation/handler.py +++ b/litellm/llms/openai/responses/guardrail_translation/handler.py @@ -40,7 +40,11 @@ from typing import TYPE_CHECKING, Any, Final, NamedTuple, Protocol, TypeVar, Uni from openai.types.responses.response_output_refusal import ResponseOutputRefusal from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError -from typing_extensions import ReadOnly, TypedDict +from typing_extensions import ( + ReadOnly, + TypedDict, + TypeIs, # noqa: TID251 # TypeIs reaches typing only on 3.13 +) from litellm._logging import verbose_proxy_logger from litellm.completion_extras.litellm_responses_transformation.transformation import ( @@ -100,6 +104,7 @@ from litellm.types.llms.openai import ( OutputTextDeltaEvent, OutputTextDoneEvent, ResponseAPIUsage, + ResponseCompactionItem, ResponseCompletedEvent, ResponsesAPIOptionalRequestParams, ResponsesAPIResponse, @@ -712,6 +717,15 @@ def _next_stream_sequence_number(responses_so_far: Sequence[object] | None) -> i return next((n + 1 for n in sequence_numbers if isinstance(n, int)), 0) +def _is_responses_input( + input_data: object, +) -> TypeIs["str | ResponseInputParam"]: # guard-ok: str, or a list of Mapping items + if isinstance(input_data, str): + return True + items: Final = cast(list[object], input_data) # cast-ok: isinstance narrows to list[Unknown] only + return isinstance(input_data, list) and all(isinstance(item, (Mapping, ResponseCompactionItem)) for item in items) + + class OpenAIResponsesHandler(BaseTranslation): """ Handler for processing OpenAI Responses API with guardrails. @@ -733,8 +747,8 @@ class OpenAIResponsesHandler(BaseTranslation): Transforms `input` (string or ResponseInputParam) and optional `instructions` into chat completion messages. """ - input_data: Final = data.get("input") - if input_data is None: + input_data: Final[object] = data.get("input") + if not _is_responses_input(input_data): return None messages: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( input=input_data, 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 3eb5f544ac6..d80a5e835c9 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 @@ -29,6 +29,7 @@ from openai.types.responses import ( ResponseOutputRefusal, ResponseOutputText, ) +from openai.types.responses.response_compaction_item import ResponseCompactionItem from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj @@ -3349,6 +3350,25 @@ class TestGetStructuredMessages: result = handler.get_structured_messages(data) assert result is None + def test_should_return_none_for_a_moderation_style_list_of_strings(self): + handler = OpenAIResponsesHandler() + data = {"model": "omni-moderation-latest", "input": ["safe text", "more safe text"]} + assert handler.get_structured_messages(data) is None + + def test_should_keep_an_image_item_replayed_with_an_sdk_compaction_item(self): + handler = OpenAIResponsesHandler() + image_url = "https://example.com/cat.png" + data = { + "model": "gpt-5.5", + "input": [ + ResponseCompactionItem(type="compaction", id="cmp_1", encrypted_content="abc"), + {"role": "user", "content": [{"type": "input_image", "image_url": image_url}]}, + ], + } + result = handler.get_structured_messages(data) + assert result is not None + assert image_url in json.dumps(result) + class ToolAppendingGuardrail(CustomGuardrail): """Guardrail that appends a new function tool, mimicking a guardrail that diff --git a/tests/unit/test_router/test_router.py b/tests/unit/test_router/test_router.py index 93da00ff064..6e097e75463 100644 --- a/tests/unit/test_router/test_router.py +++ b/tests/unit/test_router/test_router.py @@ -31445,6 +31445,7 @@ def test_request_carries_image_sees_chat_parts_and_responses_input_items(): assert litellm.Router._request_carries_image(None, {"input": _IMAGE_RESPONSES_INPUT}) is True assert litellm.Router._request_carries_image([{"role": "user", "content": "Say hi"}], {}) is False assert litellm.Router._request_carries_image(None, {"input": "Say hi"}) is False + assert litellm.Router._request_carries_image(None, {"input": ["safe text", "more safe text"]}) is False @pytest.mark.asyncio