diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index 07822bb17b3..9cb074a177e 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -499,8 +499,13 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): ) if sanitized_tools is not None: response_api_optional_request_params["tools"] = sanitized_tools - final_request_params: Final = dict( - ResponsesAPIRequestParams(model=model, input=replay_safe_input, **response_api_optional_request_params) + final_request_params: Final = cast( # cast-ok: typed dict spread is inferred as Unknown by basedpyright + dict[str, object], + { + "model": model, + "input": replay_safe_input, + **response_api_optional_request_params, + }, ) return final_request_params @@ -1209,8 +1214,13 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): ) if sanitized_tools is not None: response_api_optional_request_params["tools"] = sanitized_tools - data: Final = dict( - ResponsesAPIRequestParams(model=model, input=replay_safe_input, **response_api_optional_request_params) + data: Final = cast( # cast-ok: typed dict spread is inferred as Unknown by basedpyright + dict[str, object], + { + "model": model, + "input": replay_safe_input, + **response_api_optional_request_params, + }, ) return url, data diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 4912f9f1375..8e1db87c16f 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -1342,6 +1342,10 @@ class ResponsesAPIStreamOptions(TypedDict, total=False): include_obfuscation: bool +class ResponsesAPIModeration(TypedDict, total=False): + model: Required[ReadOnly[str]] + + class ResponsesAPIOptionalRequestParams(TypedDict, total=False): """TypedDict for Optional parameters supported by the responses API.""" @@ -1369,6 +1373,7 @@ class ResponsesAPIOptionalRequestParams(TypedDict, total=False): prompt_cache_key: str | None prompt_cache_retention: str | None prompt_cache_options: ReadOnly[PromptCacheOptions | None] + moderation: ReadOnly[ResponsesAPIModeration | None] stream_options: ResponsesAPIStreamOptions | None top_logprobs: int | None partial_images: int | None # Number of partial images to generate (1-3) for streaming image generation diff --git a/tests/e2e/llm_translation/test_responses_e2e.py b/tests/e2e/llm_translation/test_responses_e2e.py index bb75569ad99..a0f185c4077 100644 --- a/tests/e2e/llm_translation/test_responses_e2e.py +++ b/tests/e2e/llm_translation/test_responses_e2e.py @@ -183,6 +183,48 @@ class TestResponses: ) assert response.output_text.strip(), f"/responses returned no output text: {response.output!r}" + @pytest.mark.covers("llm.responses.openai.basic.nonstream.works") + @meta( + Subject( + domain=Domain.LLM_TRANSLATION, + route=Route.RESPONSES, + providers=(Provider.OPENAI,), + models=(OPENAI_RESPONSES_BACKEND,), + mode=Mode.NONSTREAM, + ) + ) + def test_responses_forwards_moderation( + self, proxy: ProxyClient, resources: ResourceManager, sdk: SdkClients + ) -> None: + model: Final = _register(proxy, resources, _openai_responses_params()) + client: Final = sdk.openai(resources.key()) + + response: Final = client.responses.create( + model=model, + input="hi", + extra_body={**NO_PROXY_CACHE, "moderation": {"model": "omni-moderation-latest"}}, + ) + response_body: Final = TypeAdapter(dict[str, object]).validate_python(cast(object, response.model_dump())) + moderation_value: Final = response_body.get("moderation") + assert moderation_value is not None, f"response had no moderation results: {response_body!r}" + moderation: Final = TypeAdapter(dict[str, object]).validate_python(moderation_value) + moderation_input: Final = TypeAdapter(dict[str, object]).validate_python(moderation["input"]) + moderation_output: Final = TypeAdapter(dict[str, object]).validate_python(moderation["output"]) + assert moderation_input.get("type") == "moderation_result", ( + f"unexpected input moderation: {moderation_input!r}" + ) + assert ( + moderation_output.get("type") == "moderation_result" + ), f"unexpected output moderation: {moderation_output!r}" + + with pytest.raises(openai.BadRequestError) as exc_info: + client.responses.create( + model=model, + input="hi", + extra_body={**NO_PROXY_CACHE, "moderation": {"model": "does-not-exist"}}, + ) + assert exc_info.value.status_code == 400 + @pytest.mark.covers("llm.responses.openai.basic.stream.works") @meta( Subject( diff --git a/tests/unit/llms/perplexity/responses/test_perplexity_responses_transformation.py b/tests/unit/llms/perplexity/responses/test_perplexity_responses_transformation.py index 534176e381a..b6d26aa1de6 100644 --- a/tests/unit/llms/perplexity/responses/test_perplexity_responses_transformation.py +++ b/tests/unit/llms/perplexity/responses/test_perplexity_responses_transformation.py @@ -8,10 +8,13 @@ Source: litellm/llms/perplexity/responses/transformation.py """ import json +from typing import Final import httpx import pytest +import respx +import litellm from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.base_llm.chat.transformation import BaseLLMException @@ -21,6 +24,17 @@ from litellm.types.utils import LlmProviders from litellm.utils import ProviderConfigManager +_SUCCESS_RESPONSE: Final[dict[str, object]] = { + "id": "resp_moderation_test", + "object": "response", + "created_at": 1700000000, + "status": "completed", + "model": "openai/gpt-5.2", + "output": [], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, +} + + class TestPerplexityResponsesTransformation: """Test Perplexity Responses API configuration and transformations""" @@ -60,9 +74,7 @@ class TestPerplexityResponsesTransformation: assert len(result["tools"]) == 1 assert result["tools"][0]["type"] == "function" assert result["tools"][0]["function"]["name"] == "get_weather" - assert ( - result["tools"][0]["function"]["description"] == "Get the current weather" - ) + assert result["tools"][0]["function"]["description"] == "Get the current weather" assert "parameters" in result["tools"][0]["function"] def test_web_search_tool_passthrough(self): @@ -130,9 +142,7 @@ class TestPerplexityResponsesTransformation: """tool_choice passes through""" config = PerplexityResponsesConfig() - params = ResponsesAPIOptionalRequestParams( - tool_choice="required", temperature=0.7 - ) + params = ResponsesAPIOptionalRequestParams(tool_choice="required", temperature=0.7) result = config.map_openai_params( response_api_optional_params=params, @@ -146,9 +156,7 @@ class TestPerplexityResponsesTransformation: """parallel_tool_calls passes through""" config = PerplexityResponsesConfig() - params = ResponsesAPIOptionalRequestParams( - parallel_tool_calls=True, temperature=0.7 - ) + params = ResponsesAPIOptionalRequestParams(parallel_tool_calls=True, temperature=0.7) result = config.map_openai_params( response_api_optional_params=params, @@ -492,9 +500,7 @@ class TestPerplexityResponsesTransformation: "id": "msg_123", "role": "assistant", "status": "completed", - "content": [ - {"type": "output_text", "text": "Hello!", "annotations": []} - ], + "content": [{"type": "output_text", "text": "Hello!", "annotations": []}], } ], "usage": { @@ -549,9 +555,7 @@ class TestPerplexityResponsesTransformation: "id": "msg_123", "role": "assistant", "status": "completed", - "content": [ - {"type": "output_text", "text": "Hello!", "annotations": []} - ], + "content": [{"type": "output_text", "text": "Hello!", "annotations": []}], } ], "usage": { @@ -587,3 +591,26 @@ class TestPerplexityResponsesTransformation: assert result.type == "response.completed" assert result.response.usage.cost == 0.0003 assert isinstance(result.response.usage.cost, float) + + +@pytest.mark.asyncio +async def test_aresponses_rejects_moderation_for_perplexity_without_drop_params( + monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(litellm, "drop_params", False) + litellm.in_memory_llm_clients_cache.flush_cache() + upstream: Final = respx_mock.post("https://api.perplexity.ai/v1/responses").mock( + return_value=httpx.Response(200, json=_SUCCESS_RESPONSE) + ) + + with pytest.raises(litellm.UnsupportedParamsError): + await litellm.aresponses( + model="perplexity/openai/gpt-5.2", + api_base="https://api.perplexity.ai", + api_key="fake-api-key", + input="hi", + moderation={"model": "omni-moderation-latest"}, + ) + + assert upstream.call_count == 0 diff --git a/tests/unit/responses/test_responses_api_request_body.py b/tests/unit/responses/test_responses_api_request_body.py index bf571c74e22..b926b824fc6 100644 --- a/tests/unit/responses/test_responses_api_request_body.py +++ b/tests/unit/responses/test_responses_api_request_body.py @@ -12,7 +12,7 @@ import math from collections.abc import Awaitable, Callable, Mapping from pathlib import Path from importlib import import_module -from typing import Final +from typing import Final, cast from unittest.mock import AsyncMock, patch import httpx @@ -254,6 +254,84 @@ async def test_aresponses_forwards_non_enum_reasoning_effort( assert response.output[0].content[0].text == "Done." +@pytest.mark.asyncio +async def test_aresponses_forwards_moderation( + monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key") + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + litellm.in_memory_llm_clients_cache.flush_cache() + upstream: Final = respx_mock.post("https://api.openai.com/v1/responses").mock( + return_value=httpx.Response(200, json=_minimal_responses_api_payload("resp_moderation", "gpt-5.4")) + ) + moderation: Final = {"model": "omni-moderation-latest"} + + await litellm.aresponses(model="openai/gpt-5.4", input="hi", moderation=moderation) + + request_body: Final = TypeAdapter(dict[str, object]).validate_python( + cast(object, json.loads(upstream.calls[0].request.read())) + ) + assert upstream.call_count == 1 + assert request_body.get("moderation") == moderation + + +@pytest.mark.asyncio +async def test_aresponses_omits_null_moderation( + monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key") + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + litellm.in_memory_llm_clients_cache.flush_cache() + upstream: Final = respx_mock.post("https://api.openai.com/v1/responses").mock( + return_value=httpx.Response(200, json=_minimal_responses_api_payload("resp_null_moderation", "gpt-5.4")) + ) + + await litellm.aresponses(model="openai/gpt-5.4", input="hi", moderation=None) + + request_body: Final = TypeAdapter(dict[str, object]).validate_python( + cast(object, json.loads(upstream.calls[0].request.read())) + ) + assert upstream.call_count == 1 + assert "moderation" not in request_body + + +@pytest.mark.asyncio +async def test_aresponses_stream_forwards_moderation(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key") + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + litellm.in_memory_llm_clients_cache.flush_cache() + received: Final[asyncio.Queue[httpx.Request]] = asyncio.Queue() + payload: Final = _minimal_responses_api_payload("resp_stream_moderation", "gpt-5.4") + moderation: Final = {"model": "omni-moderation-latest"} + + def respond(request: httpx.Request) -> httpx.Response: + received.put_nowait(request) + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + text="".join( + f"event: {kind}\ndata: {json.dumps({'type': kind, 'response': payload})}\n\n" + for kind in ("response.created", "response.completed") + ), + ) + + async_client: Final = AsyncHTTPHandler(transport=httpx.MockTransport(respond)) + try: + response: Final = await litellm.aresponses( + model="openai/gpt-5.4", input="hi", moderation=moderation, stream=True, client=async_client + ) + events: Final = tuple([event.type async for event in response]) + assert "response.completed" in events + finally: + await async_client.close() + + request_body: Final = TypeAdapter(dict[str, object]).validate_python( + cast(object, json.loads(received.get_nowait().content)) + ) + assert request_body.get("moderation") == moderation + assert received.empty() + + @pytest.mark.asyncio async def test_acompletion_with_tools_forwards_non_enum_reasoning_effort_over_the_bridge( monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 8af76e4d16f..76e3c95fb75 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -44163,6 +44163,11 @@ export interface components { */ type: "tool_search_output"; }; + /** ResponsesAPIModeration */ + ResponsesAPIModeration: { + /** Model */ + model: string; + }; /** * ResponsesAPIRequestParams * @description TypedDict for request parameters supported by the responses API. @@ -44188,6 +44193,7 @@ export interface components { } | null; /** Model */ model: string; + moderation?: components["schemas"]["ResponsesAPIModeration"] | null; /** Parallel Tool Calls */ parallel_tool_calls?: boolean | null; /** Partial Images */ @@ -66975,6 +66981,7 @@ export interface operations { } | null; /** Model */ model: string; + moderation?: components["schemas"]["ResponsesAPIModeration"] | null; /** Parallel Tool Calls */ parallel_tool_calls?: boolean | null; /** Partial Images */ @@ -70439,6 +70446,7 @@ export interface operations { } | null; /** Model */ model: string; + moderation?: components["schemas"]["ResponsesAPIModeration"] | null; /** Parallel Tool Calls */ parallel_tool_calls?: boolean | null; /** Partial Images */ @@ -81237,6 +81245,7 @@ export interface operations { } | null; /** Model */ model: string; + moderation?: components["schemas"]["ResponsesAPIModeration"] | null; /** Parallel Tool Calls */ parallel_tool_calls?: boolean | null; /** Partial Images */