[Feat] Add logging callback support for /moderations API (#10390)

* add OpenAIModerationResponse as a type

* Add typing for litellm.moderation response obj

* Add typing for litellm.moderation response obj

* test_moderations_api_logging

* add assertions for logged slp object

* test_moderations_api_logging

* refactor moderations api usage

* Revert "test_moderations_api_logging"

This reverts commit 5a9001f04c.

* Revert "refactor moderations api usage"

This reverts commit b076d3174c.

* test_moderations_api_logging
This commit is contained in:
Ishaan Jaff 2025-04-28 20:24:17 -07:00 • committed by GitHub
parent 25213f6087
commit 34cdefbb77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 147 additions and 12 deletions

View file

@ -58,6 +58,7 @@ from litellm.responses.utils import ResponseAPILoggingUtils
from litellm.types.llms.openai import (
HttpxBinaryResponseContent,
ImageGenerationRequestQuality,
OpenAIModerationResponse,
OpenAIRealtimeStreamList,
OpenAIRealtimeStreamResponseBaseObject,
OpenAIRealtimeStreamSessionEvents,
@ -643,9 +644,9 @@ def completion_cost( # noqa: PLR0915
or isinstance(completion_response, dict)
): # tts returns a custom class
if isinstance(completion_response, dict):
usage_obj: Optional[
Union[dict, Usage]
] = completion_response.get("usage", {})
usage_obj: Optional[Union[dict, Usage]] = (
completion_response.get("usage", {})
)
else:
usage_obj = getattr(completion_response, "usage", {})
if isinstance(usage_obj, BaseModel) and not _is_known_usage_objects(
@ -944,6 +945,7 @@ def response_cost_calculator(
RerankResponse,
ResponsesAPIResponse,
LiteLLMRealtimeStreamLoggingObject,
OpenAIModerationResponse,
],
model: str,
custom_llm_provider: Optional[str],

View file

@ -59,6 +59,7 @@ from litellm.types.llms.openai import (
FineTuningJob,
HttpxBinaryResponseContent,
OpenAIFileObject,
OpenAIModerationResponse,
ResponseCompletedEvent,
ResponsesAPIResponse,
)
@ -943,6 +944,7 @@ class Logging(LiteLLMLoggingBaseClass):
ResponseCompletedEvent,
OpenAIFileObject,
LiteLLMRealtimeStreamLoggingObject,
OpenAIModerationResponse,
],
cache_hit: Optional[bool] = None,
litellm_model_name: Optional[str] = None,
@ -1137,6 +1139,7 @@ class Logging(LiteLLMLoggingBaseClass):
or isinstance(logging_result, ResponsesAPIResponse)
or isinstance(logging_result, OpenAIFileObject)
or isinstance(logging_result, LiteLLMRealtimeStreamLoggingObject)
or isinstance(logging_result, OpenAIModerationResponse)
):
## HIDDEN PARAMS ##
hidden_params = getattr(logging_result, "_hidden_params", {})

View file

@ -10,7 +10,10 @@ import litellm
from litellm._logging import verbose_logger
from litellm.constants import RESPONSE_FORMAT_TOOL_NAME
from litellm.types.llms.databricks import DatabricksTool
from litellm.types.llms.openai import ChatCompletionThinkingBlock
from litellm.types.llms.openai import (
ChatCompletionThinkingBlock,
OpenAIModerationResponse,
)
from litellm.types.utils import (
ChatCompletionDeltaToolCall,
ChatCompletionMessageToolCall,
@ -297,6 +300,12 @@ class LiteLLMResponseObjectHandler:
model_response_object = ImageResponse(**model_response_dict)
return model_response_object
@staticmethod
def convert_to_moderation_response(
response_object: dict,
) -> OpenAIModerationResponse:
return OpenAIModerationResponse(**response_object)
@staticmethod
def convert_chat_to_text_completion(
response: ModelResponse,
@ -500,9 +509,9 @@ def convert_to_model_response_object( # noqa: PLR0915
provider_specific_fields["thinking_blocks"] = thinking_blocks
if reasoning_content:
provider_specific_fields[
"reasoning_content"
] = reasoning_content
provider_specific_fields["reasoning_content"] = (
reasoning_content
)
message = Message(
content=content,

View file

@ -183,6 +183,7 @@ from .types.llms.openai import (
ChatCompletionUserMessage,
HttpxBinaryResponseContent,
ImageGenerationRequestQuality,
OpenAIModerationResponse,
)
from .types.utils import (
LITELLM_IMAGE_VARIATION_PROVIDERS,
@ -4436,7 +4437,7 @@ def adapter_completion(
def moderation(
input: str, model: Optional[str] = None, api_key: Optional[str] = None, **kwargs
):
) -> OpenAIModerationResponse:
# only supports open ai for now
api_key = (
api_key
@ -4455,7 +4456,11 @@ def moderation(
response = openai_client.moderations.create(input=input, model=model)
else:
response = openai_client.moderations.create(input=input)
return response
response_dict: Dict = response.model_dump()
return litellm.utils.LiteLLMResponseObjectHandler.convert_to_moderation_response(
response_object=response_dict,
)
@client
@ -4465,7 +4470,7 @@ async def amoderation(
api_key: Optional[str] = None,
custom_llm_provider: Optional[str] = None,
**kwargs,
):
) -> OpenAIModerationResponse:
from openai import AsyncOpenAI
# only supports open ai for now
@ -4487,10 +4492,13 @@ async def amoderation(
_openai_client = openai_client
optional_params = GenericLiteLLMParams(**kwargs)
litellm_logging_obj: Optional[LiteLLMLoggingObj] = kwargs.get(
"litellm_logging_obj", None
)
try:
(
model,
_custom_llm_provider,
custom_llm_provider,
_dynamic_api_key,
_dynamic_api_base,
) = litellm.get_llm_provider(
@ -4502,11 +4510,28 @@ async def amoderation(
except litellm.BadRequestError:
# `model` is optional field for moderation - get_llm_provider will throw BadRequestError if model is not set / not recognized
pass
# update litellm_logging_obj with environment variables
custom_llm_provider = custom_llm_provider or litellm.LlmProviders.OPENAI.value
if litellm_logging_obj is not None:
litellm_logging_obj.update_environment_variables(
model=model,
user=kwargs.get("user", None),
optional_params={},
litellm_params={
**kwargs,
},
custom_llm_provider=custom_llm_provider,
)
if model is not None:
response = await _openai_client.moderations.create(input=input, model=model)
else:
response = await _openai_client.moderations.create(input=input)
return response
response_dict: Dict = response.model_dump()
return litellm.utils.LiteLLMResponseObjectHandler.convert_to_moderation_response(
response_object=response_dict,
)
##### Image Generation #######################

View file

@ -1268,3 +1268,21 @@ class ImageGenerationRequestQuality(str, Enum):
AUTO = "auto"
STANDARD = "standard"
HD = "hd"
class OpenAIModerationResponse(BaseLiteLLMOpenAIResponseObject):
"""
Response from the OpenAI Moderation API.
"""
id: str
"""The unique identifier for the moderation request."""
model: str
"""The model used to generate the moderation results."""
results: List[Dict]
"""A list of moderation objects."""
# Define private attributes using PrivateAttr
_hidden_params: dict = PrivateAttr(default_factory=dict)

View file

@ -0,0 +1,78 @@
import os
import sys
import traceback
import uuid
import pytest
from dotenv import load_dotenv
from fastapi import Request
from fastapi.routing import APIRoute
load_dotenv()
import io
import os
import time
import json
sys.path.insert(
0, os.path.abspath("../..")
) # Adds the parent directory to the system path
import litellm
import asyncio
from typing import Optional
from litellm.types.utils import StandardLoggingPayload, Usage, ModelInfoBase
from litellm.integrations.custom_logger import CustomLogger
class TestCustomLogger(CustomLogger):
def __init__(self):
self.recorded_usage: Optional[Usage] = None
self.standard_logging_payload: Optional[StandardLoggingPayload] = None
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
standard_logging_payload = kwargs.get("standard_logging_object")
self.standard_logging_payload = standard_logging_payload
print(
"standard_logging_payload",
json.dumps(standard_logging_payload, indent=4, default=str),
)
pass
@pytest.mark.asyncio
@pytest.mark.parametrize("model", [
None,
"omni-moderation-latest"
])
async def test_moderations_api_logging(model):
"""
When moderations API is called, it should log the event on standard_logging_payload
"""
custom_logger = TestCustomLogger()
litellm.logging_callback_manager.add_litellm_callback(custom_logger)
input_content = "Hello, how are you?"
response = await litellm.amoderation(
input=input_content,
model=model,
)
print("response", json.dumps(response, indent=4, default=str))
await asyncio.sleep(2)
assert custom_logger.standard_logging_payload is not None
# validate the standard_logging_payload
standard_logging_payload: StandardLoggingPayload = custom_logger.standard_logging_payload
assert standard_logging_payload["call_type"] == litellm.utils.CallTypes.amoderation.value
assert standard_logging_payload["status"] == "success"
assert standard_logging_payload["custom_llm_provider"] == litellm.LlmProviders.OPENAI.value
# assert the logged input == input
assert standard_logging_payload["messages"][0]["content"] == input_content
# assert the logged response == response user received client side
assert dict(standard_logging_payload["response"]) == response.model_dump()