fix(responses): forward top-level moderation param to OpenAI (#48708)

* test: cover Responses moderation forwarding

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(responses): forward moderation parameter

Co-authored-by: Poppy Huang <ruihong_huang@foxmail.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test: remove Perplexity drop_params moderation case

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(responses): preserve typed moderation forwarding

Co-authored-by: Poppy Huang <ruihong_huang@foxmail.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Poppy Huang <ruihong_huang@foxmail.com>
This commit is contained in:
moe-berri 2026-10-28 10:33:11 -07:00 • committed by GitHub
parent 9da926b90f
commit 12deb60e27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 191 additions and 20 deletions

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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

View file

@ -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

View file

@ -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 */