fix(spend_logs): store request prompt in LiteLLM_SpendLogs.messages for all call types

Resolves #34747
This commit is contained in:
Devin AI 2026-07-27 15:44:57 +00:00
parent 24123269cc
commit 322f755676
4 changed files with 117 additions and 21 deletions

View file

@ -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)

View file

@ -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"})

View file

@ -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!"}]

View file

@ -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(