feat: keep prompts only in s3 bucket aws

This commit is contained in:
Harshit Jain 2026-02-10 16:30:23 +00:00
parent 0f01802dde
commit f08f81312f
4 changed files with 243 additions and 86 deletions

View file

@ -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}")

View file

@ -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)

View file

@ -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

View file

@ -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.