diff --git a/docs/my-website/docs/proxy/logging.md b/docs/my-website/docs/proxy/logging.md index baf3d42234d..20fc6103883 100644 --- a/docs/my-website/docs/proxy/logging.md +++ b/docs/my-website/docs/proxy/logging.md @@ -1335,6 +1335,7 @@ litellm_settings: s3_aws_secret_access_key: os.environ/AWS_SECRET_ACCESS_KEY # AWS Secret Access Key for S3 s3_path: my-test-path # [OPTIONAL] set path in bucket you want to write logs to s3_endpoint_url: https://s3.amazonaws.com # [OPTIONAL] S3 endpoint URL, if you want to use Backblaze/cloudflare s3 buckets + s3_strip_base64_files: false # [OPTIONAL] remove base64 files before storing in s3 ``` **Step 3**: Start the proxy, make a test request diff --git a/litellm/integrations/s3_v2.py b/litellm/integrations/s3_v2.py index a65500c80dc..cc44450e737 100644 --- a/litellm/integrations/s3_v2.py +++ b/litellm/integrations/s3_v2.py @@ -49,6 +49,7 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): s3_batch_size: Optional[int] = DEFAULT_S3_BATCH_SIZE, s3_config=None, s3_use_team_prefix: bool = False, + s3_strip_base64_files: bool = False, **kwargs, ): try: @@ -80,6 +81,7 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): s3_config=s3_config, s3_path=s3_path, s3_use_team_prefix=s3_use_team_prefix, + s3_strip_base64_files=s3_strip_base64_files ) verbose_logger.debug(f"s3 logger using endpoint url {s3_endpoint_url}") @@ -124,6 +126,7 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): s3_config=None, s3_path: Optional[str] = None, s3_use_team_prefix: bool = False, + s3_strip_base64_files: bool = False, ): """ Initialize the s3 params for this logging callback @@ -194,6 +197,11 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): or s3_use_team_prefix ) + self.s3_strip_base64_files = ( + bool(litellm.s3_callback_params.get("s3_strip_base64_files", False)) + or s3_strip_base64_files + ) + return async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): @@ -364,6 +372,10 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): if standard_logging_payload is None: return None + if self.s3_strip_base64_files: + import asyncio + standard_logging_payload = asyncio.run(self._strip_base64_from_messages(standard_logging_payload)) + team_alias = standard_logging_payload["metadata"].get("user_api_key_team_alias") team_alias_prefix = "" diff --git a/litellm/integrations/sqs.py b/litellm/integrations/sqs.py index 545aebbec6d..b353c3670f3 100644 --- a/litellm/integrations/sqs.py +++ b/litellm/integrations/sqs.py @@ -256,6 +256,8 @@ class SQSLogger(CustomBatchLogger, BaseAWSLLM): standard_logging_payload = kwargs.get("standard_logging_object") if standard_logging_payload is None: raise ValueError("standard_logging_payload is None") + if self.sqs_strip_base64_files: + standard_logging_payload = await self._strip_base64_from_messages(standard_logging_payload) self.log_queue.append(standard_logging_payload) verbose_logger.debug( diff --git a/tests/test_litellm/integrations/test_s3_v2.py b/tests/test_litellm/integrations/test_s3_v2.py index d31d783ba0a..12ec8af1ef4 100644 --- a/tests/test_litellm/integrations/test_s3_v2.py +++ b/tests/test_litellm/integrations/test_s3_v2.py @@ -154,4 +154,145 @@ class TestS3V2UnitTests: expected_download_url = "https://download.s3.endpoint.com/download-bucket/2025-09-14/download-test-key.json" assert url_download == expected_download_url, f"Expected download URL {expected_download_url}, got {url_download}" - assert result == {"downloaded": "data"} \ No newline at end of file + assert result == {"downloaded": "data"} + +@pytest.mark.asyncio +async def test_strip_base64_removes_file_and_nontext_entries(): + logger = S3Logger(s3_strip_base64_files=True) + + payload = { + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Hello world"}, + {"type": "image", "file": {"file_data": "data:image/png;base64,AAAA"}}, + {"type": "file", "file": {"file_data": "data:application/pdf;base64,BBBB"}}, + ], + }, + { + "role": "assistant", + "content": [ + {"type": "text", "text": "Response"}, + {"type": "audio", "file": {"file_data": "data:audio/wav;base64,CCCC"}}, + ], + }, + ] + } + + stripped = await logger._strip_base64_from_messages(payload) + + # 1️⃣ File/image/audio entries are removed + assert len(stripped["messages"][0]["content"]) == 1 + assert stripped["messages"][0]["content"][0]["text"] == "Hello world" + + assert len(stripped["messages"][1]["content"]) == 1 + assert stripped["messages"][1]["content"][0]["text"] == "Response" + + # 2️⃣ No 'file' keys remain + for msg in stripped["messages"]: + for content in msg["content"]: + assert "file" not in content + assert content.get("type") == "text" + + +@pytest.mark.asyncio +async def test_strip_base64_keeps_non_file_content(): + logger = S3Logger(s3_strip_base64_files=True) + + payload = { + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Just text"}, + {"type": "text", "text": "Another message"}, + ], + } + ] + } + + stripped = await logger._strip_base64_from_messages(payload) + + # Should not modify pure text messages + assert stripped["messages"][0]["content"] == payload["messages"][0]["content"] + + +@pytest.mark.asyncio +async def test_strip_base64_handles_empty_or_missing_messages(): + logger = S3Logger(s3_strip_base64_files=True) + + # Missing messages key + payload_no_messages = {} + stripped1 = await logger._strip_base64_from_messages(payload_no_messages) + assert stripped1 == payload_no_messages + + # Empty messages list + payload_empty = {"messages": []} + stripped2 = await logger._strip_base64_from_messages(payload_empty) + assert stripped2 == payload_empty + + +@pytest.mark.asyncio +async def test_strip_base64_mixed_nested_objects(): + """ + Handles weird/nested content structures gracefully. + """ + logger = S3Logger(s3_strip_base64_files=True) + + payload = { + "messages": [ + { + "role": "system", + "content": [ + {"type": "text", "text": "Keep me"}, + {"type": "custom", "metadata": "ignore but non-text"}, + {"foo": "bar"}, + {"file": {"file_data": "data:application/pdf;base64,XXX"}}, + ], + "extra": {"trace_id": "123"}, + } + ] + } + + stripped = await logger._strip_base64_from_messages(payload) + + # Custom/non-text and file entries removed + content = stripped["messages"][0]["content"] + assert len(content) == 2 + assert {"type": "text", "text": "Keep me"} in content + assert {"foo": "bar"} in content + # Extra metadata preserved + assert stripped["messages"][0]["extra"]["trace_id"] == "123" + + +@pytest.mark.asyncio +async def test_strip_base64_recursive_redaction(): + logger = S3Logger(s3_strip_base64_files=True) + payload = { + "messages": [ + { + "content": [ + {"type": "text", "text": "normal text"}, + {"type": "text", "text": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg"}, + {"type": "text", "text": "Nested: {'data': 'data:application/pdf;base64,AAA...'}"}, + {"file": {"file_data": "data:application/pdf;base64,AAAA"}}, + {"metadata": {"preview": "data:audio/mp3;base64,AAAAA=="}}, + ] + } + ] + } + + result = await logger._strip_base64_from_messages(payload) + content = result["messages"][0]["content"] + + # Dropped file-type entries + assert not any("file" in c for c in content) + + # Base64 redacted globally + import json + for c in content: + if isinstance(c, dict): + s = json.dumps(c).lower() + # "[base64_redacted]" is fine, but raw base64 is not + assert "base64," not in s, f"Found real base64 blob in: {s}"