mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +00:00
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:
parent
9da926b90f
commit
12deb60e27
6 changed files with 191 additions and 20 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
9
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
9
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue