From f08a8b792749263ef27632deb43abc2870a4a61d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:50:45 +0000 Subject: [PATCH] fix(spend_logs): persist prompts in SpendLogs.messages for all call types --- .../spend_tracking/spend_tracking_utils.py | 52 ++++++++++---- .../logging_callback_tests/test_spend_logs.py | 3 + .../test_spend_tracking_utils.py | 67 +++++++++++++++++-- 3 files changed, 104 insertions(+), 18 deletions(-) diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index a6105b6dff9..1889ebb08ea 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 for ``LiteLLM_SpendLogs.messages``. + + Stored for every call type when ``store_prompts_in_spend_logs`` is on; the + input lives under ``messages`` in the standard logging payload regardless of + the API surface (chat completions, Responses API, realtime, ...). + """ + 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 ( + 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([{"role": "user", "content": REDACTED_BY_LITELM_STRING}]) + + sanitized_messages = _sanitize_request_body_for_spend_logs_payload({"messages": messages}).get("messages", messages) + try: + messages_json_str = safe_dumps(sanitized_messages) + 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..857e5114c19 100644 --- a/tests/logging_callback_tests/test_spend_logs.py +++ b/tests/logging_callback_tests/test_spend_logs.py @@ -385,6 +385,9 @@ def test_spend_logs_payload_with_prompts_enabled(monkeypatch): print("json payload: ", json.dumps(payload, indent=4, default=str)) # Verify messages and response are included in payload + assert json.loads(payload["messages"] or "{}") == [ + {"role": "user", "content": "Hello!"} + ] assert payload["response"] == json.dumps( {"role": "assistant", "content": "Hi there!"} ) 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..1c915e8b9f4 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,78 @@ 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_prompts_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: prompts were only persisted for realtime calls, so + LiteLLM_SpendLogs.messages stayed '{}' for /chat/completions and /responses. """ mock_should_store.return_value = True payload = cast( StandardLoggingPayload, { - "call_type": "acompletion", + "call_type": call_type, "messages": [{"role": "user", "content": "Hello"}], }, ) result = _get_messages_for_spend_logs_payload(payload) - assert result == "{}" + 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_strings(mock_should_store): + from litellm.constants import MAX_STRING_LENGTH_PROMPT_IN_DB + + mock_should_store.return_value = True + payload = cast( + StandardLoggingPayload, + { + "call_type": "acompletion", + "messages": [ + { + "role": "user", + "content": "A" * (MAX_STRING_LENGTH_PROMPT_IN_DB + 500), + } + ], + }, + ) + result = _get_messages_for_spend_logs_payload(payload) + assert LITELLM_TRUNCATED_PAYLOAD_FIELD in result + assert ( + len(json.loads(result)[0]["content"]) < MAX_STRING_LENGTH_PROMPT_IN_DB + 500 + ) + + +@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.""" + mock_should_store.return_value = True + payload = cast( + StandardLoggingPayload, + { + "call_type": "acompletion", + "messages": [{"role": "user", "content": "my secret prompt"}], + }, + ) + result = _get_messages_for_spend_logs_payload( + payload, + kwargs={ + "litellm_params": {"metadata": {}}, + "standard_callback_dynamic_params": {"turn_off_message_logging": True}, + }, + ) + assert "my secret prompt" not in result + assert json.loads(result) == [ + {"role": "user", "content": REDACTED_BY_LITELM_STRING} + ] @patch(