fix(s3): bound s3 object keys and download filenames for long Responses API ids (#39164)

* fix(s3): bound object keys and download filenames to s3 limits

Long OpenAI-compatible Responses API ids pushed the s3 object key past s3's
1024 UTF-8 byte cap, so the PUT failed with a 400 and the log record was
dropped. Keys that still fit are unchanged, byte for byte. An oversized one
now keeps a readable head of the file name and appends the sha256 of the full
name. A configured path/alias prefix that is long enough to overflow on its
own keeps whole leading path segments, so a prefix-scoped IAM policy or
lifecycle rule still matches, and ends in a short digest of the full
configured value so two operators do not land in the same folder.

The Content-Disposition filename carried the same unbounded id and hit s3's
2048 byte metadata-header cap, so the upload still failed with
MetadataTooLarge once the key was bounded. It is bounded the same way, head
plus digest, so two records downloaded from the console stay distinct files.

The full response id stays in the uploaded JSON payload.

* fix(s3): keep the configured prefix whole and spend the whole key budget

Shorten the response id first and only trim the operator's configured prefix
when the prefix itself is what does not fit, so prefix scoped IAM policies and
lifecycle rules keep matching. Trim by bytes rather than whole segments so the
longest possible string prefix survives, and route the audit log key through
the same shared builder.

* chore(s3): trim the comments and docstrings the review flagged

Keep the two external facts that are not visible from the code, the 1024 byte
object key cap and the 2048 byte metadata header cap, and drop the rest.
This commit is contained in:
yucheng-berri 2026-09-01 13:30:02 -07:00 • committed by GitHub
parent 286a754999
commit cdb1245e74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 409 additions and 28 deletions

View file

@ -13,6 +13,12 @@ DEFAULT_BATCH_SIZE: Final = int(os.getenv("DEFAULT_BATCH_SIZE", 512))
DEFAULT_FLUSH_INTERVAL_SECONDS: Final = int(os.getenv("DEFAULT_FLUSH_INTERVAL_SECONDS", 5))
DEFAULT_S3_FLUSH_INTERVAL_SECONDS: Final = int(os.getenv("DEFAULT_S3_FLUSH_INTERVAL_SECONDS", 10))
DEFAULT_S3_BATCH_SIZE: Final = int(os.getenv("DEFAULT_S3_BATCH_SIZE", 512))
# https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html
MAX_S3_OBJECT_KEY_BYTES: Final = 1024
S3_BOUNDED_OBJECT_KEY_HEAD_BYTES: Final = 64
S3_PREFIX_DIGEST_CHARS: Final = 16
# s3 allows 2048 bytes of combined metadata headers, which Content-Disposition counts against
MAX_S3_OBJECT_DOWNLOAD_FILENAME_BYTES: Final = 1024
DEFAULT_SQS_FLUSH_INTERVAL_SECONDS: Final = int(os.getenv("DEFAULT_SQS_FLUSH_INTERVAL_SECONDS", 10))
DEFAULT_NUM_WORKERS_LITELLM_PROXY: Final = int(os.getenv("DEFAULT_NUM_WORKERS_LITELLM_PROXY", 1))
DYNAMIC_RATE_LIMIT_ERROR_THRESHOLD_PER_MINUTE = int(os.getenv("DYNAMIC_RATE_LIMIT_ERROR_THRESHOLD_PER_MINUTE", 1))

View file

@ -1,11 +1,18 @@
#### What this does ####
# On success + failure, log events to Supabase
import hashlib
from datetime import datetime
from typing import Final, cast
import litellm
from litellm._logging import print_verbose, verbose_logger
from litellm.constants import (
MAX_S3_OBJECT_DOWNLOAD_FILENAME_BYTES,
MAX_S3_OBJECT_KEY_BYTES,
S3_BOUNDED_OBJECT_KEY_HEAD_BYTES,
S3_PREFIX_DIGEST_CHARS,
)
from litellm.types.utils import StandardLoggingPayload
@ -133,9 +140,7 @@ class S3Logger:
s3_file_name,
)
s3_object_download_filename: Final = (
"time-" + start_time.strftime("%Y-%m-%dT%H-%M-%S-%f") + "_" + payload["id"] + ".json"
)
s3_object_download_filename: Final = get_s3_object_download_filename(start_time, payload["id"])
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
@ -198,6 +203,47 @@ def resolve_sse_params(
return algorithm, valid_key_id
S3_MIN_BOUNDED_FILE_NAME_BYTES: Final = 64
def _truncate_to_utf8_bytes(value: str, max_bytes: int) -> str:
"""Trim `value` so its UTF-8 encoding fits `max_bytes`, never splitting a character."""
if max_bytes <= 0:
return ""
encoded: Final = value.encode("utf-8")
if len(encoded) <= max_bytes:
return value
return encoded[:max_bytes].decode("utf-8", errors="ignore")
def get_s3_object_download_filename(start_time: datetime, response_id: str) -> str:
"""Content-Disposition filename for the uploaded object, bounded to the metadata header cap."""
sanitized_response_id: Final = response_id.replace("/", "_").replace('"', "_")
file_name: Final = f"time-{start_time.strftime('%Y-%m-%dT%H-%M-%S-%f')}_{response_id}"
sanitized_file_name: Final = f"time-{start_time.strftime('%Y-%m-%dT%H-%M-%S-%f')}_{sanitized_response_id}"
budget: Final = MAX_S3_OBJECT_DOWNLOAD_FILENAME_BYTES - len(b".json")
if len(sanitized_file_name.encode("utf-8")) <= budget:
return sanitized_file_name + ".json"
return _bounded_s3_file_name(file_name, sanitized_file_name, budget) + ".json"
def _bounded_s3_file_name(s3_file_name: str, sanitized_s3_file_name: str, max_bytes: int) -> str:
"""As much of the file name as `max_bytes` allows, then the sha256 of the whole name."""
digest: Final = hashlib.sha256(s3_file_name.encode("utf-8")).hexdigest()
head_budget: Final = min(S3_BOUNDED_OBJECT_KEY_HEAD_BYTES, max_bytes - len(digest) - 1)
head: Final = _truncate_to_utf8_bytes(sanitized_s3_file_name, head_budget)
return f"{head}_{digest}" if head else digest
def _bounded_s3_prefix(configured_prefix: str, max_bytes: int) -> str:
"""As much of the configured prefix as fits, then a digest segment naming the full prefix."""
digest_segment: Final = hashlib.sha256(configured_prefix.encode("utf-8")).hexdigest()[:S3_PREFIX_DIGEST_CHARS] + "/"
if max_bytes < len(digest_segment):
return ""
head: Final = _truncate_to_utf8_bytes(configured_prefix, max_bytes - len(digest_segment) - 1).rstrip("/")
return f"{head}/{digest_segment}" if head else digest_segment
def get_s3_object_key(
s3_path: str,
prefix: str,
@ -205,12 +251,23 @@ def get_s3_object_key(
s3_file_name: str,
) -> str:
sanitized_s3_file_name: Final = s3_file_name.replace("/", "_")
s3_object_key = (
(s3_path.rstrip("/") + "/" if s3_path else "")
+ prefix
+ start_time.strftime("%Y-%m-%d")
+ "/"
+ sanitized_s3_file_name
) # we need the s3 key to include the time, so we log cache hits too
s3_object_key += ".json"
return s3_object_key
configured_prefix: Final = (s3_path.rstrip("/") + "/" if s3_path else "") + prefix
date_segment: Final = start_time.strftime("%Y-%m-%d") + "/"
# we need the s3 key to include the time, so we log cache hits too
s3_object_key: Final = configured_prefix + date_segment + sanitized_s3_file_name + ".json"
if len(s3_object_key.encode("utf-8")) <= MAX_S3_OBJECT_KEY_BYTES:
return s3_object_key
# shorten the response id first and only trim the configured prefix if that is what does not
# fit, so prefix scoped IAM policies and lifecycle rules keep matching
budget: Final = MAX_S3_OBJECT_KEY_BYTES - len(date_segment.encode("utf-8")) - len(b".json")
prefix_bytes: Final = len(configured_prefix.encode("utf-8"))
if prefix_bytes + S3_MIN_BOUNDED_FILE_NAME_BYTES <= budget:
bounded_file_name: Final = _bounded_s3_file_name(s3_file_name, sanitized_s3_file_name, budget - prefix_bytes)
return configured_prefix + date_segment + bounded_file_name + ".json"
shortest_file_name: Final = _bounded_s3_file_name(
s3_file_name, sanitized_s3_file_name, S3_MIN_BOUNDED_FILE_NAME_BYTES
)
bounded_prefix: Final = _bounded_s3_prefix(configured_prefix, budget - len(shortest_file_name.encode("utf-8")))
return bounded_prefix + date_segment + shortest_file_name + ".json"

View file

@ -16,7 +16,11 @@ from urllib.parse import quote
import litellm
from litellm._logging import print_verbose, verbose_logger
from litellm.constants import DEFAULT_S3_BATCH_SIZE, DEFAULT_S3_FLUSH_INTERVAL_SECONDS
from litellm.integrations.s3 import get_s3_object_key, resolve_sse_params
from litellm.integrations.s3 import (
get_s3_object_download_filename,
get_s3_object_key,
resolve_sse_params,
)
from litellm.litellm_core_utils.aws_partition import get_aws_dns_suffix
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
@ -259,11 +263,11 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM):
now: Final = datetime.now(timezone.utc)
audit_log_id: Final = audit_log.get("id", "unknown")
s3_path = cast(str | None, self.s3_path) or ""
s3_path = s3_path.rstrip("/") + "/" if s3_path else ""
s3_object_key: Final = (
f"{s3_path}audit_logs/{now.strftime('%Y-%m-%d')}/{now.strftime('%H-%M-%S')}_{audit_log_id}.json"
s3_object_key: Final = get_s3_object_key(
cast(str | None, self.s3_path) or "",
"audit_logs/",
now,
f"{now.strftime('%H-%M-%S')}_{audit_log_id}",
)
element: Final = s3BatchLoggingElement(
@ -463,9 +467,7 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM):
)
verbose_logger.debug("s3_object_key=%s", s3_object_key)
s3_object_download_filename: Final = (
f"time-{start_time.strftime('%Y-%m-%dT%H-%M-%S-%f')}_{standard_logging_payload['id']}.json"
)
s3_object_download_filename: Final = get_s3_object_download_filename(start_time, standard_logging_payload["id"])
return s3BatchLoggingElement(
payload=dict(standard_logging_payload),

View file

@ -2,26 +2,27 @@ from datetime import datetime
from unittest.mock import MagicMock, patch
import litellm
from litellm.constants import MAX_S3_OBJECT_DOWNLOAD_FILENAME_BYTES, MAX_S3_OBJECT_KEY_BYTES
from litellm.integrations.s3 import S3Logger
TEST_KMS_KEY_ARN = "arn:aws:kms:us-east-1:111122223333:key/test-key-id"
def _standard_logging_payload() -> dict:
def _standard_logging_payload(response_id: str = "chatcmpl-test-id") -> dict:
return {
"id": "chatcmpl-test-id",
"id": response_id,
"metadata": {"user_api_key_team_alias": None},
}
def _log_event_kwargs() -> dict:
def _log_event_kwargs(response_id: str = "chatcmpl-test-id") -> dict:
return {
"litellm_params": {"metadata": {}},
"standard_logging_object": _standard_logging_payload(),
"standard_logging_object": _standard_logging_payload(response_id),
}
def _run_log_event(callback_params: dict) -> MagicMock:
def _run_log_event(callback_params: dict, response_id: str = "chatcmpl-test-id") -> MagicMock:
original = litellm.s3_callback_params
litellm.s3_callback_params = callback_params
try:
@ -30,8 +31,8 @@ def _run_log_event(callback_params: dict) -> MagicMock:
mock_boto3_client.return_value = mock_s3_client
logger = S3Logger()
logger.log_event(
kwargs=_log_event_kwargs(),
response_obj={},
kwargs=_log_event_kwargs(response_id),
response_obj={"id": response_id},
start_time=datetime(2026, 7, 30, 12, 0, 0),
end_time=datetime(2026, 7, 30, 12, 0, 1),
print_verbose=lambda *args, **kwargs: None,
@ -154,3 +155,30 @@ def test_non_string_key_id_is_dropped_and_valid_algorithm_is_kept():
put_object_kwargs = mock_s3_client.put_object.call_args.kwargs
assert put_object_kwargs["ServerSideEncryption"] == "aws:kms"
assert "SSEKMSKeyId" not in put_object_kwargs
def test_put_object_key_and_filename_are_bounded_for_an_oversized_response_id():
"""The sync logger bounds both the key and the Content-Disposition filename."""
mock_s3_client = _run_log_event(
{"s3_bucket_name": "test-bucket", "s3_region_name": "us-west-2", "s3_path": "logs"},
response_id="resp_" + "A" * 1100,
)
put_object_kwargs = mock_s3_client.put_object.call_args.kwargs
assert len(put_object_kwargs["Key"].encode("utf-8")) <= MAX_S3_OBJECT_KEY_BYTES
assert put_object_kwargs["Key"].startswith("logs/2026-07-30/time-12-00-00-000000_resp_")
filename = put_object_kwargs["ContentDisposition"].removeprefix('inline; filename="').removesuffix('"')
assert len(filename.encode("utf-8")) <= MAX_S3_OBJECT_DOWNLOAD_FILENAME_BYTES
def test_put_object_keeps_the_configured_path_intact_when_only_the_id_has_to_shrink():
"""A long configured s3_path survives whole when the id can be shortened instead."""
long_path = "litellm-prod-logs/" + "t" * 921
mock_s3_client = _run_log_event(
{"s3_bucket_name": "test-bucket", "s3_region_name": "us-west-2", "s3_path": long_path},
response_id="resp_" + "B" * 100,
)
key = mock_s3_client.put_object.call_args.kwargs["Key"]
assert key.startswith(long_path + "/2026-07-30/")
assert len(key.encode("utf-8")) == MAX_S3_OBJECT_KEY_BYTES

View file

@ -1170,6 +1170,294 @@ def test_create_s3_batch_logging_element_flat_key_for_arn_response_id():
assert file_segment.endswith("model-invocation-job_gl18r6skk9yy.json")
# --------------------------------------------------------------
# object keys bounded to S3's 1024 UTF-8 byte limit
# --------------------------------------------------------------
def _oversized_response_id() -> str:
return "resp_" + "A" * 1100
def test_s3_object_key_at_the_byte_limit_is_left_alone():
"""A key that still fits is left byte-identical."""
from litellm.constants import MAX_S3_OBJECT_KEY_BYTES
from litellm.integrations.s3 import get_s3_object_key
start_time = datetime(2026, 8, 24, 6, 18, 41, 948021)
fixed_len = len("input/2026-08-24/.json")
file_name = "x" * (MAX_S3_OBJECT_KEY_BYTES - fixed_len)
key = get_s3_object_key(s3_path="input", prefix="", start_time=start_time, s3_file_name=file_name)
assert key == f"input/2026-08-24/{file_name}.json"
assert len(key.encode("utf-8")) == MAX_S3_OBJECT_KEY_BYTES
def test_s3_object_key_is_bounded_for_oversized_response_id():
"""An oversized Responses API id is shortened to a readable head plus a digest."""
import hashlib
from litellm.constants import MAX_S3_OBJECT_KEY_BYTES
from litellm.integrations.s3 import get_s3_object_key
start_time = datetime(2026, 8, 24, 6, 18, 41, 948021)
file_name = f"time-06-18-41-948021_{_oversized_response_id()}"
key = get_s3_object_key(s3_path="input", prefix="DefaultTeamProd/", start_time=start_time, s3_file_name=file_name)
assert len(key.encode("utf-8")) <= MAX_S3_OBJECT_KEY_BYTES
assert key.startswith("input/DefaultTeamProd/2026-08-24/time-06-18-41-948021_resp_")
assert key.endswith(f"_{hashlib.sha256(file_name.encode('utf-8')).hexdigest()}.json")
@pytest.mark.parametrize(
"s3_path,prefix",
[
("input", ""),
("a" * 900, ""),
("input", "team-" + "b" * 900 + "/"),
("c" * 600, "team-" + "d" * 600 + "/key-" + "e" * 600 + "/"),
# many short segments, so the trim lands exactly on the budget edge
("", "ssss/" * 200),
],
)
def test_s3_object_key_is_bounded_for_long_paths_and_aliases(s3_path: str, prefix: str):
"""Long paths, team aliases and key aliases stay within the cap."""
from litellm.constants import MAX_S3_OBJECT_KEY_BYTES
from litellm.integrations.s3 import get_s3_object_key
key = get_s3_object_key(
s3_path=s3_path,
prefix=prefix,
start_time=datetime(2026, 8, 24, 6, 18, 41, 948021),
s3_file_name=f"time-06-18-41-948021_{_oversized_response_id()}",
)
assert len(key.encode("utf-8")) <= MAX_S3_OBJECT_KEY_BYTES
assert key.endswith(".json")
assert "/2026-08-24/" in key or key.startswith("2026-08-24/")
assert "/" not in key.rsplit("2026-08-24/", 1)[1]
def test_s3_object_key_trimmed_prefixes_stay_distinct_per_operator():
"""Prefixes that differ only past the trim point keep separate folders."""
from litellm.constants import MAX_S3_OBJECT_KEY_BYTES
from litellm.integrations.s3 import get_s3_object_key
start_time = datetime(2026, 8, 24, 6, 18, 41, 948021)
keys = [
get_s3_object_key(
s3_path="input",
prefix="team-" + "b" * 1000 + suffix + "/",
start_time=start_time,
s3_file_name=f"time-06-18-41-948021_{_oversized_response_id()}",
)
for suffix in ("-one", "-two")
]
assert keys[0] != keys[1]
assert all(key.startswith("input/team-" + "b" * 900) for key in keys)
assert all(len(key.encode("utf-8")) == MAX_S3_OBJECT_KEY_BYTES for key in keys)
def test_s3_object_key_bounded_prefix_never_splits_a_multibyte_character():
"""A multibyte prefix is trimmed on a character boundary."""
from litellm.constants import MAX_S3_OBJECT_KEY_BYTES
from litellm.integrations.s3 import get_s3_object_key
s3_path = "\u65e5\u672c\u8a9e" * 200
key = get_s3_object_key(
s3_path=s3_path,
prefix="\u30c1\u30fc\u30e0" * 200 + "/",
start_time=datetime(2026, 8, 24, 6, 18, 41, 948021),
s3_file_name=f"time-06-18-41-948021_{_oversized_response_id()}",
)
assert len(key.encode("utf-8")) <= MAX_S3_OBJECT_KEY_BYTES
assert key.startswith(s3_path[:100])
assert "\ufffd" not in key
def test_s3_object_key_stays_unique_for_ids_sharing_a_head():
"""Ids sharing a visible head still get distinct keys."""
from litellm.integrations.s3 import get_s3_object_key
start_time = datetime(2026, 8, 24, 6, 18, 41, 948021)
keys = {
get_s3_object_key(
s3_path="input",
prefix="",
start_time=start_time,
s3_file_name=f"time-06-18-41-948021_{_oversized_response_id()}{suffix}",
)
for suffix in ("first", "second", "third")
}
assert len(keys) == 3
def test_s3_object_key_bounding_matches_the_documented_layout():
"""The bounded key is `<prefix>/<date>/<head>_<sha256>.json`."""
import hashlib
from litellm.integrations.s3 import get_s3_object_key
file_name = f"time-06-18-41-948021_{_oversized_response_id()}"
key = get_s3_object_key(
s3_path="input",
prefix="team/",
start_time=datetime(2026, 8, 24, 6, 18, 41, 948021),
s3_file_name=file_name,
)
digest = hashlib.sha256(file_name.encode("utf-8")).hexdigest()
assert key == f"input/team/2026-08-24/{file_name[:64]}_{digest}.json"
def test_s3_object_key_keeps_the_configured_prefix_when_only_the_id_overflows():
"""A 940 byte configured prefix survives whole when only the id overflows."""
from litellm.constants import MAX_S3_OBJECT_KEY_BYTES
from litellm.integrations.s3 import get_s3_object_key
prefix = "team-" + "b" * 934 + "/"
key = get_s3_object_key(
s3_path="",
prefix=prefix,
start_time=datetime(2026, 8, 24, 6, 18, 41, 948021),
s3_file_name=f"time-06-18-41-948021_{_oversized_response_id()}",
)
assert key.startswith(prefix + "2026-08-24/")
assert len(key.encode("utf-8")) == MAX_S3_OBJECT_KEY_BYTES
def test_s3_object_key_spends_the_whole_budget_when_the_prefix_must_be_trimmed():
"""A trimmed prefix keeps every byte the budget allows, not whole segments."""
from litellm.constants import MAX_S3_OBJECT_KEY_BYTES
from litellm.integrations.s3 import get_s3_object_key
s3_path = "p" * 400 + "/" + "q" * 600
key = get_s3_object_key(
s3_path=s3_path,
prefix="",
start_time=datetime(2026, 8, 24, 6, 18, 41, 948021),
s3_file_name="time-06-18-41-948021_abc",
)
assert len(key.encode("utf-8")) == MAX_S3_OBJECT_KEY_BYTES
assert key.startswith("p" * 400 + "/" + "q" * 500)
def test_s3_object_key_keeps_a_single_segment_path_as_far_as_it_fits():
"""A path with no separator is kept as far as it fits, never dropped to the bucket root."""
from litellm.constants import MAX_S3_OBJECT_KEY_BYTES
from litellm.integrations.s3 import get_s3_object_key
key = get_s3_object_key(
s3_path="a" * 1050,
prefix="",
start_time=datetime(2026, 8, 24, 6, 18, 41, 948021),
s3_file_name="time-06-18-41-948021_chatcmpl-xyz",
)
assert len(key.encode("utf-8")) == MAX_S3_OBJECT_KEY_BYTES
assert key.startswith("a" * 900)
def test_create_s3_batch_logging_element_bounds_key_and_keeps_full_response_id():
"""The batch element bounds the key and keeps the full response id in the payload."""
from litellm.constants import MAX_S3_OBJECT_KEY_BYTES
logger = S3Logger(s3_use_team_prefix=True, s3_use_key_prefix=True)
response_id = _oversized_response_id()
payload = StandardLoggingPayload(
id=response_id,
metadata={"user_api_key_team_alias": "DefaultTeamProd", "user_api_key_alias": "prod-key"},
messages=[],
)
result = logger.create_s3_batch_logging_element(datetime(2026, 8, 24, 6, 18, 41, 948021), payload)
assert result is not None
assert len(result.s3_object_key.encode("utf-8")) <= MAX_S3_OBJECT_KEY_BYTES
assert result.s3_object_key.startswith("DefaultTeamProd/prod-key/2026-08-24/")
assert result.payload["id"] == response_id
def test_s3_object_download_filename_is_bounded_for_oversized_response_id():
"""The Content-Disposition filename is bounded too, or the PUT fails with MetadataTooLarge."""
from litellm.constants import MAX_S3_OBJECT_DOWNLOAD_FILENAME_BYTES
from litellm.integrations.s3 import get_s3_object_download_filename
file_name = get_s3_object_download_filename(datetime(2026, 8, 24, 6, 18, 41, 948021), _oversized_response_id())
assert len(file_name.encode("utf-8")) <= MAX_S3_OBJECT_DOWNLOAD_FILENAME_BYTES
assert file_name.startswith("time-2026-08-24T06-18-41-948021_resp_")
assert file_name.endswith(".json")
def test_s3_object_download_filenames_stay_distinct_when_shortened():
"""Shortened filenames stay distinct."""
from litellm.integrations.s3 import get_s3_object_download_filename
start_time = datetime(2026, 8, 24, 6, 18, 41, 948021)
file_names = {
get_s3_object_download_filename(start_time, _oversized_response_id() + suffix)
for suffix in ("first", "second", "third")
}
assert len(file_names) == 3
def test_s3_object_download_filename_short_id_is_unchanged():
"""An ordinary response id keeps the filename it had before."""
from litellm.integrations.s3 import get_s3_object_download_filename
file_name = get_s3_object_download_filename(datetime(2026, 8, 24, 6, 18, 41, 948021), "resp_abc123")
assert file_name == "time-2026-08-24T06-18-41-948021_resp_abc123.json"
def test_create_s3_batch_logging_element_bounds_the_download_filename():
"""The batch element carries a bounded Content-Disposition filename."""
from litellm.constants import MAX_S3_OBJECT_DOWNLOAD_FILENAME_BYTES
logger = S3Logger()
payload = StandardLoggingPayload(id=_oversized_response_id(), metadata={}, messages=[])
result = logger.create_s3_batch_logging_element(datetime(2026, 8, 24, 6, 18, 41, 948021), payload)
assert result is not None
assert len(result.s3_object_download_filename.encode("utf-8")) <= MAX_S3_OBJECT_DOWNLOAD_FILENAME_BYTES
@pytest.mark.asyncio
async def test_audit_log_object_key_is_bounded_for_a_long_configured_path():
"""Audit log keys are bounded by the same builder."""
from litellm.constants import MAX_S3_OBJECT_KEY_BYTES
logger = S3Logger()
logger.s3_path = "audit-archive/" + "z" * 1100
await logger.async_log_audit_log_event({"id": "1a4f7bd0-6f1e-4d0a-9b3c-9f2e1d5a7c88"})
assert len(logger.log_queue) == 1
assert len(logger.log_queue[0].s3_object_key.encode("utf-8")) <= MAX_S3_OBJECT_KEY_BYTES
assert logger.log_queue[0].s3_object_key.startswith("audit-archive/" + "z" * 900)
def test_s3_object_download_filename_drops_characters_that_break_the_header():
"""A quote or separator in the response id cannot escape the quoted header value."""
from litellm.integrations.s3 import get_s3_object_download_filename
file_name = get_s3_object_download_filename(datetime(2026, 8, 24, 6, 18, 41, 948021), 'resp_a"b/c')
assert file_name == "time-2026-08-24T06-18-41-948021_resp_a_b_c.json"
# --------------------------------------------------------------
# params_source / s3_callback_params_override (audit-log decoupling)
# --------------------------------------------------------------