diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 593cdb2850a..1320d49123d 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -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], diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 441392a1192..3f8c0c65e77 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -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", {}) diff --git a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py index 5a8319a7471..e412e3f35f1 100644 --- a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py +++ b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py @@ -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, diff --git a/litellm/main.py b/litellm/main.py index ff21824fac5..ec00e1a3491 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -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 ####################### diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index be5f7585bfd..912f8be2cbd 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -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) diff --git a/tests/logging_callback_tests/test_moderations_api_logging.py b/tests/logging_callback_tests/test_moderations_api_logging.py new file mode 100644 index 00000000000..129e802763b --- /dev/null +++ b/tests/logging_callback_tests/test_moderations_api_logging.py @@ -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() +