diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index 8a963ec0134..e9a079d622b 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -137,6 +137,17 @@ def _get_spend_logs_metadata( clean_metadata["litellm_overhead_time_ms"] = litellm_overhead_time_ms clean_metadata["cost_breakdown"] = cost_breakdown + # Redact error_message when prompt/response storage is disabled. + # str(exception) for provider errors (e.g. BadRequestError) includes the raw + # response body (Received={...}), which violates the no-content-logging policy. + if not _should_store_prompts_and_responses_in_spend_logs(): + error_info = clean_metadata.get("error_information") + if isinstance(error_info, dict) and error_info.get("error_message"): + clean_metadata["error_information"] = { + **error_info, + "error_message": None, + } + return clean_metadata 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 30b952cd421..88902871372 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 @@ -1469,3 +1469,61 @@ class TestIsMasterKey: master = "sk-master-key-123" hashed = hash_token(master) assert _is_master_key(api_key=hashed, _master_key=master) is True + + +# --------------------------------------------------------------------------- +# error_message redaction in spend logs +# --------------------------------------------------------------------------- + +STORE_PROMPTS_PATH = "litellm.proxy.spend_tracking.spend_tracking_utils._should_store_prompts_and_responses_in_spend_logs" + + +def _failure_metadata_with_error(error_msg: str) -> dict: + return { + "user_api_key": "sk-test", + "status": "failure", + "error_information": { + "error_code": "400", + "error_class": "BadRequestError", + "llm_provider": "vertex_ai", + "traceback": "Traceback ...", + "error_message": error_msg, + }, + } + + +@patch(STORE_PROMPTS_PATH, return_value=False) +def test_error_message_redacted_when_store_prompts_disabled(mock_store): + """error_message must be None when store_prompts_in_spend_logs is False.""" + raw = 'litellm.BadRequestError: Received={"candidates": [{"content": {"role": "model", "parts": [{"text": "secret content"}]}}]}' + result = _get_spend_logs_metadata(_failure_metadata_with_error(raw)) + assert result["error_information"]["error_message"] is None + + +@patch(STORE_PROMPTS_PATH, return_value=True) +def test_error_message_kept_when_store_prompts_enabled(mock_store): + """error_message must be preserved when store_prompts_in_spend_logs is True.""" + raw = 'litellm.BadRequestError: Received={"candidates": [{"content": {"role": "model"}}]}' + result = _get_spend_logs_metadata(_failure_metadata_with_error(raw)) + assert result["error_information"]["error_message"] == raw + + +@patch(STORE_PROMPTS_PATH, return_value=False) +def test_other_error_fields_preserved_when_message_redacted(mock_store): + """error_code, error_class, llm_provider, and traceback must survive redaction.""" + raw = "error with content" + result = _get_spend_logs_metadata(_failure_metadata_with_error(raw)) + info = result["error_information"] + assert info["error_code"] == "400" + assert info["error_class"] == "BadRequestError" + assert info["llm_provider"] == "vertex_ai" + assert info["traceback"] == "Traceback ..." + assert info["error_message"] is None + + +@patch(STORE_PROMPTS_PATH, return_value=False) +def test_no_error_when_error_information_is_none(mock_store): + """No crash when error_information is absent (success path).""" + metadata = {"user_api_key": "sk-test", "status": "success"} + result = _get_spend_logs_metadata(metadata) + assert result.get("error_information") is None