diff --git a/litellm/integrations/s3.py b/litellm/integrations/s3.py index 2e70b1d6519..535cc5cbab1 100644 --- a/litellm/integrations/s3.py +++ b/litellm/integrations/s3.py @@ -62,6 +62,11 @@ class S3Logger: s3_use_team_prefix = bool( litellm.s3_callback_params.get("s3_use_team_prefix", False) ) + self.s3_log_prompts_only = bool( + litellm.s3_callback_params.get("s3_log_prompts_only", False) + ) + else: + self.s3_log_prompts_only = False self.s3_use_team_prefix = s3_use_team_prefix self.bucket_name = s3_bucket_name self.s3_path = s3_path @@ -156,7 +161,13 @@ class S3Logger: from litellm.litellm_core_utils.safe_json_dumps import safe_dumps - payload_str = safe_dumps(payload) + payload_dict = dict(payload) + if self.s3_log_prompts_only: + # Store only prompt content when prompts-only logging is enabled. + payload_dict = { + "messages": payload_dict.get("messages") or [], + } + payload_str = safe_dumps(payload_dict) print_verbose(f"\ns3 Logger - Logging payload = {payload_str}") diff --git a/litellm/integrations/s3_v2.py b/litellm/integrations/s3_v2.py index 534b85e4752..e4e3c64dd88 100644 --- a/litellm/integrations/s3_v2.py +++ b/litellm/integrations/s3_v2.py @@ -51,6 +51,7 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): s3_use_team_prefix: bool = False, s3_strip_base64_files: bool = False, s3_use_key_prefix: bool = False, + s3_log_prompts_only: bool = False, **kwargs, ): try: @@ -78,7 +79,8 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): s3_path=s3_path, s3_use_team_prefix=s3_use_team_prefix, s3_strip_base64_files=s3_strip_base64_files, - s3_use_key_prefix=s3_use_key_prefix + s3_use_key_prefix=s3_use_key_prefix, + s3_log_prompts_only=s3_log_prompts_only, ) verbose_logger.debug(f"s3 logger using endpoint url {s3_endpoint_url}") @@ -89,7 +91,7 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): ) self.async_httpx_client = get_async_httpx_client( llm_provider=httpxSpecialProvider.LoggingCallback, - params={"ssl_verify": self.s3_verify} + params={"ssl_verify": self.s3_verify}, ) asyncio.create_task(self.periodic_flush()) @@ -135,6 +137,7 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): s3_use_team_prefix: bool = False, s3_strip_base64_files: bool = False, s3_use_key_prefix: bool = False, + s3_log_prompts_only: bool = False, ): """ Initialize the s3 params for this logging callback @@ -155,10 +158,14 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): litellm.s3_callback_params.get("s3_api_version") or s3_api_version ) self.s3_use_ssl = ( - litellm.s3_callback_params.get("s3_use_ssl", True) if litellm.s3_callback_params.get("s3_use_ssl") is not None else s3_use_ssl + litellm.s3_callback_params.get("s3_use_ssl", True) + if litellm.s3_callback_params.get("s3_use_ssl") is not None + else s3_use_ssl ) self.s3_verify = ( - litellm.s3_callback_params.get("s3_verify") if litellm.s3_callback_params.get("s3_verify") is not None else s3_verify + litellm.s3_callback_params.get("s3_verify") + if litellm.s3_callback_params.get("s3_verify") is not None + else s3_verify ) self.s3_endpoint_url = ( litellm.s3_callback_params.get("s3_endpoint_url") or s3_endpoint_url @@ -208,8 +215,8 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): ) self.s3_use_key_prefix = ( - bool(litellm.s3_callback_params.get("s3_use_key_prefix", False)) - or s3_use_key_prefix + bool(litellm.s3_callback_params.get("s3_use_key_prefix", False)) + or s3_use_key_prefix ) self.s3_strip_base64_files = ( @@ -217,6 +224,11 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): or s3_strip_base64_files ) + self.s3_log_prompts_only = ( + bool(litellm.s3_callback_params.get("s3_log_prompts_only", False)) + or s3_log_prompts_only + ) + return async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): @@ -294,9 +306,7 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): verbose_logger.debug( f"s3_v2 logger - uploading data to s3 - {batch_logging_element.s3_object_key}" ) - verbose_logger.debug( - f"s3_v2 logger - s3_verify setting: {self.s3_verify}" - ) + verbose_logger.debug(f"s3_v2 logger - s3_verify setting: {self.s3_verify}") # Prepare the URL url = f"https://{self.s3_bucket_name}.s3.{self.s3_region_name}.amazonaws.com/{batch_logging_element.s3_object_key}" @@ -392,20 +402,25 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): return None if self.s3_strip_base64_files: - standard_logging_payload = self._strip_base64_from_messages_sync(standard_logging_payload) + standard_logging_payload = self._strip_base64_from_messages_sync( + standard_logging_payload + ) # Base prefix (default empty) prefix_components = [] if self.s3_use_team_prefix: - team_alias = standard_logging_payload.get("metadata", {}).get("user_api_key_team_alias", None) + team_alias = standard_logging_payload.get("metadata", {}).get( + "user_api_key_team_alias", None + ) if team_alias: prefix_components.append(team_alias) if self.s3_use_key_prefix: - user_api_key_alias = standard_logging_payload.get("metadata", {}).get("user_api_key_alias", None) + user_api_key_alias = standard_logging_payload.get("metadata", {}).get( + "user_api_key_alias", None + ) if user_api_key_alias: prefix_components.append(user_api_key_alias) - # Construct full prefix path prefix_path = "/".join(prefix_components) if prefix_path: @@ -414,7 +429,9 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): s3_file_name = ( litellm.utils.get_logging_id(start_time, standard_logging_payload) or "" ) - verbose_logger.debug(f"Creating s3 file with prefix_components={prefix_components},prefix_path={prefix_path} and {s3_file_name}") + verbose_logger.debug( + f"Creating s3 file with prefix_components={prefix_components},prefix_path={prefix_path} and {s3_file_name}" + ) s3_object_key = get_s3_object_key( s3_path=cast(Optional[str], self.s3_path) or "", prefix=prefix_path, @@ -425,8 +442,15 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): s3_object_download_filename = f"time-{start_time.strftime('%Y-%m-%dT%H-%M-%S-%f')}_{standard_logging_payload['id']}.json" + payload_dict = dict(standard_logging_payload) + if self.s3_log_prompts_only: + # Store only prompt content when prompts-only logging is enabled. + payload_dict = { + "messages": payload_dict.get("messages") or [], + } + return s3BatchLoggingElement( - payload=dict(standard_logging_payload), + payload=payload_dict, s3_object_key=s3_object_key, s3_object_download_filename=s3_object_download_filename, ) @@ -497,7 +521,9 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): signed_headers = dict(aws_request.headers.items()) httpx_client = _get_httpx_client( - params={"ssl_verify": self.s3_verify} if self.s3_verify is not None else None + params={"ssl_verify": self.s3_verify} + if self.s3_verify is not None + else None ) # Make the request response = httpx_client.put(url, data=json_string, headers=signed_headers) diff --git a/tests/test_litellm/integrations/test_s3.py b/tests/test_litellm/integrations/test_s3.py new file mode 100644 index 00000000000..bb4d4b48437 --- /dev/null +++ b/tests/test_litellm/integrations/test_s3.py @@ -0,0 +1,47 @@ +import json +from datetime import datetime +from unittest.mock import MagicMock, patch + +import litellm + +from litellm.integrations.s3 import S3Logger + + +def test_s3_prompts_only_payload(): + mock_client = MagicMock() + mock_client.put_object.return_value = {"ResponseMetadata": {"HTTPStatusCode": 200}} + + with patch("boto3.client", return_value=mock_client): + litellm.s3_callback_params = { + "s3_bucket_name": "test-bucket", + "s3_region_name": "us-east-1", + "s3_log_prompts_only": True, + } + + logger = S3Logger() + + kwargs = { + "standard_logging_object": { + "id": "test-id", + "metadata": {}, + "messages": [{"role": "user", "content": "hello"}], + "response": {"id": "resp"}, + } + } + + logger.log_event( + kwargs=kwargs, + response_obj={}, + start_time=datetime(2026, 2, 10, 15, 0, 0), + end_time=datetime(2026, 2, 10, 15, 0, 1), + print_verbose=lambda *args, **kwargs: None, + ) + + call_args = mock_client.put_object.call_args + assert call_args is not None + payload_str = call_args.kwargs["Body"] + payload = json.loads(payload_str) + + assert payload == {"messages": [{"role": "user", "content": "hello"}]} + + litellm.s3_callback_params = None diff --git a/tests/test_litellm/integrations/test_s3_v2.py b/tests/test_litellm/integrations/test_s3_v2.py index 0a3523699a9..33c5c6ce756 100644 --- a/tests/test_litellm/integrations/test_s3_v2.py +++ b/tests/test_litellm/integrations/test_s3_v2.py @@ -25,11 +25,13 @@ class TestS3V2UnitTests: "json.dumps(" not in source_code ), "S3 v2 should not use json.dumps directly" - @patch('asyncio.create_task') - @patch('litellm.integrations.s3_v2.CustomBatchLogger.periodic_flush') - def test_s3_v2_endpoint_url(self, mock_periodic_flush, mock_create_task): + @patch("asyncio.create_task") + @patch("litellm.integrations.s3_v2.CustomBatchLogger.periodic_flush") + def test_s3_v2_endpoint_url( # noqa: PLR0915 + self, mock_periodic_flush, mock_create_task + ): """testing s3 endpoint url""" - from unittest.mock import AsyncMock, MagicMock + from unittest.mock import AsyncMock from litellm.types.integrations.s3_v2 import s3BatchLoggingElement @@ -46,7 +48,7 @@ class TestS3V2UnitTests: test_element = s3BatchLoggingElement( s3_object_key="2025-09-14/test-key.json", payload={"test": "data"}, - s3_object_download_filename="test-file.json" + s3_object_download_filename="test-file.json", ) # Test 1: Custom endpoint URL with bucket name @@ -55,7 +57,7 @@ class TestS3V2UnitTests: s3_endpoint_url="https://s3.amazonaws.com", s3_aws_access_key_id="test-key", s3_aws_secret_access_key="test-secret", - s3_region_name="us-east-1" + s3_region_name="us-east-1", ) s3_logger.async_httpx_client = AsyncMock() @@ -75,7 +77,7 @@ class TestS3V2UnitTests: s3_endpoint_url="https://minio.example.com:9000", s3_aws_access_key_id="minio-key", s3_aws_secret_access_key="minio-secret", - s3_region_name="us-east-1" + s3_region_name="us-east-1", ) s3_logger_minio.async_httpx_client = AsyncMock() @@ -86,15 +88,19 @@ class TestS3V2UnitTests: call_args_minio = s3_logger_minio.async_httpx_client.put.call_args assert call_args_minio is not None url_minio = call_args_minio[0][0] - expected_minio_url = "https://minio.example.com:9000/litellm-logs/2025-09-14/test-key.json" - assert url_minio == expected_minio_url, f"Expected MinIO URL {expected_minio_url}, got {url_minio}" + expected_minio_url = ( + "https://minio.example.com:9000/litellm-logs/2025-09-14/test-key.json" + ) + assert ( + url_minio == expected_minio_url + ), f"Expected MinIO URL {expected_minio_url}, got {url_minio}" # Test 3: Custom endpoint without bucket name (should fall back to default) s3_logger_no_bucket = S3Logger( s3_endpoint_url="https://s3.amazonaws.com", s3_aws_access_key_id="test-key", s3_aws_secret_access_key="test-secret", - s3_region_name="us-east-1" + s3_region_name="us-east-1", ) s3_logger_no_bucket.async_httpx_client = AsyncMock() @@ -117,20 +123,27 @@ class TestS3V2UnitTests: s3_endpoint_url="https://custom.s3.endpoint.com", s3_aws_access_key_id="sync-key", s3_aws_secret_access_key="sync-secret", - s3_region_name="us-east-1" + s3_region_name="us-east-1", ) mock_sync_client = MagicMock() mock_sync_client.put.return_value = mock_response - with patch('litellm.integrations.s3_v2._get_httpx_client', return_value=mock_sync_client): + with patch( + "litellm.integrations.s3_v2._get_httpx_client", + return_value=mock_sync_client, + ): s3_logger_sync.upload_data_to_s3(test_element) call_args_sync = mock_sync_client.put.call_args assert call_args_sync is not None url_sync = call_args_sync[0][0] - expected_sync_url = "https://custom.s3.endpoint.com/sync-bucket/2025-09-14/test-key.json" - assert url_sync == expected_sync_url, f"Expected sync URL {expected_sync_url}, got {url_sync}" + expected_sync_url = ( + "https://custom.s3.endpoint.com/sync-bucket/2025-09-14/test-key.json" + ) + assert ( + url_sync == expected_sync_url + ), f"Expected sync URL {expected_sync_url}, got {url_sync}" # Test 5: Download method with custom endpoint s3_logger_download = S3Logger( @@ -138,7 +151,7 @@ class TestS3V2UnitTests: s3_endpoint_url="https://download.s3.endpoint.com", s3_aws_access_key_id="download-key", s3_aws_secret_access_key="download-secret", - s3_region_name="us-east-1" + s3_region_name="us-east-1", ) mock_download_response = MagicMock() @@ -147,16 +160,23 @@ class TestS3V2UnitTests: s3_logger_download.async_httpx_client = AsyncMock() s3_logger_download.async_httpx_client.get.return_value = mock_download_response - result = asyncio.run(s3_logger_download._download_object_from_s3("2025-09-14/download-test-key.json")) + result = asyncio.run( + s3_logger_download._download_object_from_s3( + "2025-09-14/download-test-key.json" + ) + ) call_args_download = s3_logger_download.async_httpx_client.get.call_args assert call_args_download is not None url_download = call_args_download[0][0] 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 ( + url_download == expected_download_url + ), f"Expected download URL {expected_download_url}, got {url_download}" assert result == {"downloaded": "data"} + @pytest.mark.asyncio async def test_strip_base64_removes_file_and_nontext_entries(): logger = S3Logger(s3_strip_base64_files=True) @@ -167,15 +187,24 @@ async def test_strip_base64_removes_file_and_nontext_entries(): "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"}}, + { + "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"}}, + { + "type": "audio", + "file": {"file_data": "data:audio/wav;base64,CCCC"}, + }, ], }, ] @@ -267,11 +296,31 @@ async def test_strip_base64_mixed_nested_objects(): assert stripped["messages"][0]["extra"]["trace_id"] == "123" +def test_s3_v2_prompts_only_payload(): + start_time = datetime(2026, 2, 10, 15, 0, 0) + logger = S3Logger(s3_log_prompts_only=True) + + standard_logging_payload = { + "id": "test-id", + "metadata": {}, + "messages": [{"role": "user", "content": "hello"}], + "response": {"id": "resp"}, + } + + element = logger.create_s3_batch_logging_element( + start_time=start_time, + standard_logging_payload=standard_logging_payload, + ) + + assert element is not None + assert element.payload == {"messages": [{"role": "user", "content": "hello"}]} + + @pytest.mark.asyncio async def test_s3_verify_false_handling(): """ Test that s3_verify=False is properly handled and not treated as None. - + This is a regression test for the bug where s3_verify=False was being ignored because 'False or s3_verify' would evaluate to s3_verify (None). """ @@ -289,25 +338,35 @@ async def test_s3_verify_false_handling(): "s3_verify": False, # This should NOT be ignored "s3_use_ssl": False, # This should also NOT be ignored } - - with patch('asyncio.create_task'): - with patch('litellm.integrations.s3_v2.get_async_httpx_client') as mock_get_client: + + with patch("asyncio.create_task"): + with patch( + "litellm.integrations.s3_v2.get_async_httpx_client" + ) as mock_get_client: mock_client = AsyncMock() mock_get_client.return_value = mock_client - + # Create logger logger = S3Logger() - + # Verify s3_verify is False, not None - assert logger.s3_verify is False, f"Expected s3_verify=False, got {logger.s3_verify}" - assert logger.s3_use_ssl is False, f"Expected s3_use_ssl=False, got {logger.s3_use_ssl}" - + assert ( + logger.s3_verify is False + ), f"Expected s3_verify=False, got {logger.s3_verify}" + assert ( + logger.s3_use_ssl is False + ), f"Expected s3_use_ssl=False, got {logger.s3_use_ssl}" + # Verify that get_async_httpx_client was called with ssl_verify=False mock_get_client.assert_called_once() call_kwargs = mock_get_client.call_args.kwargs - assert 'params' in call_kwargs, "params should be passed to get_async_httpx_client" - assert call_kwargs['params'] == {'ssl_verify': False}, f"Expected ssl_verify=False in params, got {call_kwargs.get('params')}" - + assert ( + "params" in call_kwargs + ), "params should be passed to get_async_httpx_client" + assert call_kwargs["params"] == { + "ssl_verify": False + }, f"Expected ssl_verify=False in params, got {call_kwargs.get('params')}" + # Clean up litellm.s3_callback_params = None @@ -328,27 +387,31 @@ async def test_s3_verify_none_handling(): "s3_aws_secret_access_key": "test-secret", "s3_region_name": "us-east-1", } - - with patch('asyncio.create_task'): - with patch('litellm.integrations.s3_v2.get_async_httpx_client') as mock_get_client: + + with patch("asyncio.create_task"): + with patch( + "litellm.integrations.s3_v2.get_async_httpx_client" + ) as mock_get_client: mock_client = AsyncMock() mock_get_client.return_value = mock_client - + # Create logger without explicit s3_verify logger = S3Logger() - + # Verify s3_verify is None (default) - assert logger.s3_verify is None, f"Expected s3_verify=None, got {logger.s3_verify}" - + assert ( + logger.s3_verify is None + ), f"Expected s3_verify=None, got {logger.s3_verify}" + # Verify that get_async_httpx_client was called mock_get_client.assert_called_once() call_kwargs = mock_get_client.call_args.kwargs # When s3_verify is None, params={'ssl_verify': None} which is fine - uses default behavior # The important thing is it's not False - if 'params' in call_kwargs and call_kwargs['params'] is not None: - assert call_kwargs['params'].get('ssl_verify') is None + if "params" in call_kwargs and call_kwargs["params"] is not None: + assert call_kwargs["params"].get("ssl_verify") is None # Either params is None or params={'ssl_verify': None} is acceptable - + # Clean up litellm.s3_callback_params = None @@ -357,7 +420,7 @@ async def test_s3_verify_none_handling(): async def test_s3_verify_false_creates_httpx_client_with_verify_false(): """ Test that when s3_verify=False, the actual httpx client has verify=False. - + This validates that ssl_verify=False flows through to the httpx.AsyncClient. """ from unittest.mock import patch @@ -373,22 +436,24 @@ async def test_s3_verify_false_creates_httpx_client_with_verify_false(): "s3_region_name": "us-east-1", "s3_verify": False, } - - with patch('asyncio.create_task'): + + with patch("asyncio.create_task"): # Create logger - this creates the httpx client logger = S3Logger() - + # Verify the logger has s3_verify=False assert logger.s3_verify is False - + # Check the actual httpx client has verify=False # The async_httpx_client.client is the actual httpx.AsyncClient - if hasattr(logger.async_httpx_client, 'client'): + if hasattr(logger.async_httpx_client, "client"): httpx_client = logger.async_httpx_client.client # Check the _verify attribute (httpx internal) - if hasattr(httpx_client, '_verify'): - assert httpx_client._verify is False, f"Expected httpx client _verify=False, got {httpx_client._verify}" - + if hasattr(httpx_client, "_verify"): + assert ( + httpx_client._verify is False + ), f"Expected httpx client _verify=False, got {httpx_client._verify}" + # Clean up litellm.s3_callback_params = None @@ -398,7 +463,7 @@ async def test_s3_verify_false_async_client(): """ Test that the async httpx client respects s3_verify=False. """ - from unittest.mock import AsyncMock, MagicMock, patch + from unittest.mock import AsyncMock, patch import litellm from litellm.types.integrations.s3_v2 import s3BatchLoggingElement @@ -412,38 +477,40 @@ async def test_s3_verify_false_async_client(): "s3_region_name": "us-east-1", "s3_verify": False, } - - with patch('asyncio.create_task'): + + with patch("asyncio.create_task"): logger = S3Logger() - + # Verify s3_verify is False assert logger.s3_verify is False - + # Create test element test_element = s3BatchLoggingElement( s3_object_key="2025-11-03/test-key.json", payload={"test": "data"}, - s3_object_download_filename="test-file.json" + s3_object_download_filename="test-file.json", ) - + # Mock the async httpx client's put method mock_response = MagicMock() mock_response.status_code = 200 mock_response.raise_for_status = MagicMock() logger.async_httpx_client.put = AsyncMock(return_value=mock_response) - + # Call async upload await logger.async_upload_data_to_s3(test_element) - + # Verify put was called assert logger.async_httpx_client.put.called - + # Check that the async httpx client was created with verify=False - if hasattr(logger.async_httpx_client, 'client'): + if hasattr(logger.async_httpx_client, "client"): httpx_client = logger.async_httpx_client.client - if hasattr(httpx_client, '_verify'): - assert httpx_client._verify is False, f"Expected async httpx client _verify=False, got {httpx_client._verify}" - + if hasattr(httpx_client, "_verify"): + assert ( + httpx_client._verify is False + ), f"Expected async httpx client _verify=False, got {httpx_client._verify}" + # Clean up litellm.s3_callback_params = None @@ -456,8 +523,14 @@ async def test_strip_base64_recursive_redaction(): { "content": [ {"type": "text", "text": "normal text"}, - {"type": "text", "text": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg"}, - {"type": "text", "text": "Nested: {'data': 'data:application/pdf;base64,AAA...'}"}, + { + "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=="}}, ] @@ -473,6 +546,7 @@ async def test_strip_base64_recursive_redaction(): # Base64 redacted globally import json + for c in content: if isinstance(c, dict): s = json.dumps(c).lower() @@ -480,7 +554,6 @@ async def test_strip_base64_recursive_redaction(): assert "base64," not in s, f"Found real base64 blob in: {s}" - # -------------------------------------------------------------- # Shared fixture that silences asyncio.create_task during tests # -------------------------------------------------------------- @@ -507,7 +580,7 @@ def patch_asyncio_create_task(): ], ) def test_s3_object_key_prefix_combinations( - use_team_prefix, use_key_prefix, team_alias, key_alias, expected_prefix + use_team_prefix, use_key_prefix, team_alias, key_alias, expected_prefix ): """ Validate correct S3 prefix composition for team alias + key alias combinations.