From 322f7556768d34d4e53e8f20614f77b711127b4a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:44:57 +0000 Subject: [PATCH] fix(spend_logs): store request prompt in LiteLLM_SpendLogs.messages for all call types Resolves #34747 --- litellm/litellm_core_utils/redact_messages.py | 7 +- .../spend_tracking/spend_tracking_utils.py | 52 +++++++++---- .../logging_callback_tests/test_spend_logs.py | 3 + .../test_spend_tracking_utils.py | 76 +++++++++++++++++-- 4 files changed, 117 insertions(+), 21 deletions(-) diff --git a/litellm/litellm_core_utils/redact_messages.py b/litellm/litellm_core_utils/redact_messages.py index 43181e7f5ff..7f53f920933 100644 --- a/litellm/litellm_core_utils/redact_messages.py +++ b/litellm/litellm_core_utils/redact_messages.py @@ -10,7 +10,7 @@ import asyncio import copy import inspect -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, Final, Optional import litellm from litellm.integrations.custom_logger import CustomLogger @@ -34,6 +34,9 @@ else: LiteLLMLoggingObject = Any +REDACTED_MESSAGES_PLACEHOLDER: Final[tuple[dict[str, str], ...]] = ({"role": "user", "content": "redacted-by-litellm"},) + + def redact_message_input_output_from_custom_logger( litellm_logging_obj: LiteLLMLoggingObject, result, custom_logger: CustomLogger ): @@ -235,7 +238,7 @@ def perform_redaction(model_call_details: dict, result, redact_streaming_respons copy via redact_streaming_responses_for_custom_logger instead. """ # Redact model_call_details - model_call_details["messages"] = [{"role": "user", "content": "redacted-by-litellm"}] + model_call_details["messages"] = list(REDACTED_MESSAGES_PLACEHOLDER) model_call_details["prompt"] = "" model_call_details["input"] = "" _redact_standard_logging_object(model_call_details) diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index a6105b6dff9..08587d8cf49 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -441,7 +441,7 @@ def get_logging_payload(kwargs, response_obj, start_time, end_time) -> SpendLogs requester_ip_address=clean_metadata.get("requester_ip_address", None), custom_llm_provider=kwargs.get("custom_llm_provider", ""), messages=_get_messages_for_spend_logs_payload( - standard_logging_payload=standard_logging_payload, metadata=metadata + standard_logging_payload=standard_logging_payload, kwargs=kwargs ), response=_get_response_for_spend_logs_payload(payload=standard_logging_payload, kwargs=kwargs), proxy_server_request=_get_proxy_server_request_for_spend_logs_payload( @@ -655,19 +655,45 @@ async def get_spend_by_team_and_customer( def _get_messages_for_spend_logs_payload( standard_logging_payload: Optional[StandardLoggingPayload], - metadata: Optional[dict] = None, + kwargs: Optional[dict] = None, ) -> str: - if _should_store_prompts_and_responses_in_spend_logs(): - if standard_logging_payload is not None: - call_type = standard_logging_payload.get("call_type", "") - if call_type == "_arealtime": - messages = standard_logging_payload.get("messages") - if messages is not None: - try: - return safe_dumps(messages) - except Exception: - return "{}" - return "{}" + """ + Serialize the request prompt into ``LiteLLM_SpendLogs.messages``. + + Only stored when ``store_prompts_in_spend_logs`` is enabled, and replaced + with a placeholder when ``turn_off_message_logging`` redaction applies. + """ + if standard_logging_payload is None or not _should_store_prompts_and_responses_in_spend_logs(): + return "{}" + + messages: Any = standard_logging_payload.get("messages") + if messages is None: + return "{}" + + if kwargs is not None: + from litellm.litellm_core_utils.redact_messages import ( + REDACTED_MESSAGES_PLACEHOLDER, + should_redact_message_logging, + ) + + model_call_details = { + "litellm_params": kwargs.get("litellm_params", {}), + "standard_callback_dynamic_params": kwargs.get("standard_callback_dynamic_params"), + } + if should_redact_message_logging(model_call_details=model_call_details): + return safe_dumps(list(REDACTED_MESSAGES_PLACEHOLDER)) + + try: + sanitized = _sanitize_request_body_for_spend_logs_payload({"messages": messages}).get("messages") + messages_json_str = safe_dumps(sanitized) + except Exception: + return "{}" + if LITELLM_TRUNCATED_PAYLOAD_FIELD in messages_json_str: + verbose_proxy_logger.info( + "Spend Log: messages were truncated before storing in DB. %s", + LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE, + ) + return messages_json_str _SENSITIVE_REQUEST_BODY_KEYS = frozenset({"secret_fields"}) diff --git a/tests/logging_callback_tests/test_spend_logs.py b/tests/logging_callback_tests/test_spend_logs.py index f9c4db7c6d5..4126d6036d7 100644 --- a/tests/logging_callback_tests/test_spend_logs.py +++ b/tests/logging_callback_tests/test_spend_logs.py @@ -388,6 +388,9 @@ def test_spend_logs_payload_with_prompts_enabled(monkeypatch): assert payload["response"] == json.dumps( {"role": "assistant", "content": "Hi there!"} ) + assert json.loads(payload["messages"] or "{}") == [ + {"role": "user", "content": "Hello!"} + ] proxy_server_request = json.loads(payload["proxy_server_request"] or "{}") assert proxy_server_request["model"] == "gpt-5.5" assert proxy_server_request["messages"] == [{"role": "user", "content": "Hello!"}] diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py index cc1e2943c8f..8ff46befcf2 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py @@ -544,21 +544,85 @@ def test_get_messages_for_spend_logs_realtime_empty_when_disabled(mock_should_st @patch( "litellm.proxy.spend_tracking.spend_tracking_utils._should_store_prompts_and_responses_in_spend_logs" ) -def test_get_messages_for_spend_logs_non_realtime_returns_empty(mock_should_store): +@pytest.mark.parametrize("call_type", ["acompletion", "aresponses"]) +def test_get_messages_for_spend_logs_stores_prompt_for_all_call_types( + mock_should_store, call_type +): """ - Test that _get_messages_for_spend_logs_payload returns '{}' for non-realtime - calls even when store_prompts_in_spend_logs is True. + Regression for #34747: with store_prompts_in_spend_logs enabled, + LiteLLM_SpendLogs.messages must hold the request prompt for regular + /chat/completions and /responses traffic, not just realtime calls. + """ + mock_should_store.return_value = True + payload = cast( + StandardLoggingPayload, + { + "call_type": call_type, + "messages": [{"role": "user", "content": "Hello"}], + }, + ) + result = _get_messages_for_spend_logs_payload(payload) + assert json.loads(result) == [{"role": "user", "content": "Hello"}] + + +@patch( + "litellm.proxy.spend_tracking.spend_tracking_utils._should_store_prompts_and_responses_in_spend_logs" +) +def test_get_messages_for_spend_logs_truncates_large_prompt(mock_should_store): + """ + Oversized prompts must be capped by the same DB-storage safeguard used for + the request body and response so a single row can't blow up. + """ + from litellm.constants import ( + LITELLM_TRUNCATED_PAYLOAD_FIELD, + MAX_STRING_LENGTH_PROMPT_IN_DB, + ) + + mock_should_store.return_value = True + large_content = "A" * (MAX_STRING_LENGTH_PROMPT_IN_DB + 500) + payload = cast( + StandardLoggingPayload, + { + "call_type": "acompletion", + "messages": [{"role": "user", "content": large_content}], + }, + ) + + result = _get_messages_for_spend_logs_payload(payload) + + assert LITELLM_TRUNCATED_PAYLOAD_FIELD in result + assert len(json.loads(result)[0]["content"]) < len(large_content) + + +@patch( + "litellm.proxy.spend_tracking.spend_tracking_utils._should_store_prompts_and_responses_in_spend_logs" +) +def test_get_messages_for_spend_logs_redacts_when_message_logging_off( + mock_should_store, +): + """ + turn_off_message_logging must win over store_prompts_in_spend_logs, matching + how the response and proxy_server_request columns behave. """ mock_should_store.return_value = True payload = cast( StandardLoggingPayload, { "call_type": "acompletion", - "messages": [{"role": "user", "content": "Hello"}], + "messages": [{"role": "user", "content": "secret prompt"}], }, ) - result = _get_messages_for_spend_logs_payload(payload) - assert result == "{}" + + result = _get_messages_for_spend_logs_payload( + payload, + kwargs={ + "litellm_params": {}, + "standard_callback_dynamic_params": {"turn_off_message_logging": True}, + }, + ) + + assert "secret prompt" not in result + assert json.loads(result) == [{"role": "user", "content": "redacted-by-litellm"}] @patch(