From 2e007505da35db65b60cad4c69dd3d066c9d3a49 Mon Sep 17 00:00:00 2001 From: Alexsander Hamir Date: Tue, 18 Nov 2025 20:12:49 -0800 Subject: [PATCH] fix(spend-logs): trim logged response strings (#16654) * fix(spend-logs): trim logged response strings - route spend-log responses through the existing string sanitizer so oversized base64/text fields are truncated before persistence - add unit tests covering the truncation path and the feature flag Note: embeddings-specific truncation (numeric vectors) is still pending and will be handled separately. * remove unnecessary comment * add: sanitization unit test for embeddings * fix: simplify sanatization logic I overcomplicated a simple change for lack of understanding, fixed. --- .../spend_tracking/spend_tracking_utils.py | 15 +++- .../test_spend_tracking_utils.py | 69 ++++++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index 32d9c4b1f21..3f85de855d8 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -651,7 +651,20 @@ def _get_response_for_spend_logs_payload( if payload is None: return "{}" if _should_store_prompts_and_responses_in_spend_logs(): - return json.dumps(payload.get("response", {})) + response_obj: Any = payload.get("response") + if response_obj is None: + return "{}" + + sanitized_wrapper = _sanitize_request_body_for_spend_logs_payload( + {"response": response_obj} + ) + sanitized_response = sanitized_wrapper.get("response", response_obj) + + if sanitized_response is None: + return "{}" + if isinstance(sanitized_response, str): + return sanitized_response + return safe_dumps(sanitized_response) return "{}" 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 ab8709d818a..1c137c43ba9 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 @@ -4,7 +4,7 @@ import json import os import sys from datetime import timezone -from typing import Any +from typing import Any, cast import pytest from fastapi.testclient import TestClient @@ -19,10 +19,12 @@ import litellm from litellm.constants import LITELLM_TRUNCATED_PAYLOAD_FIELD, REDACTED_BY_LITELM_STRING from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.proxy.spend_tracking.spend_tracking_utils import ( + _get_response_for_spend_logs_payload, _get_vector_store_request_for_spend_logs_payload, _sanitize_request_body_for_spend_logs_payload, get_logging_payload, ) +from litellm.types.utils import StandardLoggingPayload def test_sanitize_request_body_for_spend_logs_payload_basic(): @@ -235,6 +237,71 @@ def test_get_vector_store_request_for_spend_logs_payload_null_input(mock_should_ assert result is None +@patch( + "litellm.proxy.spend_tracking.spend_tracking_utils._should_store_prompts_and_responses_in_spend_logs" +) +def test_get_response_for_spend_logs_payload_truncates_large_base64(mock_should_store): + from litellm.constants import MAX_STRING_LENGTH_PROMPT_IN_DB + + mock_should_store.return_value = True + large_text = "A" * (MAX_STRING_LENGTH_PROMPT_IN_DB + 500) + payload = cast( + StandardLoggingPayload, + { + "response": { + "data": [ + { + "b64_json": large_text, + "other_field": "value", + } + ] + } + }, + ) + + response_json = _get_response_for_spend_logs_payload(payload) + parsed = json.loads(response_json) + truncated_value = parsed["data"][0]["b64_json"] + assert len(truncated_value) < len(large_text) + assert LITELLM_TRUNCATED_PAYLOAD_FIELD in truncated_value + assert parsed["data"][0]["other_field"] == "value" + + +@patch( + "litellm.proxy.spend_tracking.spend_tracking_utils._should_store_prompts_and_responses_in_spend_logs" +) +def test_get_response_for_spend_logs_payload_truncates_large_embedding(mock_should_store): + from litellm.constants import MAX_STRING_LENGTH_PROMPT_IN_DB + + mock_should_store.return_value = True + embedding_values = [ + round(i * 0.0001, 6) for i in range(MAX_STRING_LENGTH_PROMPT_IN_DB + 500) + ] + large_embedding = json.dumps(embedding_values) + payload = cast( + StandardLoggingPayload, + { + "response": { + "data": [ + { + "embedding": large_embedding, + "other_field": "value", + } + ] + } + }, + ) + + response_json = _get_response_for_spend_logs_payload(payload) + parsed = json.loads(response_json) + truncated_value = parsed["data"][0]["embedding"] + + assert isinstance(truncated_value, str) + assert len(truncated_value) < len(large_embedding) + assert LITELLM_TRUNCATED_PAYLOAD_FIELD in truncated_value + assert parsed["data"][0]["other_field"] == "value" + + def test_safe_dumps_handles_circular_references(): """Test that safe_dumps can handle circular references without raising exceptions"""