diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index ec3f57d3bed..a55be627eb4 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -44,11 +44,11 @@ from litellm.litellm_core_utils.litellm_logging import ( ) from litellm.litellm_core_utils.ptu_pricing import azure_spillover from litellm.litellm_core_utils.safe_json_dumps import safe_dumps, strip_null_bytes +from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker from litellm.proxy._types import SpendLogsMetadata, SpendLogsPayload, SpendLogsRouterMetadata from litellm.proxy.route_llm_request import ProxyModelNotFoundError from litellm.proxy.spend_tracking.spend_log_error_logger import spend_log_error from litellm.proxy.utils import PrismaClient, hash_token -from litellm.types.llms.bedrock import AWS_CREDENTIAL_VALUE_PARAM_KEYS from litellm.types.router import DeploymentTypedDict, LiteLLM_Params from litellm.types.utils import ( PROMPT_CARRYING_GUARDRAIL_FIELDS, @@ -61,6 +61,7 @@ from litellm.types.utils import ( StandardLoggingModelInformation, StandardLoggingPayload, StandardLoggingPayloadErrorInformation, + StandardLoggingUserAPIKeyMetadata, StandardLoggingVectorStoreRequest, VectorStoreSearchResponse, ) @@ -1084,20 +1085,32 @@ def _get_messages_for_spend_logs_payload( _SENSITIVE_REQUEST_BODY_KEYS: Final = frozenset({"secret_fields"}) +_REQUEST_BODY_CREDENTIAL_MASKER: Final = SensitiveDataMasker(extra_sensitive_patterns=frozenset({"apikey"})) +_PROXY_KEY_IDENTITY_FIELDS: Final = frozenset(StandardLoggingUserAPIKeyMetadata.__annotations__) | {"user_api_key"} + + +def _is_request_body_credential(key: str, value: object) -> bool: + return ( + isinstance(value, str) + and key not in _PROXY_KEY_IDENTITY_FIELDS + and _REQUEST_BODY_CREDENTIAL_MASKER.is_sensitive_key(key) + ) def _sanitize_request_body_for_spend_logs_payload( request_body: Mapping[str, object], visited: set | None = None, max_string_length_prompt_in_db: int | None = None, + redact_credentials: bool = False, ) -> dict: """ Recursively sanitize request body to prevent logging large base64 strings or other large values. Truncates strings longer than MAX_STRING_LENGTH_PROMPT_IN_DB characters and handles nested dictionaries. At every nesting level, also strips keys listed in _SENSITIVE_REQUEST_BODY_KEYS (e.g. secret_fields, - which holds raw HTTP headers including Authorization tokens) and masks string values of AWS - credential keys with REDACTED_BY_LITELM_STRING. + which holds raw HTTP headers including Authorization tokens). With ``redact_credentials``, string + values under keys SensitiveDataMasker classifies as credentials are replaced with + REDACTED_BY_LITELM_STRING. """ from litellm.constants import ( LITELLM_TRUNCATED_PAYLOAD_FIELD, @@ -1117,7 +1130,9 @@ def _sanitize_request_body_for_spend_logs_payload( def _sanitize_value(value: object) -> object: if isinstance(value, Mapping): - return _sanitize_request_body_for_spend_logs_payload(value, visited, max_string_length_prompt_in_db) + return _sanitize_request_body_for_spend_logs_payload( + value, visited, max_string_length_prompt_in_db, redact_credentials + ) elif isinstance(value, list): return [_sanitize_value(item) for item in value] elif isinstance(value, str): @@ -1155,9 +1170,7 @@ def _sanitize_request_body_for_spend_logs_payload( return value return { - k: REDACTED_BY_LITELM_STRING - if k in AWS_CREDENTIAL_VALUE_PARAM_KEYS and isinstance(v, str) - else _sanitize_value(v) + k: REDACTED_BY_LITELM_STRING if redact_credentials and _is_request_body_credential(k, v) else _sanitize_value(v) for k, v in request_body.items() if k not in _SENSITIVE_REQUEST_BODY_KEYS } @@ -1575,7 +1588,7 @@ def _get_proxy_server_request_for_spend_logs_payload( _request_body = _convert_mapping_to_json_serializable(without_classifier_audit(_request_body)) perform_redaction(model_call_details=_request_body, result=None) - _request_body = _sanitize_request_body_for_spend_logs_payload(_request_body) + _request_body = _sanitize_request_body_for_spend_logs_payload(_request_body, redact_credentials=True) _request_body_json_str: Final = safe_dumps(_request_body) if LITELLM_TRUNCATED_PAYLOAD_FIELD in _request_body_json_str: verbose_proxy_logger.info( 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 204cc427829..14f26036e67 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 @@ -2727,6 +2727,69 @@ def test_proxy_server_request_payload_strips_nested_aws_credentials(mock_should_ assert parsed["messages"] == [{"role": "user", "content": "hello"}] +@patch("litellm.proxy.spend_tracking.spend_tracking_utils.should_store_prompts_and_responses_in_spend_logs") +def test_proxy_server_request_payload_redacts_provider_credentials(mock_should_store: MagicMock) -> None: + mock_should_store.return_value = True + credentials: Final = { + "azure_password": "canary-azure-password", + "client_secret": "canary-client-secret", + "azure_ad_token": "canary-azure-ad-token", + "vertex_credentials": "canary-vertex-credentials", + "s3_secret_access_key": "canary-s3-secret", + "token": "canary-watsonx-token", + "apikey": "canary-watsonx-apikey", + "zen_api_key": "canary-zen-api-key", + "gemini_api_key": "canary-gemini-api-key", + "gigachat_access_token": "canary-gigachat-token", + "oci_key": "canary-oci-key", + } + identity_metadata: Final = { + "user_api_key": "hashed-key", + "user_api_key_alias": "team-a-key", + "user_api_key_team_id": "team-a", + "user_api_key_user_id": "user-a", + } + tool_parameters: Final = {"type": "object", "properties": {"client_secret": {"type": "string"}}} + litellm_params: Final = { + "proxy_server_request": { + "body": { + "model": "azure-gpt", + "messages": [{"role": "user", "content": "hello"}], + "max_tokens": 10, + "vertex_credentials": {"private_key": "canary-private-key", "client_email": "sa@example.com"}, + "extra_headers": {"Authorization": "Bearer canary-extra-header"}, + "tools": [ + {"type": "function", "function": {"name": "f", "parameters": tool_parameters}}, + {"type": "mcp", "server_url": "https://mcp.example.com", "headers": {"Authorization": "canary-mcp"}}, + ], + "fallbacks": [{"model": "azure-b", **credentials}], + "metadata": identity_metadata, + **credentials, + } + } + } + + parsed: Final = json.loads( + _get_proxy_server_request_for_spend_logs_payload(metadata={}, litellm_params=litellm_params, kwargs={}) + ) + + assert "canary" not in json.dumps(parsed) + assert {name: parsed[name] for name in credentials} == dict.fromkeys(credentials, REDACTED_BY_LITELM_STRING) + assert parsed["vertex_credentials"] == REDACTED_BY_LITELM_STRING + assert parsed["extra_headers"] == {"Authorization": REDACTED_BY_LITELM_STRING} + assert parsed["tools"][0]["function"]["parameters"] == tool_parameters + assert parsed["tools"][1]["server_url"] == "https://mcp.example.com" + assert parsed["metadata"] == identity_metadata + assert parsed["max_tokens"] == 10 + assert parsed["messages"] == [{"role": "user", "content": "hello"}] + + +def test_sanitize_request_body_keeps_credential_named_fields_by_default() -> None: + response: Final = {"system_fingerprint": "fp_123", "usage": {"prompt_tokens": 1}} + + assert _sanitize_request_body_for_spend_logs_payload({"response": response}) == {"response": response} + + @patch("litellm.proxy.spend_tracking.spend_tracking_utils.should_store_prompts_and_responses_in_spend_logs") def test_proxy_server_request_payload_excludes_secret_fields(mock_should_store): """