From fc978aec2111e9b1ce5cdd07cff0a09efd0034e1 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:33:14 -0700 Subject: [PATCH 01/12] feat(bedrock): support file delete and list for S3-backed managed files --- .../proxy/hooks/managed_files.py | 8 +- litellm/files/main.py | 4 + litellm/llms/bedrock/files/transformation.py | 225 +++++++-- .../openai_files_endpoints/files_endpoints.py | 4 +- .../proxy/test_managed_files_hook.py | 96 ++++ .../test_bedrock_files_transformation.py | 469 +++++++++++++++++- .../test_files_endpoint.py | 67 +++ 7 files changed, 814 insertions(+), 59 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py index e11c6e70540..748ab5dd26a 100644 --- a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py +++ b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py @@ -1781,7 +1781,13 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): # Remove conflicting keys from data to avoid duplicate keyword arguments filtered_data = {k: v for k, v in data.items() if k not in ("model", "file_id")} for model_id, model_file_id in specific_model_file_id_mapping.items(): - delete_response = await llm_router.afile_delete(model=model_id, file_id=model_file_id, **filtered_data) # type: ignore + credentials = llm_router.get_deployment_credentials_with_provider(model_id=model_id) + router_kwargs = ( + {**filtered_data, "_litellm_internal_model_credentials": MappingProxyType(dict(credentials))} + if credentials is not None + else filtered_data + ) + delete_response = await llm_router.afile_delete(model=model_id, file_id=model_file_id, **router_kwargs) stored_file_object = await self.delete_unified_file_id(file_id, litellm_parent_otel_span) diff --git a/litellm/files/main.py b/litellm/files/main.py index 294c62f3d80..34d153b7754 100644 --- a/litellm/files/main.py +++ b/litellm/files/main.py @@ -681,6 +681,10 @@ def file_list( ) if provider_config is not None: litellm_params_dict: Final = get_litellm_params(**kwargs) + add_trusted_model_credentials_to_litellm_params( + litellm_params_dict=litellm_params_dict, + kwargs=kwargs, + ) litellm_params_dict["api_key"] = optional_params.api_key litellm_params_dict["api_base"] = optional_params.api_base diff --git a/litellm/llms/bedrock/files/transformation.py b/litellm/llms/bedrock/files/transformation.py index 33b27943ad8..41ea206a250 100644 --- a/litellm/llms/bedrock/files/transformation.py +++ b/litellm/llms/bedrock/files/transformation.py @@ -1,14 +1,18 @@ import base64 import json import os +import posixpath import time +import xml.etree.ElementTree as ET from collections.abc import Iterable, Mapping, MutableMapping, Sequence from contextlib import suppress +from dataclasses import dataclass +from datetime import datetime from functools import cache from itertools import chain from types import MappingProxyType from typing import Any, Final, TypeAlias, TypedDict -from urllib.parse import unquote +from urllib.parse import quote, unquote, urlencode import httpx from httpx import Headers, Response @@ -23,6 +27,7 @@ from litellm.files.utils import FilesAPIUtils from litellm.litellm_core_utils.aws_partition import get_aws_dns_suffix from litellm.litellm_core_utils.cloud_storage_security import ( BEDROCK_MANAGED_S3_BATCH_PREFIX, + BEDROCK_MANAGED_S3_OUTPUT_PREFIX, BEDROCK_MANAGED_S3_PREFIXES, BEDROCK_MANAGED_S3_UPLOAD_PREFIX, build_managed_cloud_object_name, @@ -60,11 +65,15 @@ from litellm.utils import get_llm_provider from ..base_aws_llm import BaseAWSLLM from ..common_utils import BedrockError, merge_bedrock_aws_request_params, resolve_s3_encryption_key_id -# litellm_params key used to hand the SigV4-signed GET headers from -# `transform_file_content_request` to `validate_environment` (the only hook -# the shared file-content HTTP handler exposes for setting request headers). +# litellm_params key used to hand SigV4-signed request headers from the +# content, delete, and list request transforms to `validate_environment` (the +# only hook the shared files HTTP handler exposes for setting request headers). # Same pattern as the `upload_url` handoff in `transform_create_file_request`. -S3_SIGNED_GET_HEADERS_PARAM: Final = "_s3_signed_get_headers" +S3_SIGNED_REQUEST_HEADERS_PARAM: Final = "_s3_signed_request_headers" + +DELETED_FILE_ID_PARAM: Final = "_s3_deleted_file_id" + +LIST_FILES_PURPOSE_PARAM: Final = "_s3_list_files_purpose" # litellm_params key carrying the size of the body uploaded to S3, handed from # `transform_create_file_request` to `transform_create_file_response`. @@ -151,6 +160,13 @@ class _BedrockS3RequestParams(BaseModel): s3_endpoint_url: str | None = None +@dataclass(frozen=True, slots=True) +class _S3RequestTarget: + endpoint_url: str + aws_region_name: str + request_params: _BedrockS3RequestParams + + class _TrustedS3ModelCredentials(BaseModel): """The S3 buckets the server trusts file ids against, from the deployment snapshot.""" @@ -247,6 +263,51 @@ def _validate_file_id_against_configured_buckets( return validate_against(configured_bucket_names[-1]) +def _managed_listing_prefix(configured_prefix: str) -> str: + common_prefix: Final = os.path.commonprefix(BEDROCK_MANAGED_S3_PREFIXES) + return f"{configured_prefix}/{common_prefix}" if configured_prefix else common_prefix + + +def _listed_object_created_at(entry: ET.Element) -> int: + last_modified: Final = entry.findtext("{*}LastModified") + if not last_modified: + return 0 + return int(datetime.fromisoformat(last_modified.replace("Z", "+00:00")).timestamp()) + + +def _listed_managed_file( + entry: ET.Element, + bucket_name: str, + configured_bucket_name: str, + allow_legacy_cloud_file_ids: bool, +) -> OpenAIFileObject | None: + object_key: Final = entry.findtext("{*}Key") + if not object_key: + return None + file_id: Final = f"s3://{bucket_name}/{object_key}" + try: + validate_managed_cloud_file_id( + file_id=file_id, + scheme="s3://", + configured_bucket_name=configured_bucket_name, + allowed_object_prefixes=BEDROCK_MANAGED_S3_PREFIXES, + allow_legacy_cloud_file_ids=allow_legacy_cloud_file_ids, + ) + except ValueError: + return None + _, configured_prefix = split_configured_cloud_bucket_name(configured_bucket_name) + relative_key: Final = object_key[len(configured_prefix) + 1 :] if configured_prefix else object_key + return OpenAIFileObject( + id=file_id, + bytes=int(entry.findtext("{*}Size") or 0), + created_at=_listed_object_created_at(entry), + filename=posixpath.basename(object_key), + object="file", + purpose="batch_output" if relative_key.startswith(BEDROCK_MANAGED_S3_OUTPUT_PREFIX) else "batch", + status="uploaded", + ) + + def _uploaded_object_size(litellm_params: Mapping[str, object], raw_response: Response) -> int: """ S3 answers PutObject with an empty body, so the stored object size comes from the @@ -291,7 +352,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): ) -> dict: result: Final[dict[str, object]] = {} result.update(headers) - signed_headers: Final = litellm_params.pop(S3_SIGNED_GET_HEADERS_PARAM, None) + signed_headers: Final = litellm_params.pop(S3_SIGNED_REQUEST_HEADERS_PARAM, None) if isinstance(signed_headers, Mapping): result.update(signed_headers) # any-ok: untyped handoff headers # otherwise no extra headers - AWS credentials are handled by BaseAWSLLM @@ -1187,34 +1248,95 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): def transform_delete_file_request( self, file_id: str, - optional_params: dict, - litellm_params: dict, - ) -> tuple[str, dict]: - raise NotImplementedError("BedrockFilesConfig does not support file deletion") + optional_params: Mapping[str, object], + litellm_params: MutableMapping[str, object], + ) -> tuple[str, dict[str, str]]: + if not file_id: + raise ValueError("file_id is required for Bedrock file deletion") + bucket_name, object_key = _validate_file_id_against_configured_buckets( + s3_uri=extract_s3_uri_from_file_id(file_id), + configured_bucket_names=get_configured_s3_bucket_names(litellm_params), + allow_legacy_cloud_file_ids=should_allow_legacy_cloud_file_ids(litellm_params), + ) + target: Final = self._s3_request_target(optional_params=optional_params, litellm_params=litellm_params) + url: Final = f"{target.endpoint_url}/{bucket_name}/{encode_s3_object_key_for_url(object_key)}" + signed_headers: Final = self._sign_s3_empty_body_request( + method="DELETE", + api_base=url, + aws_region_name=target.aws_region_name, + request_params=target.request_params, + ) + litellm_params[S3_SIGNED_REQUEST_HEADERS_PARAM] = signed_headers # rebind-ok: handed to validate_environment + litellm_params[DELETED_FILE_ID_PARAM] = file_id # rebind-ok: S3 DeleteObject answers with an empty body + return url, {} # mutable-ok: the base files contract returns the query as a dict def transform_delete_file_response( self, raw_response: httpx.Response, logging_obj: LiteLLMLoggingObj, - litellm_params: dict, + litellm_params: Mapping[str, object], ) -> FileDeleted: - raise NotImplementedError("BedrockFilesConfig does not support file deletion") + if raw_response.status_code >= 400: + raise BedrockError( + status_code=raw_response.status_code, + message=raw_response.text, + headers=raw_response.headers, + ) + return FileDeleted(id=str(litellm_params.get(DELETED_FILE_ID_PARAM, "")), deleted=True, object="file") def transform_list_files_request( self, purpose: str | None, - optional_params: dict, - litellm_params: dict, - ) -> tuple[str, dict]: - raise NotImplementedError("BedrockFilesConfig does not support file listing") + optional_params: Mapping[str, object], + litellm_params: MutableMapping[str, object], + ) -> tuple[str, dict[str, str]]: + bucket_name, configured_prefix = split_configured_cloud_bucket_name( + get_configured_s3_bucket_name(litellm_params) + ) + target: Final = self._s3_request_target(optional_params=optional_params, litellm_params=litellm_params) + url: Final = f"{target.endpoint_url}/{bucket_name}/" + query: Final[dict[str, str]] = { # mutable-ok: the base files contract returns the query as a dict + "list-type": "2", + "prefix": _managed_listing_prefix(configured_prefix), + } + signed_headers: Final = self._sign_s3_empty_body_request( + method="GET", + api_base=f"{url}?{urlencode(query, quote_via=quote, safe='')}", + aws_region_name=target.aws_region_name, + request_params=target.request_params, + ) + litellm_params[S3_SIGNED_REQUEST_HEADERS_PARAM] = signed_headers # rebind-ok: handed to validate_environment + litellm_params[LIST_FILES_PURPOSE_PARAM] = purpose # rebind-ok: handed to the response transform + return url, query def transform_list_files_response( self, raw_response: httpx.Response, logging_obj: LiteLLMLoggingObj, - litellm_params: dict, + litellm_params: Mapping[str, object], ) -> list[OpenAIFileObject]: - raise NotImplementedError("BedrockFilesConfig does not support file listing") + if raw_response.status_code >= 400: + raise BedrockError( + status_code=raw_response.status_code, + message=raw_response.text, + headers=raw_response.headers, + ) + configured_bucket_name: Final = get_configured_s3_bucket_name(litellm_params) + allow_legacy_cloud_file_ids: Final = should_allow_legacy_cloud_file_ids(litellm_params) + requested_purpose: Final = litellm_params.get(LIST_FILES_PURPOSE_PARAM) + listing: Final = ET.fromstring(raw_response.content) + bucket_name: Final = ( + listing.findtext("{*}Name") or split_configured_cloud_bucket_name(configured_bucket_name)[0] + ) + listed_files: Final = ( + _listed_managed_file(entry, bucket_name, configured_bucket_name, allow_legacy_cloud_file_ids) + for entry in listing.iterfind("{*}Contents") + ) + return [ # mutable-ok: the base files contract returns a list + listed_file + for listed_file in listed_files + if listed_file is not None and (requested_purpose is None or listed_file.purpose == requested_purpose) + ] def transform_file_content_request( self, @@ -1239,40 +1361,53 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): configured_bucket_names=get_configured_s3_bucket_names(litellm_params), allow_legacy_cloud_file_ids=should_allow_legacy_cloud_file_ids(litellm_params), ) + target: Final = self._s3_request_target(optional_params=optional_params, litellm_params=litellm_params) + url: Final = f"{target.endpoint_url}/{bucket_name}/{encode_s3_object_key_for_url(object_key)}" - # The shared file-content handler passes optional_params={}, so AWS - # credentials/region arrive via litellm_params here (unlike the upload - # path). s3_region_name wins over aws_region_name, same priority as - # get_complete_file_url above. - merged_params: Final[dict[str, object]] = {} - merged_params.update(litellm_params) - merged_params.update(optional_params) - request_params: Final = _BedrockS3RequestParams.model_validate(merged_params) - - region_preference: Final = request_params.s3_region_name or request_params.aws_region_name - region_params: Final[dict[str, str | None]] = {"aws_region_name": region_preference} - aws_region_name: Final = self._get_aws_region_name(optional_params=region_params, model="") - - s3_endpoint_url = ( - request_params.s3_endpoint_url or f"https://s3.{aws_region_name}.{get_aws_dns_suffix(aws_region_name)}" - ).rstrip("/") - url: Final = f"{s3_endpoint_url}/{bucket_name}/{encode_s3_object_key_for_url(object_key)}" - - litellm_params[S3_SIGNED_GET_HEADERS_PARAM] = self._sign_s3_get_request( + signed_headers: Final = self._sign_s3_empty_body_request( + method="GET", api_base=url, - aws_region_name=aws_region_name, - request_params=request_params, + aws_region_name=target.aws_region_name, + request_params=target.request_params, ) + litellm_params[S3_SIGNED_REQUEST_HEADERS_PARAM] = signed_headers # rebind-ok: handed to validate_environment return url, {} - def _sign_s3_get_request( + def _s3_request_target( self, + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], + ) -> _S3RequestTarget: + """ + The shared files handler passes optional_params={}, so AWS credentials and + region arrive via litellm_params here (unlike the upload path). + s3_region_name wins over aws_region_name, same priority as get_complete_file_url. + """ + request_params: Final = _BedrockS3RequestParams.model_validate( + MappingProxyType({**litellm_params, **optional_params}) + ) + region_preference: Final = request_params.s3_region_name or request_params.aws_region_name + aws_region_name: Final = self._get_aws_region_name( + optional_params={"aws_region_name": region_preference}, # mutable-ok: BaseAWSLLM takes a dict + model="", + ) + endpoint_url: Final = ( + request_params.s3_endpoint_url or f"https://s3.{aws_region_name}.{get_aws_dns_suffix(aws_region_name)}" + ).rstrip("/") + return _S3RequestTarget( + endpoint_url=endpoint_url, aws_region_name=aws_region_name, request_params=request_params + ) + + def _sign_s3_empty_body_request( + self, + method: str, api_base: str, aws_region_name: str, request_params: _BedrockS3RequestParams, - ) -> dict[str, str]: + ) -> Mapping[str, str]: """ - SigV4-sign an S3 GetObject request, mirroring `_sign_s3_request` (PUT). + SigV4-sign a bodiless S3 request (GetObject, DeleteObject, ListObjectsV2), + mirroring `_sign_s3_request` (PUT). """ try: import hashlib @@ -1297,13 +1432,13 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): empty_body_hash: Final = hashlib.sha256(b"").hexdigest() aws_request: Final = AWSRequest( # any-ok: botocore AWSRequest is untyped - method="GET", + method=method, url=api_base, - headers={"x-amz-content-sha256": empty_body_hash}, + headers={"x-amz-content-sha256": empty_body_hash}, # mutable-ok: botocore AWSRequest takes a dict ) auth: Final = S3SigV4Auth(credentials, "s3", aws_region_name) # any-ok: botocore untyped auth.add_auth(aws_request) # any-ok: botocore request mutation is untyped - return dict(aws_request.headers) # any-ok: botocore headers are untyped + return MappingProxyType(dict(aws_request.headers)) # any-ok: botocore headers are untyped def transform_file_content_response( self, diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index bf07f4748ef..00acc524eb8 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -1519,7 +1519,7 @@ async def list_files( if should_route and credentials is not None: # Use model-based routing with credentials from config - prepare_data_with_credentials(data=data, credentials=credentials) + prepare_data_with_credentials(data=data, credentials=credentials, include_internal_credentials=True) response = await litellm.afile_list( custom_llm_provider=credentials["custom_llm_provider"], purpose=purpose, @@ -1545,7 +1545,7 @@ async def list_files( model_id=target_model_names_list[0], operation_context="file list", ) - prepare_data_with_credentials(data=data, credentials=credentials) + prepare_data_with_credentials(data=data, credentials=credentials, include_internal_credentials=True) response = await litellm.afile_list( custom_llm_provider=credentials["custom_llm_provider"], purpose=purpose, diff --git a/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py b/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py index f3ad8a8592e..6556fec0262 100644 --- a/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py +++ b/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py @@ -1574,3 +1574,99 @@ async def test_batch_retrieve_hook_does_not_claim_attribution(): managed_files.store_unified_object_id.assert_awaited_once() assert managed_files.store_unified_object_id.await_args.kwargs["persist_attribution"] is False + + +@pytest.mark.asyncio +async def test_afile_delete_passes_trusted_model_credentials_to_router(): + """ + afile_delete must hand the deployment's credential snapshot to the router + call, since Bedrock validates the s3:// file id against the bucket in it. + """ + from types import MappingProxyType + + managed_files = _make_managed_files_instance() + unified_file_id = "unified-file-id" + s3_uri = "s3://my-bucket/litellm-bedrock-files/job-123/input.jsonl" + managed_files.get_model_file_id_mapping = AsyncMock(return_value={unified_file_id: {"model-123": s3_uri}}) + managed_files.delete_unified_file_id = AsyncMock(return_value=_make_file_object(unified_file_id)) + + mock_router = MagicMock() + mock_router.get_deployment_credentials_with_provider = MagicMock( + return_value={ + "custom_llm_provider": "bedrock", + "s3_bucket_name": "my-bucket", + "aws_region_name": "us-west-2", + } + ) + mock_router.afile_delete = AsyncMock(return_value=MagicMock()) + + await managed_files.afile_delete( + file_id=unified_file_id, + litellm_parent_otel_span=None, + llm_router=mock_router, + ) + + call_kwargs = mock_router.afile_delete.call_args.kwargs + assert call_kwargs["model"] == "model-123" + assert call_kwargs["file_id"] == s3_uri + trusted_credentials = call_kwargs["_litellm_internal_model_credentials"] + assert isinstance(trusted_credentials, MappingProxyType) + assert trusted_credentials["s3_bucket_name"] == "my-bucket" + + +@pytest.mark.asyncio +async def test_afile_delete_bedrock_unified_id_end_to_end(monkeypatch): + """ + Proxy repro for deleting a Bedrock batch input file by unified id: the + s3:// object must be removed via a SigV4-signed S3 DELETE using the + deployment's s3_bucket_name (no AWS_S3_BUCKET_NAME env). + + Regression test for "BedrockFilesConfig does not support file deletion" + raised on this path. + """ + import httpx + import respx + + import litellm + from litellm import Router + + monkeypatch.delenv("AWS_S3_BUCKET_NAME", raising=False) + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + litellm.in_memory_llm_clients_cache.flush_cache() + + router = Router( + model_list=[ + { + "model_name": "bedrock-claude", + "litellm_params": { + "model": "bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0", + "aws_access_key_id": "AKIAEXAMPLE", + "aws_secret_access_key": "secret", + "aws_region_name": "us-west-2", + "s3_bucket_name": "my-bucket", + }, + "model_info": {"id": "model-123"}, + } + ] + ) + + managed_files = _make_managed_files_instance() + unified_file_id = "unified-file-id" + s3_uri = "s3://my-bucket/litellm-bedrock-files/job-123/input.jsonl" + managed_files.get_model_file_id_mapping = AsyncMock(return_value={unified_file_id: {"model-123": s3_uri}}) + managed_files.delete_unified_file_id = AsyncMock(return_value=_make_file_object(unified_file_id)) + + expected_url = "https://s3.us-west-2.amazonaws.com/my-bucket/litellm-bedrock-files/job-123/input.jsonl" + with respx.mock: + route = respx.delete(expected_url).mock(return_value=httpx.Response(204)) + + response = await managed_files.afile_delete( + file_id=unified_file_id, + litellm_parent_otel_span=None, + llm_router=router, + ) + + assert route.called + assert route.calls[0].request.headers["Authorization"].startswith("AWS4-HMAC-SHA256") + assert response.id == unified_file_id + managed_files.delete_unified_file_id.assert_awaited_once_with(unified_file_id, None) diff --git a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py index 541c0db15d8..37195771e0d 100644 --- a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py +++ b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py @@ -1873,7 +1873,7 @@ class TestBedrockFileContentTransformation: import hashlib from litellm.llms.bedrock.files.transformation import ( - S3_SIGNED_GET_HEADERS_PARAM, + S3_SIGNED_REQUEST_HEADERS_PARAM, BedrockFilesConfig, ) @@ -1889,7 +1889,7 @@ class TestBedrockFileContentTransformation: assert url == self.EXPECTED_URL assert params == {} - signed_headers = litellm_params[S3_SIGNED_GET_HEADERS_PARAM] + signed_headers = litellm_params[S3_SIGNED_REQUEST_HEADERS_PARAM] content_hashes = { value for name, value in signed_headers.items() @@ -2139,7 +2139,7 @@ class TestBedrockFileContentTransformation: def test_s3_region_name_wins_for_content_signing(self, monkeypatch): """s3_region_name must override aws_region_name for both the URL and the signature.""" from litellm.llms.bedrock.files.transformation import ( - S3_SIGNED_GET_HEADERS_PARAM, + S3_SIGNED_REQUEST_HEADERS_PARAM, BedrockFilesConfig, ) @@ -2154,17 +2154,17 @@ class TestBedrockFileContentTransformation: ) assert url.startswith("https://s3.eu-west-1.amazonaws.com/") - authorization = litellm_params[S3_SIGNED_GET_HEADERS_PARAM]["Authorization"] + authorization = litellm_params[S3_SIGNED_REQUEST_HEADERS_PARAM]["Authorization"] assert "/eu-west-1/s3/aws4_request" in authorization def test_validate_environment_merges_and_pops_signed_get_headers(self): from litellm.llms.bedrock.files.transformation import ( - S3_SIGNED_GET_HEADERS_PARAM, + S3_SIGNED_REQUEST_HEADERS_PARAM, BedrockFilesConfig, ) litellm_params = { - S3_SIGNED_GET_HEADERS_PARAM: {"Authorization": "AWS4-HMAC-SHA256 test"} + S3_SIGNED_REQUEST_HEADERS_PARAM: {"Authorization": "AWS4-HMAC-SHA256 test"} } headers = BedrockFilesConfig().validate_environment( @@ -2179,7 +2179,7 @@ class TestBedrockFileContentTransformation: "x-custom": "kept", "Authorization": "AWS4-HMAC-SHA256 test", } - assert S3_SIGNED_GET_HEADERS_PARAM not in litellm_params + assert S3_SIGNED_REQUEST_HEADERS_PARAM not in litellm_params def test_transform_file_content_response_wraps_binary_content(self): import httpx @@ -2379,7 +2379,7 @@ class TestBedrockFilesS3SignatureEncoding: self, monkeypatch: pytest.MonkeyPatch ) -> None: from litellm.llms.bedrock.files.transformation import ( - S3_SIGNED_GET_HEADERS_PARAM, + S3_SIGNED_REQUEST_HEADERS_PARAM, BedrockFilesConfig, ) @@ -2402,7 +2402,7 @@ class TestBedrockFilesS3SignatureEncoding: method="GET", url=url, body=None, - headers=litellm_params[S3_SIGNED_GET_HEADERS_PARAM], + headers=litellm_params[S3_SIGNED_REQUEST_HEADERS_PARAM], ) @@ -2457,7 +2457,7 @@ def test_sign_s3_request_assumes_role_with_external_id(monkeypatch): assert "ASIAFILESPUTROLE" in authorization -def test_sign_s3_get_request_assumes_role_with_external_id(monkeypatch): +def test_sign_s3_empty_body_request_assumes_role_with_external_id(monkeypatch): """A trust policy requiring sts:ExternalId must be satisfied when signing the S3 download request.""" import datetime from unittest.mock import patch @@ -2504,7 +2504,8 @@ def test_sign_s3_get_request_assumes_role_with_external_id(monkeypatch): assert request_params.aws_external_id == "external-id-files-get" with patch.object(boto3, "client", return_value=FakeSTSClient()): - signed_headers = BedrockFilesConfig()._sign_s3_get_request( + signed_headers = BedrockFilesConfig()._sign_s3_empty_body_request( + method="GET", api_base="https://s3.us-east-1.amazonaws.com/safe-bucket/litellm-bedrock-files-model-id-abc.jsonl", aws_region_name="us-east-1", request_params=request_params, @@ -2512,3 +2513,449 @@ def test_sign_s3_get_request_assumes_role_with_external_id(monkeypatch): authorization = {key.lower(): value for key, value in signed_headers.items()}["authorization"] assert "ASIAFILESGETROLE" in authorization + + +def _s3_signature_for(method: str, url: str, headers: Mapping[str, str]) -> str: + sent = {name.lower(): value for name, value in headers.items()} + signed_names = sent["authorization"].split("SignedHeaders=")[1].split(",")[0].split(";") + request = AWSRequest( + method=method, + url=url, + headers={name: sent[name] for name in signed_names if name in sent}, + ) + request.context["timestamp"] = sent["x-amz-date"] + signer = S3SigV4Auth(Credentials("AKIAEXAMPLE", "secret"), "s3", "us-west-2") + return signer.signature(signer.string_to_sign(request, signer.canonical_request(request)), request) + + +def _sent_signature(headers: Mapping[str, str]) -> str: + authorization = {name.lower(): value for name, value in headers.items()}["authorization"] + return authorization.split("Signature=")[1].strip() + + +def _bedrock_s3_params() -> dict: + return { + "aws_access_key_id": "AKIAEXAMPLE", + "aws_secret_access_key": "secret", + "aws_region_name": "us-west-2", + } + + +def _trusted_bucket_snapshot(**deployment_litellm_params) -> dict: + from types import MappingProxyType + + from litellm.types.router import CredentialLiteLLMParams + + snapshot = CredentialLiteLLMParams(**deployment_litellm_params).model_dump(exclude_none=True) + return {**_bedrock_s3_params(), "_litellm_internal_model_credentials": MappingProxyType(snapshot)} + + +class TestBedrockFileDeletionTransformation: + """SigV4-signed S3 DeleteObject for LiteLLM-managed Bedrock batch files.""" + + S3_URI = "s3://my-bucket/litellm-bedrock-files/job-123/input.jsonl" + EXPECTED_URL = "https://s3.us-west-2.amazonaws.com/my-bucket/litellm-bedrock-files/job-123/input.jsonl" + + def test_transform_delete_file_request_signs_s3_delete(self, monkeypatch): + import hashlib + + from litellm.llms.bedrock.files.transformation import ( + DELETED_FILE_ID_PARAM, + S3_SIGNED_REQUEST_HEADERS_PARAM, + BedrockFilesConfig, + ) + + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") + litellm_params = _bedrock_s3_params() + + url, params = BedrockFilesConfig().transform_delete_file_request( + file_id=self.S3_URI, + optional_params={}, + litellm_params=litellm_params, + ) + + assert url == self.EXPECTED_URL + assert params == {} + signed_headers = litellm_params[S3_SIGNED_REQUEST_HEADERS_PARAM] + lowered = {name.lower(): value for name, value in signed_headers.items()} + assert lowered["x-amz-content-sha256"] == hashlib.sha256(b"").hexdigest() + assert "/us-west-2/s3/aws4_request" in lowered["authorization"] + assert _sent_signature(signed_headers) == _s3_signature_for("DELETE", url, signed_headers) + assert litellm_params[DELETED_FILE_ID_PARAM] == self.S3_URI + + def test_transform_delete_file_request_decodes_unified_file_id(self, monkeypatch): + import base64 + + from litellm.llms.bedrock.files.transformation import ( + DELETED_FILE_ID_PARAM, + BedrockFilesConfig, + ) + from litellm.types.utils import SpecialEnums + + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") + unified_file_id = SpecialEnums.LITELLM_MANAGED_FILE_COMPLETE_STR.value.format( + "application/json", "unified-id", "", self.S3_URI, "model-id" + ) + encoded_file_id = base64.urlsafe_b64encode(unified_file_id.encode()).decode().rstrip("=") + litellm_params = _bedrock_s3_params() + + url, _ = BedrockFilesConfig().transform_delete_file_request( + file_id=encoded_file_id, + optional_params={}, + litellm_params=litellm_params, + ) + + assert url == self.EXPECTED_URL + assert litellm_params[DELETED_FILE_ID_PARAM] == encoded_file_id + + def test_transform_delete_file_request_uses_trusted_snapshot_bucket(self, monkeypatch): + from litellm.llms.bedrock.files.transformation import BedrockFilesConfig + + monkeypatch.delenv("AWS_S3_BUCKET_NAME", raising=False) + + url, _ = BedrockFilesConfig().transform_delete_file_request( + file_id=self.S3_URI, + optional_params={}, + litellm_params=_trusted_bucket_snapshot(s3_bucket_name="my-bucket"), + ) + + assert url == self.EXPECTED_URL + + def test_transform_delete_file_request_rejects_foreign_bucket(self, monkeypatch): + from litellm.llms.bedrock.files.transformation import BedrockFilesConfig + + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") + + with pytest.raises(ValueError, match="configured storage bucket"): + BedrockFilesConfig().transform_delete_file_request( + file_id="s3://other-bucket/litellm-bedrock-files/job-123/input.jsonl", + optional_params={}, + litellm_params=_bedrock_s3_params(), + ) + + def test_transform_delete_file_request_rejects_unmanaged_key(self, monkeypatch): + from litellm.llms.bedrock.files.transformation import BedrockFilesConfig + + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") + + with pytest.raises(ValueError, match="LiteLLM-managed"): + BedrockFilesConfig().transform_delete_file_request( + file_id="s3://my-bucket/private/x.jsonl", + optional_params={}, + litellm_params=_bedrock_s3_params(), + ) + + def test_transform_delete_file_response_echoes_the_deleted_id(self): + import httpx + + from litellm.llms.bedrock.files.transformation import ( + DELETED_FILE_ID_PARAM, + BedrockFilesConfig, + ) + + deleted = BedrockFilesConfig().transform_delete_file_response( + raw_response=httpx.Response(204), + logging_obj=MagicMock(), + litellm_params={DELETED_FILE_ID_PARAM: self.S3_URI}, + ) + + assert deleted.id == self.S3_URI + assert deleted.deleted is True + assert deleted.object == "file" + + def test_transform_delete_file_response_raises_on_s3_error(self): + import httpx + + from litellm.llms.bedrock.common_utils import BedrockError + from litellm.llms.bedrock.files.transformation import BedrockFilesConfig + + with pytest.raises(BedrockError) as excinfo: + BedrockFilesConfig().transform_delete_file_response( + raw_response=httpx.Response(403, text="AccessDenied"), + logging_obj=MagicMock(), + litellm_params={}, + ) + + assert excinfo.value.status_code == 403 + + def test_file_delete_end_to_end_sends_signed_delete(self, monkeypatch): + import httpx + import respx + + import litellm + + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") + + with respx.mock: + route = respx.delete(self.EXPECTED_URL).mock(return_value=httpx.Response(204)) + + response = litellm.file_delete( + file_id=self.S3_URI, + custom_llm_provider="bedrock", + **_bedrock_s3_params(), + ) + + assert route.called + request = route.calls[0].request + assert request.headers["Authorization"].startswith("AWS4-HMAC-SHA256") + assert _sent_signature(request.headers) == _s3_signature_for("DELETE", str(request.url), request.headers) + assert response.id == self.S3_URI + assert response.deleted is True + + @pytest.mark.asyncio + async def test_afile_delete_end_to_end_sends_signed_delete(self, monkeypatch): + import httpx + import respx + + import litellm + + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + litellm.in_memory_llm_clients_cache.flush_cache() + + with respx.mock: + route = respx.delete(self.EXPECTED_URL).mock(return_value=httpx.Response(204)) + + response = await litellm.afile_delete( + file_id=self.S3_URI, + custom_llm_provider="bedrock", + **_bedrock_s3_params(), + ) + + assert route.called + request = route.calls[0].request + assert _sent_signature(request.headers) == _s3_signature_for("DELETE", str(request.url), request.headers) + assert response.id == self.S3_URI + assert response.deleted is True + + +class TestBedrockFileListTransformation: + """SigV4-signed S3 ListObjectsV2 over the LiteLLM-managed key prefixes.""" + + BUCKET_URL = "https://s3.us-west-2.amazonaws.com/my-bucket/" + MANAGED_QUERY = {"list-type": "2", "prefix": "litellm-b"} + LISTING = b""" + + my-bucket + litellm-b + 4 + false + + litellm-bedrock-files-model-abc.jsonl + 2026-09-01T10:00:00.000Z + 120 + + + litellm-bedrock-files/job-123/input.jsonl + 2026-09-02T11:30:00.000Z + 340 + + + litellm-batch-outputs/job-123/input.jsonl.out + 2026-09-03T12:45:00.000Z + 560 + + + litellm-bogus/other.jsonl + 2026-09-03T12:45:00.000Z + 1 + +""" + BATCH_IDS = ( + "s3://my-bucket/litellm-bedrock-files-model-abc.jsonl", + "s3://my-bucket/litellm-bedrock-files/job-123/input.jsonl", + ) + OUTPUT_ID = "s3://my-bucket/litellm-batch-outputs/job-123/input.jsonl.out" + + def test_transform_list_files_request_signs_managed_prefix_listing(self, monkeypatch): + from litellm.llms.bedrock.files.transformation import ( + LIST_FILES_PURPOSE_PARAM, + S3_SIGNED_REQUEST_HEADERS_PARAM, + BedrockFilesConfig, + ) + + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") + litellm_params = _bedrock_s3_params() + + url, params = BedrockFilesConfig().transform_list_files_request( + purpose="batch", + optional_params={}, + litellm_params=litellm_params, + ) + + assert url == self.BUCKET_URL + assert params == self.MANAGED_QUERY + signed_headers = litellm_params[S3_SIGNED_REQUEST_HEADERS_PARAM] + assert _sent_signature(signed_headers) == _s3_signature_for( + "GET", f"{url}?list-type=2&prefix=litellm-b", signed_headers + ) + assert litellm_params[LIST_FILES_PURPOSE_PARAM] == "batch" + + def test_transform_list_files_request_scopes_to_configured_prefix(self, monkeypatch): + from litellm.llms.bedrock.files.transformation import ( + S3_SIGNED_REQUEST_HEADERS_PARAM, + BedrockFilesConfig, + ) + + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket/LLM AI Projects") + litellm_params = _bedrock_s3_params() + + url, params = BedrockFilesConfig().transform_list_files_request( + purpose=None, + optional_params={}, + litellm_params=litellm_params, + ) + + assert url == self.BUCKET_URL + assert params == {"list-type": "2", "prefix": "LLM AI Projects/litellm-b"} + signed_headers = litellm_params[S3_SIGNED_REQUEST_HEADERS_PARAM] + assert _sent_signature(signed_headers) == _s3_signature_for( + "GET", f"{url}?list-type=2&prefix=LLM%20AI%20Projects%2Flitellm-b", signed_headers + ) + + def test_transform_list_files_request_uses_trusted_snapshot_bucket(self, monkeypatch): + from litellm.llms.bedrock.files.transformation import BedrockFilesConfig + + monkeypatch.delenv("AWS_S3_BUCKET_NAME", raising=False) + + url, params = BedrockFilesConfig().transform_list_files_request( + purpose=None, + optional_params={}, + litellm_params=_trusted_bucket_snapshot(s3_bucket_name="my-bucket"), + ) + + assert url == self.BUCKET_URL + assert params == self.MANAGED_QUERY + + def _list_response(self, purpose: str | None, listing: bytes | None = None, status_code: int = 200): + import httpx + + from litellm.llms.bedrock.files.transformation import ( + LIST_FILES_PURPOSE_PARAM, + BedrockFilesConfig, + ) + + return BedrockFilesConfig().transform_list_files_response( + raw_response=httpx.Response(status_code, content=listing if listing is not None else self.LISTING), + logging_obj=MagicMock(), + litellm_params={**_bedrock_s3_params(), LIST_FILES_PURPOSE_PARAM: purpose}, + ) + + def test_transform_list_files_response_maps_managed_objects(self, monkeypatch): + from datetime import datetime, timezone + + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") + + files = self._list_response(purpose=None) + + assert [file.id for file in files] == [*self.BATCH_IDS, self.OUTPUT_ID] + assert [file.purpose for file in files] == ["batch", "batch", "batch_output"] + assert [file.bytes for file in files] == [120, 340, 560] + assert [file.filename for file in files] == [ + "litellm-bedrock-files-model-abc.jsonl", + "input.jsonl", + "input.jsonl.out", + ] + assert files[1].created_at == int(datetime(2026, 9, 2, 11, 30, tzinfo=timezone.utc).timestamp()) + assert {file.object for file in files} == {"file"} + assert {file.status for file in files} == {"uploaded"} + + def test_transform_list_files_response_filters_by_purpose(self, monkeypatch): + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") + + assert [file.id for file in self._list_response(purpose="batch")] == list(self.BATCH_IDS) + assert [file.id for file in self._list_response(purpose="batch_output")] == [self.OUTPUT_ID] + + def test_transform_list_files_response_scopes_to_configured_prefix(self, monkeypatch): + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket/team-a") + listing = b""" + + my-bucket + team-a/litellm-bedrock-files/job-1/input.jsonl10 + team-a/litellm-batch-outputs/job-1/input.jsonl.out20 + litellm-bedrock-files/job-2/input.jsonl30 +""" + + files = self._list_response(purpose=None, listing=listing) + + assert [(file.id, file.purpose) for file in files] == [ + ("s3://my-bucket/team-a/litellm-bedrock-files/job-1/input.jsonl", "batch"), + ("s3://my-bucket/team-a/litellm-batch-outputs/job-1/input.jsonl.out", "batch_output"), + ] + + def test_transform_list_files_response_raises_on_s3_error(self, monkeypatch): + from litellm.llms.bedrock.common_utils import BedrockError + + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") + + with pytest.raises(BedrockError) as excinfo: + self._list_response(purpose=None, listing=b"AccessDenied", status_code=403) + + assert excinfo.value.status_code == 403 + + def test_file_list_end_to_end_sends_signed_listing(self, monkeypatch): + import httpx + import respx + + import litellm + + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") + + with respx.mock: + route = respx.get(self.BUCKET_URL, params__contains=self.MANAGED_QUERY).mock( + return_value=httpx.Response(200, content=self.LISTING) + ) + + files = litellm.file_list(custom_llm_provider="bedrock", purpose="batch", **_bedrock_s3_params()) + + assert route.called + request = route.calls[0].request + assert request.headers["Authorization"].startswith("AWS4-HMAC-SHA256") + assert _sent_signature(request.headers) == _s3_signature_for("GET", str(request.url), request.headers) + assert [file.id for file in files] == list(self.BATCH_IDS) + + def test_file_list_uses_trusted_snapshot_bucket_without_env(self, monkeypatch): + import httpx + import respx + + import litellm + + monkeypatch.delenv("AWS_S3_BUCKET_NAME", raising=False) + + with respx.mock: + route = respx.get(self.BUCKET_URL, params__contains=self.MANAGED_QUERY).mock( + return_value=httpx.Response(200, content=self.LISTING) + ) + + files = litellm.file_list( + custom_llm_provider="bedrock", + **_trusted_bucket_snapshot(s3_bucket_name="my-bucket"), + ) + + assert route.called + assert [file.id for file in files] == [*self.BATCH_IDS, self.OUTPUT_ID] + + @pytest.mark.asyncio + async def test_afile_list_end_to_end_sends_signed_listing(self, monkeypatch): + import httpx + import respx + + import litellm + + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + litellm.in_memory_llm_clients_cache.flush_cache() + + with respx.mock: + route = respx.get(self.BUCKET_URL, params__contains=self.MANAGED_QUERY).mock( + return_value=httpx.Response(200, content=self.LISTING) + ) + + files = await litellm.afile_list( + custom_llm_provider="bedrock", purpose="batch_output", **_bedrock_s3_params() + ) + + assert route.called + request = route.calls[0].request + assert _sent_signature(request.headers) == _s3_signature_for("GET", str(request.url), request.headers) + assert [file.id for file in files] == [self.OUTPUT_ID] diff --git a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py index bca97915347..a4b36487330 100644 --- a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py +++ b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py @@ -4666,3 +4666,70 @@ def test_create_file_path_traversal_filename_rejected_before_forwarding(monkeypa assert error["param"] == "file" assert "traversal" in error["message"].lower() assert forwarded_calls == [] + + +def test_list_files_target_model_names_passes_trusted_bedrock_credentials( + mocker: MockerFixture, monkeypatch +): + """ + GET /v1/files?target_model_names= must hand the deployment's + immutable credential snapshot to litellm.afile_list, since Bedrock resolves + the S3 bucket to list from that snapshot rather than from request params. + """ + from types import MappingProxyType + + import litellm.proxy.proxy_server as ps + from litellm.proxy._types import LitellmUserRoles + + bedrock_router = Router( + model_list=[ + { + "model_name": "bedrock-claude", + "litellm_params": { + "model": "bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0", + "aws_access_key_id": "AKIAEXAMPLE", + "aws_secret_access_key": "secret", + "aws_region_name": "us-west-2", + "s3_bucket_name": "my-bucket", + }, + }, + ] + ) + + proxy_logging_obj = setup_proxy_logging_object(monkeypatch, bedrock_router) + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", bedrock_router) + proxy_logging_obj.update_request_status = mocker.AsyncMock() + proxy_logging_obj.post_call_success_hook = mocker.AsyncMock(return_value=[]) + proxy_logging_obj.post_call_failure_hook = mocker.AsyncMock() + + captured_kwargs: dict = {} + + async def _mock_afile_list(**kwargs): + captured_kwargs.update(kwargs) + return [] + + monkeypatch.setattr(litellm, "afile_list", _mock_afile_list) + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + api_key="test-key", + user_role=LitellmUserRoles.PROXY_ADMIN, + user_id="test-user", + ) + + try: + response = client.get( + "/v1/files?target_model_names=bedrock-claude&purpose=batch", + headers={"Authorization": "Bearer test-key"}, + ) + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + assert response.status_code == 200, response.text + assert captured_kwargs["custom_llm_provider"] == "bedrock" + assert captured_kwargs["purpose"] == "batch" + trusted_credentials = captured_kwargs["_litellm_internal_model_credentials"] + assert isinstance(trusted_credentials, MappingProxyType) + assert trusted_credentials["s3_bucket_name"] == "my-bucket" + proxy_logging_obj.post_call_failure_hook.assert_not_called() From e84e5d03bdf9ea6fefd64110f45bbc939faca121 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:52:52 -0700 Subject: [PATCH 02/12] fix(bedrock): list the configured output bucket for purpose=batch_output --- litellm/llms/bedrock/files/transformation.py | 38 ++++-- .../test_bedrock_files_transformation.py | 124 +++++++++++++++++- 2 files changed, 150 insertions(+), 12 deletions(-) diff --git a/litellm/llms/bedrock/files/transformation.py b/litellm/llms/bedrock/files/transformation.py index 41ea206a250..7d133b411a7 100644 --- a/litellm/llms/bedrock/files/transformation.py +++ b/litellm/llms/bedrock/files/transformation.py @@ -263,9 +263,30 @@ def _validate_file_id_against_configured_buckets( return validate_against(configured_bucket_names[-1]) -def _managed_listing_prefix(configured_prefix: str) -> str: - common_prefix: Final = os.path.commonprefix(BEDROCK_MANAGED_S3_PREFIXES) - return f"{configured_prefix}/{common_prefix}" if configured_prefix else common_prefix +_ANY_MANAGED_LISTING_PREFIX: Final = os.path.commonprefix(BEDROCK_MANAGED_S3_PREFIXES) +_MANAGED_LISTING_PREFIX_BY_PURPOSE: Final = MappingProxyType( + { + "batch": os.path.commonprefix((BEDROCK_MANAGED_S3_BATCH_PREFIX, BEDROCK_MANAGED_S3_UPLOAD_PREFIX)), + "batch_output": BEDROCK_MANAGED_S3_OUTPUT_PREFIX, + } +) + + +def _managed_listing_prefix(configured_prefix: str, purpose: str | None) -> str: + managed_prefix: Final = ( + _MANAGED_LISTING_PREFIX_BY_PURPOSE.get(purpose, _ANY_MANAGED_LISTING_PREFIX) + if purpose + else _ANY_MANAGED_LISTING_PREFIX + ) + return f"{configured_prefix}/{managed_prefix}" if configured_prefix else managed_prefix + + +def _listing_bucket_name(litellm_params: Mapping[str, object], purpose: str | None) -> str: + input_bucket_name: Final = get_configured_s3_bucket_name(litellm_params) + if purpose != "batch_output": + return input_bucket_name + trusted: Final = _trusted_s3_model_credentials(litellm_params) + return trusted.s3_output_bucket_name or os.getenv("AWS_S3_OUTPUT_BUCKET_NAME") or input_bucket_name def _listed_object_created_at(entry: ET.Element) -> int: @@ -1291,13 +1312,13 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): litellm_params: MutableMapping[str, object], ) -> tuple[str, dict[str, str]]: bucket_name, configured_prefix = split_configured_cloud_bucket_name( - get_configured_s3_bucket_name(litellm_params) + _listing_bucket_name(litellm_params, purpose) ) target: Final = self._s3_request_target(optional_params=optional_params, litellm_params=litellm_params) url: Final = f"{target.endpoint_url}/{bucket_name}/" query: Final[dict[str, str]] = { # mutable-ok: the base files contract returns the query as a dict "list-type": "2", - "prefix": _managed_listing_prefix(configured_prefix), + "prefix": _managed_listing_prefix(configured_prefix, purpose), } signed_headers: Final = self._sign_s3_empty_body_request( method="GET", @@ -1321,9 +1342,10 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): message=raw_response.text, headers=raw_response.headers, ) - configured_bucket_name: Final = get_configured_s3_bucket_name(litellm_params) - allow_legacy_cloud_file_ids: Final = should_allow_legacy_cloud_file_ids(litellm_params) requested_purpose: Final = litellm_params.get(LIST_FILES_PURPOSE_PARAM) + purpose: Final = requested_purpose if isinstance(requested_purpose, str) else None + configured_bucket_name: Final = _listing_bucket_name(litellm_params, purpose) + allow_legacy_cloud_file_ids: Final = should_allow_legacy_cloud_file_ids(litellm_params) listing: Final = ET.fromstring(raw_response.content) bucket_name: Final = ( listing.findtext("{*}Name") or split_configured_cloud_bucket_name(configured_bucket_name)[0] @@ -1335,7 +1357,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): return [ # mutable-ok: the base files contract returns a list listed_file for listed_file in listed_files - if listed_file is not None and (requested_purpose is None or listed_file.purpose == requested_purpose) + if listed_file is not None and (purpose is None or listed_file.purpose == purpose) ] def transform_file_content_request( diff --git a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py index 37195771e0d..ceac641bbf0 100644 --- a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py +++ b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py @@ -2734,6 +2734,20 @@ class TestBedrockFileListTransformation: BUCKET_URL = "https://s3.us-west-2.amazonaws.com/my-bucket/" MANAGED_QUERY = {"list-type": "2", "prefix": "litellm-b"} + BATCH_QUERY = {"list-type": "2", "prefix": "litellm-bedrock-files"} + OUTPUT_QUERY = {"list-type": "2", "prefix": "litellm-batch-outputs/"} + OUTPUT_BUCKET_URL = "https://s3.us-west-2.amazonaws.com/my-output-bucket/" + OUTPUT_BUCKET_LISTING = b""" + + my-output-bucket + litellm-batch-outputs/ + + litellm-batch-outputs/job-9/input.jsonl.out + 2026-09-04T08:00:00.000Z + 70 + +""" + OUTPUT_BUCKET_ID = "s3://my-output-bucket/litellm-batch-outputs/job-9/input.jsonl.out" LISTING = b""" my-bucket @@ -2784,10 +2798,10 @@ class TestBedrockFileListTransformation: ) assert url == self.BUCKET_URL - assert params == self.MANAGED_QUERY + assert params == self.BATCH_QUERY signed_headers = litellm_params[S3_SIGNED_REQUEST_HEADERS_PARAM] assert _sent_signature(signed_headers) == _s3_signature_for( - "GET", f"{url}?list-type=2&prefix=litellm-b", signed_headers + "GET", f"{url}?list-type=2&prefix=litellm-bedrock-files", signed_headers ) assert litellm_params[LIST_FILES_PURPOSE_PARAM] == "batch" @@ -2902,7 +2916,7 @@ class TestBedrockFileListTransformation: monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") with respx.mock: - route = respx.get(self.BUCKET_URL, params__contains=self.MANAGED_QUERY).mock( + route = respx.get(self.BUCKET_URL, params__contains=self.BATCH_QUERY).mock( return_value=httpx.Response(200, content=self.LISTING) ) @@ -2947,7 +2961,7 @@ class TestBedrockFileListTransformation: litellm.in_memory_llm_clients_cache.flush_cache() with respx.mock: - route = respx.get(self.BUCKET_URL, params__contains=self.MANAGED_QUERY).mock( + route = respx.get(self.BUCKET_URL, params__contains=self.OUTPUT_QUERY).mock( return_value=httpx.Response(200, content=self.LISTING) ) @@ -2959,3 +2973,105 @@ class TestBedrockFileListTransformation: request = route.calls[0].request assert _sent_signature(request.headers) == _s3_signature_for("GET", str(request.url), request.headers) assert [file.id for file in files] == [self.OUTPUT_ID] + + def test_transform_list_files_request_narrows_prefix_to_requested_purpose(self, monkeypatch): + from litellm.llms.bedrock.files.transformation import BedrockFilesConfig + + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") + monkeypatch.delenv("AWS_S3_OUTPUT_BUCKET_NAME", raising=False) + + batch_url, batch_params = BedrockFilesConfig().transform_list_files_request( + purpose="batch", optional_params={}, litellm_params=_bedrock_s3_params() + ) + output_url, output_params = BedrockFilesConfig().transform_list_files_request( + purpose="batch_output", optional_params={}, litellm_params=_bedrock_s3_params() + ) + + assert (batch_url, batch_params) == (self.BUCKET_URL, self.BATCH_QUERY) + assert (output_url, output_params) == (self.BUCKET_URL, self.OUTPUT_QUERY) + + def test_transform_list_files_request_lists_configured_output_bucket_for_batch_output(self, monkeypatch): + from litellm.llms.bedrock.files.transformation import ( + S3_SIGNED_REQUEST_HEADERS_PARAM, + BedrockFilesConfig, + ) + + monkeypatch.delenv("AWS_S3_BUCKET_NAME", raising=False) + monkeypatch.delenv("AWS_S3_OUTPUT_BUCKET_NAME", raising=False) + litellm_params = _trusted_bucket_snapshot( + s3_bucket_name="my-bucket", s3_output_bucket_name="my-output-bucket/team-a" + ) + + url, params = BedrockFilesConfig().transform_list_files_request( + purpose="batch_output", optional_params={}, litellm_params=litellm_params + ) + signed_headers = litellm_params[S3_SIGNED_REQUEST_HEADERS_PARAM] + input_url, input_params = BedrockFilesConfig().transform_list_files_request( + purpose="batch", optional_params={}, litellm_params=dict(litellm_params) + ) + + assert url == self.OUTPUT_BUCKET_URL + assert params == {"list-type": "2", "prefix": "team-a/litellm-batch-outputs/"} + assert _sent_signature(signed_headers) == _s3_signature_for( + "GET", f"{url}?list-type=2&prefix=team-a%2Flitellm-batch-outputs%2F", signed_headers + ) + assert (input_url, input_params) == (self.BUCKET_URL, self.BATCH_QUERY) + + def test_transform_list_files_request_reads_output_bucket_from_env(self, monkeypatch): + from litellm.llms.bedrock.files.transformation import BedrockFilesConfig + + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") + monkeypatch.setenv("AWS_S3_OUTPUT_BUCKET_NAME", "my-output-bucket") + + url, params = BedrockFilesConfig().transform_list_files_request( + purpose="batch_output", optional_params={}, litellm_params=_bedrock_s3_params() + ) + + assert (url, params) == (self.OUTPUT_BUCKET_URL, self.OUTPUT_QUERY) + + def test_transform_list_files_response_accepts_output_bucket_objects(self, monkeypatch): + import httpx + + from litellm.llms.bedrock.files.transformation import ( + LIST_FILES_PURPOSE_PARAM, + BedrockFilesConfig, + ) + + monkeypatch.delenv("AWS_S3_BUCKET_NAME", raising=False) + monkeypatch.delenv("AWS_S3_OUTPUT_BUCKET_NAME", raising=False) + + files = BedrockFilesConfig().transform_list_files_response( + raw_response=httpx.Response(200, content=self.OUTPUT_BUCKET_LISTING), + logging_obj=MagicMock(), + litellm_params={ + **_trusted_bucket_snapshot(s3_bucket_name="my-bucket", s3_output_bucket_name="my-output-bucket"), + LIST_FILES_PURPOSE_PARAM: "batch_output", + }, + ) + + assert [(file.id, file.purpose, file.bytes) for file in files] == [(self.OUTPUT_BUCKET_ID, "batch_output", 70)] + + def test_file_list_batch_output_end_to_end_lists_output_bucket(self, monkeypatch): + import httpx + import respx + + import litellm + + monkeypatch.delenv("AWS_S3_BUCKET_NAME", raising=False) + monkeypatch.delenv("AWS_S3_OUTPUT_BUCKET_NAME", raising=False) + + with respx.mock: + route = respx.get(self.OUTPUT_BUCKET_URL, params__contains=self.OUTPUT_QUERY).mock( + return_value=httpx.Response(200, content=self.OUTPUT_BUCKET_LISTING) + ) + + files = litellm.file_list( + custom_llm_provider="bedrock", + purpose="batch_output", + **_trusted_bucket_snapshot(s3_bucket_name="my-bucket", s3_output_bucket_name="my-output-bucket"), + ) + + assert route.called + request = route.calls[0].request + assert _sent_signature(request.headers) == _s3_signature_for("GET", str(request.url), request.headers) + assert [file.id for file in files] == [self.OUTPUT_BUCKET_ID] From 6a6080a1529038fa38d692dd482d25fac5a2a8e3 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:30:52 -0700 Subject: [PATCH 03/12] feat(bedrock): follow S3 continuation tokens when listing managed files --- litellm/llms/base_llm/files/transformation.py | 11 +- litellm/llms/bedrock/files/transformation.py | 43 ++++- litellm/llms/custom_httpx/llm_http_handler.py | 90 ++++++++++- .../test_bedrock_files_transformation.py | 148 ++++++++++++++++++ 4 files changed, 276 insertions(+), 16 deletions(-) diff --git a/litellm/llms/base_llm/files/transformation.py b/litellm/llms/base_llm/files/transformation.py index 7a7088c2fb5..3f8fec354b7 100644 --- a/litellm/llms/base_llm/files/transformation.py +++ b/litellm/llms/base_llm/files/transformation.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from collections.abc import Iterator +from collections.abc import Iterator, Mapping from typing import TYPE_CHECKING, Any, Union import httpx @@ -160,6 +160,15 @@ class BaseFilesConfig(BaseConfig): ) -> tuple[str, dict]: """Transform file list request into provider-specific format.""" + def transform_list_files_next_request( + self, + raw_response: httpx.Response, + optional_params: Mapping[str, object], + litellm_params: dict, # mutable-ok: carries provider stashes from the request transform to the response one + ) -> tuple[str, dict[str, str]] | None: + """Request for the page after `raw_response`, or None once the listing is complete.""" + return None + @abstractmethod def transform_list_files_response( self, diff --git a/litellm/llms/bedrock/files/transformation.py b/litellm/llms/bedrock/files/transformation.py index 7d133b411a7..1266397636a 100644 --- a/litellm/llms/bedrock/files/transformation.py +++ b/litellm/llms/bedrock/files/transformation.py @@ -281,6 +281,11 @@ def _managed_listing_prefix(configured_prefix: str, purpose: str | None) -> str: return f"{configured_prefix}/{managed_prefix}" if configured_prefix else managed_prefix +def _requested_listing_purpose(litellm_params: Mapping[str, object]) -> str | None: + requested_purpose: Final = litellm_params.get(LIST_FILES_PURPOSE_PARAM) + return requested_purpose if isinstance(requested_purpose, str) else None + + def _listing_bucket_name(litellm_params: Mapping[str, object], purpose: str | None) -> str: input_bucket_name: Final = get_configured_s3_bucket_name(litellm_params) if purpose != "batch_output": @@ -1310,16 +1315,42 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): purpose: str | None, optional_params: Mapping[str, object], litellm_params: MutableMapping[str, object], + ) -> tuple[str, dict[str, str]]: + litellm_params[LIST_FILES_PURPOSE_PARAM] = purpose # rebind-ok: handed to the response transform + return self._signed_listing_request(purpose, optional_params, litellm_params, continuation_token=None) + + def transform_list_files_next_request( + self, + raw_response: httpx.Response, + optional_params: Mapping[str, object], + litellm_params: MutableMapping[str, object], + ) -> tuple[str, dict[str, str]] | None: + if raw_response.status_code >= 400: + return None + continuation_token: Final = ET.fromstring(raw_response.content).findtext("{*}NextContinuationToken") + if not continuation_token: + return None + return self._signed_listing_request( + _requested_listing_purpose(litellm_params), optional_params, litellm_params, continuation_token + ) + + def _signed_listing_request( + self, + purpose: str | None, + optional_params: Mapping[str, object], + litellm_params: MutableMapping[str, object], + continuation_token: str | None, ) -> tuple[str, dict[str, str]]: bucket_name, configured_prefix = split_configured_cloud_bucket_name( _listing_bucket_name(litellm_params, purpose) ) target: Final = self._s3_request_target(optional_params=optional_params, litellm_params=litellm_params) url: Final = f"{target.endpoint_url}/{bucket_name}/" - query: Final[dict[str, str]] = { # mutable-ok: the base files contract returns the query as a dict - "list-type": "2", - "prefix": _managed_listing_prefix(configured_prefix, purpose), - } + listing_query: Final = (("list-type", "2"), ("prefix", _managed_listing_prefix(configured_prefix, purpose))) + continuation_query: Final = (("continuation-token", continuation_token),) if continuation_token else () + query: Final[dict[str, str]] = dict( # mutable-ok: the base files contract returns the query as a dict + listing_query + continuation_query + ) signed_headers: Final = self._sign_s3_empty_body_request( method="GET", api_base=f"{url}?{urlencode(query, quote_via=quote, safe='')}", @@ -1327,7 +1358,6 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): request_params=target.request_params, ) litellm_params[S3_SIGNED_REQUEST_HEADERS_PARAM] = signed_headers # rebind-ok: handed to validate_environment - litellm_params[LIST_FILES_PURPOSE_PARAM] = purpose # rebind-ok: handed to the response transform return url, query def transform_list_files_response( @@ -1342,8 +1372,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): message=raw_response.text, headers=raw_response.headers, ) - requested_purpose: Final = litellm_params.get(LIST_FILES_PURPOSE_PARAM) - purpose: Final = requested_purpose if isinstance(requested_purpose, str) else None + purpose: Final = _requested_listing_purpose(litellm_params) configured_bucket_name: Final = _listing_bucket_name(litellm_params, purpose) allow_legacy_cloud_file_ids: Final = should_allow_legacy_cloud_file_ids(litellm_params) listing: Final = ET.fromstring(raw_response.content) diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index f281c249c72..e5389d0e0b7 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -4928,11 +4928,11 @@ class BaseLLMHTTPHandler: except Exception as e: raise self._handle_error(e=e, provider_config=provider_config) - return provider_config.transform_list_files_response( - raw_response=response, - logging_obj=logging_obj, - litellm_params=litellm_params, + pages: Final = ( + response, + *self._following_list_files_pages(response, provider_config, litellm_params, headers, sync_httpx_client), ) + return self._listed_files_across_pages(pages, provider_config, logging_obj, litellm_params) async def async_list_files( self, @@ -4984,11 +4984,85 @@ class BaseLLMHTTPHandler: except Exception as e: raise self._handle_error(e=e, provider_config=provider_config) - return provider_config.transform_list_files_response( - raw_response=response, - logging_obj=logging_obj, - litellm_params=litellm_params, + following_pages: Final = self._following_async_list_files_pages( + response, provider_config, litellm_params, headers, async_httpx_client ) + pages: Final = ( + response, + *[page async for page in following_pages], # mutable-ok: an async comprehension is spelled as a list + ) + return self._listed_files_across_pages(pages, provider_config, logging_obj, litellm_params) + + def _listed_files_across_pages( + self, + pages: Sequence[httpx.Response], + provider_config: BaseFilesConfig, + logging_obj: LiteLLMLoggingObj, + litellm_params: dict, # mutable-ok: handed to the files contract, which types it as a dict + ) -> list[OpenAIFileObject]: + return [ # mutable-ok: the base files contract returns a list + listed_file + for page in pages + for listed_file in provider_config.transform_list_files_response( + raw_response=page, + logging_obj=logging_obj, + litellm_params=litellm_params, + ) + ] + + def _following_list_files_pages( + self, + page: httpx.Response, + provider_config: BaseFilesConfig, + litellm_params: dict, # mutable-ok: handed to the files contract, which types it as a dict + headers: dict, # mutable-ok: handed to validate_environment, which types it as a dict + client: HTTPHandler, + ) -> Iterator[httpx.Response]: + latest_page = page # rebind-ok: advances one page per loop turn + while next_request := provider_config.transform_list_files_next_request( + raw_response=latest_page, optional_params={}, litellm_params=litellm_params + ): + url, params = next_request + next_headers = provider_config.validate_environment( + api_key=litellm_params.get("api_key"), + headers=headers, + model="", + messages=[], + optional_params={}, + litellm_params=litellm_params, + ) + try: + latest_page = client.get(url=url, headers=next_headers, params=params) + except Exception as e: # noqa: BLE001 # _handle_error maps every failure kind, like the first page's fetch + raise self._handle_error(e=e, provider_config=provider_config) + yield latest_page + + async def _following_async_list_files_pages( + self, + page: httpx.Response, + provider_config: BaseFilesConfig, + litellm_params: dict, # mutable-ok: handed to the files contract, which types it as a dict + headers: dict, # mutable-ok: handed to validate_environment, which types it as a dict + client: AsyncHTTPHandler, + ) -> AsyncIterator[httpx.Response]: + latest_page = page # rebind-ok: advances one page per loop turn + while next_request := provider_config.transform_list_files_next_request( + raw_response=latest_page, optional_params={}, litellm_params=litellm_params + ): + url, params = next_request + next_headers = provider_config.validate_environment( + api_key=litellm_params.get("api_key"), + headers=headers, + model="", + messages=[], + optional_params={}, + litellm_params=litellm_params, + ) + try: + latest_page = await client.get(url=url, headers=next_headers, params=params) + except Exception as e: # noqa: BLE001 # _handle_error maps every failure kind, like the first page's fetch + raise self._handle_error(e=e, provider_config=provider_config) + yield latest_page def retrieve_file_content( self, diff --git a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py index ceac641bbf0..8e564280bf7 100644 --- a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py +++ b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py @@ -2780,6 +2780,39 @@ class TestBedrockFileListTransformation: "s3://my-bucket/litellm-bedrock-files/job-123/input.jsonl", ) OUTPUT_ID = "s3://my-bucket/litellm-batch-outputs/job-123/input.jsonl.out" + CONTINUATION_TOKEN = "1ueGcxLPRx1Tr/XYExHnhbYLgveDs2J/wm36Hy4vbOwM=" + FIRST_PAGE = b""" + + my-bucket + litellm-bedrock-files + 1 + 1 + true + 1ueGcxLPRx1Tr/XYExHnhbYLgveDs2J/wm36Hy4vbOwM= + + litellm-bedrock-files/job-1/input.jsonl + 2026-09-01T10:00:00.000Z + 10 + +""" + LAST_PAGE = b""" + + my-bucket + litellm-bedrock-files + 1 + 1 + false + 1ueGcxLPRx1Tr/XYExHnhbYLgveDs2J/wm36Hy4vbOwM= + + litellm-bedrock-files/job-2/input.jsonl + 2026-09-02T10:00:00.000Z + 20 + +""" + PAGED_IDS = ( + "s3://my-bucket/litellm-bedrock-files/job-1/input.jsonl", + "s3://my-bucket/litellm-bedrock-files/job-2/input.jsonl", + ) def test_transform_list_files_request_signs_managed_prefix_listing(self, monkeypatch): from litellm.llms.bedrock.files.transformation import ( @@ -3075,3 +3108,118 @@ class TestBedrockFileListTransformation: request = route.calls[0].request assert _sent_signature(request.headers) == _s3_signature_for("GET", str(request.url), request.headers) assert [file.id for file in files] == [self.OUTPUT_BUCKET_ID] + + def test_transform_list_files_next_request_signs_the_continuation_page(self, monkeypatch): + import httpx + + from litellm.llms.bedrock.files.transformation import ( + S3_SIGNED_REQUEST_HEADERS_PARAM, + BedrockFilesConfig, + ) + + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") + litellm_params = _bedrock_s3_params() + config = BedrockFilesConfig() + config.transform_list_files_request(purpose="batch", optional_params={}, litellm_params=litellm_params) + first_signature = _sent_signature(litellm_params.pop(S3_SIGNED_REQUEST_HEADERS_PARAM)) + + next_request = config.transform_list_files_next_request( + raw_response=httpx.Response(200, content=self.FIRST_PAGE), + optional_params={}, + litellm_params=litellm_params, + ) + + assert next_request == (self.BUCKET_URL, {**self.BATCH_QUERY, "continuation-token": self.CONTINUATION_TOKEN}) + signed_headers = litellm_params[S3_SIGNED_REQUEST_HEADERS_PARAM] + signed_url = ( + f"{self.BUCKET_URL}?list-type=2&prefix=litellm-bedrock-files" + "&continuation-token=1ueGcxLPRx1Tr%2FXYExHnhbYLgveDs2J%2Fwm36Hy4vbOwM%3D" + ) + assert _sent_signature(signed_headers) == _s3_signature_for("GET", signed_url, signed_headers) + assert _sent_signature(signed_headers) != first_signature + + @pytest.mark.parametrize( + ("status_code", "content"), + [ + pytest.param(200, LAST_PAGE, id="last-page"), + pytest.param(403, b"AccessDenied", id="error-page"), + ], + ) + def test_transform_list_files_next_request_stops_after_the_last_page(self, monkeypatch, status_code, content): + import httpx + + from litellm.llms.bedrock.files.transformation import ( + S3_SIGNED_REQUEST_HEADERS_PARAM, + BedrockFilesConfig, + ) + + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") + litellm_params = _bedrock_s3_params() + + next_request = BedrockFilesConfig().transform_list_files_next_request( + raw_response=httpx.Response(status_code, content=content), + optional_params={}, + litellm_params=litellm_params, + ) + + assert next_request is None + assert S3_SIGNED_REQUEST_HEADERS_PARAM not in litellm_params + + def _mock_paged_listing(self, respx_module): + import httpx + + last_page = respx_module.get( + self.BUCKET_URL, params__contains={"continuation-token": self.CONTINUATION_TOKEN} + ).mock(return_value=httpx.Response(200, content=self.LAST_PAGE)) + first_page = respx_module.get(self.BUCKET_URL, params__contains=self.BATCH_QUERY).mock( + return_value=httpx.Response(200, content=self.FIRST_PAGE) + ) + return first_page, last_page + + def _assert_paged_listing(self, first_page, last_page, files): + assert (first_page.call_count, last_page.call_count) == (1, 1) + assert "continuation-token" not in str(first_page.calls[0].request.url) + last_request = last_page.calls[0].request + assert _sent_signature(last_request.headers) == _s3_signature_for( + "GET", str(last_request.url), last_request.headers + ) + assert [file.id for file in files] == list(self.PAGED_IDS) + + def test_file_list_follows_continuation_tokens_across_pages(self, monkeypatch): + import respx + + import litellm + + monkeypatch.delenv("AWS_S3_BUCKET_NAME", raising=False) + monkeypatch.delenv("AWS_S3_OUTPUT_BUCKET_NAME", raising=False) + + with respx.mock: + first_page, last_page = self._mock_paged_listing(respx) + files = litellm.file_list( + custom_llm_provider="bedrock", + purpose="batch", + **_trusted_bucket_snapshot(s3_bucket_name="my-bucket"), + ) + + self._assert_paged_listing(first_page, last_page, files) + + @pytest.mark.asyncio + async def test_afile_list_follows_continuation_tokens_across_pages(self, monkeypatch): + import respx + + import litellm + + monkeypatch.delenv("AWS_S3_BUCKET_NAME", raising=False) + monkeypatch.delenv("AWS_S3_OUTPUT_BUCKET_NAME", raising=False) + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + litellm.in_memory_llm_clients_cache.flush_cache() + + with respx.mock: + first_page, last_page = self._mock_paged_listing(respx) + files = await litellm.afile_list( + custom_llm_provider="bedrock", + purpose="batch", + **_trusted_bucket_snapshot(s3_bucket_name="my-bucket"), + ) + + self._assert_paged_listing(first_page, last_page, files) From 46be4054de214c515b687386f46214e7ea1a7c8c Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:48:37 -0700 Subject: [PATCH 04/12] fix(files): cap provider listings at the OpenAI ceiling and time out every page Following S3 continuation tokens let GET /v1/files walk an entire managed prefix however large it grew, and the follow-up page fetches dropped the caller's timeout. The handler now stops once MAX_FILE_LIST_LIMIT files are collected (10,000, the most OpenAI returns per list call), slicing the last page to fit, and hands the request timeout to the first and every later page fetch. MAX_FILE_LIST_LIMIT moves to litellm.constants so the proxy's limit validation and the handler share one number --- litellm/constants.py | 1 + litellm/llms/custom_httpx/llm_http_handler.py | 107 ++++++++++-------- .../openai_files_endpoints/common_utils.py | 3 +- .../test_bedrock_files_transformation.py | 82 ++++++++++++++ 4 files changed, 143 insertions(+), 50 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index da731cb5eb2..2ee0e36be75 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -53,6 +53,7 @@ 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 +MAX_FILE_LIST_LIMIT: Final = 10000 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)) diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index e5389d0e0b7..a70807bf6a5 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -18,7 +18,7 @@ import litellm.types import litellm.types.utils from litellm._logging import _redact_string, verbose_logger from litellm.anthropic_beta_headers_manager import update_headers_with_filtered_beta -from litellm.constants import REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES +from litellm.constants import MAX_FILE_LIST_LIMIT, REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES from litellm.litellm_core_utils.agentic_loop_settings import ( DEFAULT_MAX_AGENTIC_LOOPS, validated_max_agentic_loops, @@ -4924,15 +4924,16 @@ class BaseLLMHTTPHandler: ) try: - response: Final = sync_httpx_client.get(url=url, headers=headers, params=params) + response: Final = sync_httpx_client.get(url=url, headers=headers, params=params, timeout=timeout) except Exception as e: raise self._handle_error(e=e, provider_config=provider_config) - pages: Final = ( - response, - *self._following_list_files_pages(response, provider_config, litellm_params, headers, sync_httpx_client), + files_per_page: Final = self._files_per_listing_page( + response, provider_config, logging_obj, litellm_params, headers, sync_httpx_client, timeout ) - return self._listed_files_across_pages(pages, provider_config, logging_obj, litellm_params) + return [ # mutable-ok: the base files contract returns a list + listed_file for page_files in files_per_page for listed_file in page_files + ] async def async_list_files( self, @@ -4980,48 +4981,38 @@ class BaseLLMHTTPHandler: ) try: - response: Final = await async_httpx_client.get(url=url, headers=headers, params=params) + response: Final = await async_httpx_client.get(url=url, headers=headers, params=params, timeout=timeout) except Exception as e: raise self._handle_error(e=e, provider_config=provider_config) - following_pages: Final = self._following_async_list_files_pages( - response, provider_config, litellm_params, headers, async_httpx_client + files_per_page: Final = self._files_per_async_listing_page( + response, provider_config, logging_obj, litellm_params, headers, async_httpx_client, timeout ) - pages: Final = ( - response, - *[page async for page in following_pages], # mutable-ok: an async comprehension is spelled as a list - ) - return self._listed_files_across_pages(pages, provider_config, logging_obj, litellm_params) + return [ # mutable-ok: the base files contract returns a list + listed_file async for page_files in files_per_page for listed_file in page_files + ] - def _listed_files_across_pages( + def _files_per_listing_page( self, - pages: Sequence[httpx.Response], + first_page: httpx.Response, provider_config: BaseFilesConfig, logging_obj: LiteLLMLoggingObj, litellm_params: dict, # mutable-ok: handed to the files contract, which types it as a dict - ) -> list[OpenAIFileObject]: - return [ # mutable-ok: the base files contract returns a list - listed_file - for page in pages - for listed_file in provider_config.transform_list_files_response( - raw_response=page, - logging_obj=logging_obj, - litellm_params=litellm_params, - ) - ] - - def _following_list_files_pages( - self, - page: httpx.Response, - provider_config: BaseFilesConfig, - litellm_params: dict, # mutable-ok: handed to the files contract, which types it as a dict headers: dict, # mutable-ok: handed to validate_environment, which types it as a dict client: HTTPHandler, - ) -> Iterator[httpx.Response]: - latest_page = page # rebind-ok: advances one page per loop turn - while next_request := provider_config.transform_list_files_next_request( - raw_response=latest_page, optional_params={}, litellm_params=litellm_params - ): + timeout: float | httpx.Timeout | None, + ) -> Iterator[list[OpenAIFileObject]]: # mutable-ok: each page arrives as the list the files contract returns + latest_page = first_page # rebind-ok: advances one page per loop turn + listed_count = 0 # rebind-ok: grows per page so the listing stops at MAX_FILE_LIST_LIMIT, OpenAI's ceiling + while True: + page_files = provider_config.transform_list_files_response( + raw_response=latest_page, logging_obj=logging_obj, litellm_params=litellm_params + ) + yield page_files[: MAX_FILE_LIST_LIMIT - listed_count] + listed_count += len(page_files) + next_request = self._next_listing_request(latest_page, provider_config, litellm_params, listed_count) + if next_request is None: + return url, params = next_request next_headers = provider_config.validate_environment( api_key=litellm_params.get("api_key"), @@ -5032,23 +5023,31 @@ class BaseLLMHTTPHandler: litellm_params=litellm_params, ) try: - latest_page = client.get(url=url, headers=next_headers, params=params) + latest_page = client.get(url=url, headers=next_headers, params=params, timeout=timeout) except Exception as e: # noqa: BLE001 # _handle_error maps every failure kind, like the first page's fetch raise self._handle_error(e=e, provider_config=provider_config) - yield latest_page - async def _following_async_list_files_pages( + async def _files_per_async_listing_page( self, - page: httpx.Response, + first_page: httpx.Response, provider_config: BaseFilesConfig, + logging_obj: LiteLLMLoggingObj, litellm_params: dict, # mutable-ok: handed to the files contract, which types it as a dict headers: dict, # mutable-ok: handed to validate_environment, which types it as a dict client: AsyncHTTPHandler, - ) -> AsyncIterator[httpx.Response]: - latest_page = page # rebind-ok: advances one page per loop turn - while next_request := provider_config.transform_list_files_next_request( - raw_response=latest_page, optional_params={}, litellm_params=litellm_params - ): + timeout: float | httpx.Timeout | None, + ) -> AsyncIterator[list[OpenAIFileObject]]: # mutable-ok: each page arrives as the list the files contract returns + latest_page = first_page # rebind-ok: advances one page per loop turn + listed_count = 0 # rebind-ok: grows per page so the listing stops at MAX_FILE_LIST_LIMIT, OpenAI's ceiling + while True: + page_files = provider_config.transform_list_files_response( + raw_response=latest_page, logging_obj=logging_obj, litellm_params=litellm_params + ) + yield page_files[: MAX_FILE_LIST_LIMIT - listed_count] + listed_count += len(page_files) + next_request = self._next_listing_request(latest_page, provider_config, litellm_params, listed_count) + if next_request is None: + return url, params = next_request next_headers = provider_config.validate_environment( api_key=litellm_params.get("api_key"), @@ -5059,10 +5058,22 @@ class BaseLLMHTTPHandler: litellm_params=litellm_params, ) try: - latest_page = await client.get(url=url, headers=next_headers, params=params) + latest_page = await client.get(url=url, headers=next_headers, params=params, timeout=timeout) except Exception as e: # noqa: BLE001 # _handle_error maps every failure kind, like the first page's fetch raise self._handle_error(e=e, provider_config=provider_config) - yield latest_page + + def _next_listing_request( + self, + latest_page: httpx.Response, + provider_config: BaseFilesConfig, + litellm_params: dict, # mutable-ok: handed to the files contract, which types it as a dict + listed_count: int, + ) -> tuple[str, dict[str, str]] | None: # mutable-ok: the base files contract returns the query as a dict + if listed_count >= MAX_FILE_LIST_LIMIT: + return None + return provider_config.transform_list_files_next_request( + raw_response=latest_page, optional_params={}, litellm_params=litellm_params + ) def retrieve_file_content( self, diff --git a/litellm/proxy/openai_files_endpoints/common_utils.py b/litellm/proxy/openai_files_endpoints/common_utils.py index 992ed0d814d..4b4a99f849a 100644 --- a/litellm/proxy/openai_files_endpoints/common_utils.py +++ b/litellm/proxy/openai_files_endpoints/common_utils.py @@ -15,6 +15,7 @@ from typing import ( runtime_checkable, ) +from litellm.constants import MAX_FILE_LIST_LIMIT from litellm.proxy._types import ProxyException from litellm.repositories.table_repositories import ( ManagedFileRepository, @@ -33,8 +34,6 @@ if TYPE_CHECKING: from litellm.types.utils import LiteLLMBatch -MAX_FILE_LIST_LIMIT: Final = 10000 - FILE_LIST_CONTINUATION_CHUNK_SIZE: Final = 500 diff --git a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py index 8e564280bf7..9bf9e468dca 100644 --- a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py +++ b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py @@ -3178,6 +3178,8 @@ class TestBedrockFileListTransformation: def _assert_paged_listing(self, first_page, last_page, files): assert (first_page.call_count, last_page.call_count) == (1, 1) + read_timeouts = [call.request.extensions["timeout"]["read"] for call in (*first_page.calls, *last_page.calls)] + assert read_timeouts == [12.0, 12.0] assert "continuation-token" not in str(first_page.calls[0].request.url) last_request = last_page.calls[0].request assert _sent_signature(last_request.headers) == _s3_signature_for( @@ -3198,6 +3200,7 @@ class TestBedrockFileListTransformation: files = litellm.file_list( custom_llm_provider="bedrock", purpose="batch", + timeout=12, **_trusted_bucket_snapshot(s3_bucket_name="my-bucket"), ) @@ -3219,7 +3222,86 @@ class TestBedrockFileListTransformation: files = await litellm.afile_list( custom_llm_provider="bedrock", purpose="batch", + timeout=12, **_trusted_bucket_snapshot(s3_bucket_name="my-bucket"), ) self._assert_paged_listing(first_page, last_page, files) + + OVERSIZED_PAGE_SIZE = 3000 + OVERSIZED_PAGE_COUNT = 6 + + def _oversized_listing_page(self, page_index: int) -> bytes: + contents = "".join( + f"litellm-bedrock-files/page-{page_index}/obj-{index}.jsonl" + "2026-09-01T10:00:00.000Z1" + for index in range(self.OVERSIZED_PAGE_SIZE) + ) + continuation = ( + f"truepage-{page_index + 1}" + if page_index < self.OVERSIZED_PAGE_COUNT - 1 + else "false" + ) + return ( + '' + '' + f"{continuation}{contents}" + ).encode() + + def _mock_oversized_listing(self, respx_module): + import httpx + + def page_for(request): + token = request.url.params.get("continuation-token", "page-0") + return httpx.Response(200, content=self._oversized_listing_page(int(token.removeprefix("page-")))) + + return respx_module.get(self.BUCKET_URL, params__contains=self.BATCH_QUERY).mock(side_effect=page_for) + + def _assert_capped_listing(self, route, files): + from litellm.constants import MAX_FILE_LIST_LIMIT + + pages_needed = -(-MAX_FILE_LIST_LIMIT // self.OVERSIZED_PAGE_SIZE) + last_index = MAX_FILE_LIST_LIMIT - (pages_needed - 1) * self.OVERSIZED_PAGE_SIZE - 1 + assert pages_needed < self.OVERSIZED_PAGE_COUNT + assert route.call_count == pages_needed + assert len(files) == MAX_FILE_LIST_LIMIT + assert files[-1].id == f"s3://my-bucket/litellm-bedrock-files/page-{pages_needed - 1}/obj-{last_index}.jsonl" + + def test_file_list_stops_at_the_openai_listing_ceiling(self, monkeypatch): + import respx + + import litellm + + monkeypatch.delenv("AWS_S3_BUCKET_NAME", raising=False) + monkeypatch.delenv("AWS_S3_OUTPUT_BUCKET_NAME", raising=False) + + with respx.mock: + route = self._mock_oversized_listing(respx) + files = litellm.file_list( + custom_llm_provider="bedrock", + purpose="batch", + **_trusted_bucket_snapshot(s3_bucket_name="my-bucket"), + ) + + self._assert_capped_listing(route, files) + + @pytest.mark.asyncio + async def test_afile_list_stops_at_the_openai_listing_ceiling(self, monkeypatch): + import respx + + import litellm + + monkeypatch.delenv("AWS_S3_BUCKET_NAME", raising=False) + monkeypatch.delenv("AWS_S3_OUTPUT_BUCKET_NAME", raising=False) + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + litellm.in_memory_llm_clients_cache.flush_cache() + + with respx.mock: + route = self._mock_oversized_listing(respx) + files = await litellm.afile_list( + custom_llm_provider="bedrock", + purpose="batch", + **_trusted_bucket_snapshot(s3_bucket_name="my-bucket"), + ) + + self._assert_capped_listing(route, files) From 9b5205b62dbdb5386fbcde9d97ab1f75fa4c7695 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:16:47 -0700 Subject: [PATCH 05/12] refactor(files): import MAX_FILE_LIST_LIMIT from litellm.constants in the managed files hook The enterprise hook reached the constant through the common_utils re-export, which no longer defines it, so point it at the constant's new home --- enterprise/litellm_enterprise/proxy/hooks/managed_files.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py index 748ab5dd26a..cb3c2936b49 100644 --- a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py +++ b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py @@ -27,6 +27,7 @@ import litellm from litellm import Router, verbose_logger from litellm._uuid import uuid from litellm.caching.caching import DualCache +from litellm.constants import MAX_FILE_LIST_LIMIT from litellm.integrations.custom_logger import CustomLogger from litellm.litellm_core_utils.prompt_templates.common_utils import ( extract_file_metadata, @@ -47,7 +48,6 @@ from litellm.proxy._types import ( ) from litellm.proxy.openai_files_endpoints.common_utils import ( FILE_LIST_CONTINUATION_CHUNK_SIZE, - MAX_FILE_LIST_LIMIT, _is_base64_encoded_unified_file_id, apply_unified_file_ids, decode_model_from_file_id, From 7bddb656c10c81dad266b17b24c06e5946a9e74e Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:21:52 -0700 Subject: [PATCH 06/12] refactor(files): build the next listing page's headers in one handler helper Staging sits exactly at the LIT002 ceiling, so the duplicated validate_environment call for the next page is shared to keep the merged tree under it --- litellm/llms/custom_httpx/llm_http_handler.py | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index a70807bf6a5..884c5a6fada 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -5014,14 +5014,7 @@ class BaseLLMHTTPHandler: if next_request is None: return url, params = next_request - next_headers = provider_config.validate_environment( - api_key=litellm_params.get("api_key"), - headers=headers, - model="", - messages=[], - optional_params={}, - litellm_params=litellm_params, - ) + next_headers = self._next_listing_page_headers(provider_config, headers, litellm_params) try: latest_page = client.get(url=url, headers=next_headers, params=params, timeout=timeout) except Exception as e: # noqa: BLE001 # _handle_error maps every failure kind, like the first page's fetch @@ -5049,19 +5042,27 @@ class BaseLLMHTTPHandler: if next_request is None: return url, params = next_request - next_headers = provider_config.validate_environment( - api_key=litellm_params.get("api_key"), - headers=headers, - model="", - messages=[], - optional_params={}, - litellm_params=litellm_params, - ) + next_headers = self._next_listing_page_headers(provider_config, headers, litellm_params) try: latest_page = await client.get(url=url, headers=next_headers, params=params, timeout=timeout) except Exception as e: # noqa: BLE001 # _handle_error maps every failure kind, like the first page's fetch raise self._handle_error(e=e, provider_config=provider_config) + def _next_listing_page_headers( + self, + provider_config: BaseFilesConfig, + headers: dict, # mutable-ok: handed to validate_environment, which types it as a dict + litellm_params: dict, # mutable-ok: handed to the files contract, which types it as a dict + ) -> dict: # mutable-ok: validate_environment returns the header dict the files contract types + return provider_config.validate_environment( + api_key=litellm_params.get("api_key"), + headers=headers, + model="", + messages=[], + optional_params={}, + litellm_params=litellm_params, + ) + def _next_listing_request( self, latest_page: httpx.Response, From d238e602203cafe0582eb0003255e4c6958d8858 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:38:25 -0700 Subject: [PATCH 07/12] fix(bedrock): answer 400 for a file id outside the configured bucket and keep S3 error bodies --- litellm/llms/bedrock/files/transformation.py | 45 ++++++-- .../test_bedrock_files_transformation.py | 100 +++++++++++++++++- .../test_files_endpoint.py | 49 +++++++++ 3 files changed, 178 insertions(+), 16 deletions(-) diff --git a/litellm/llms/bedrock/files/transformation.py b/litellm/llms/bedrock/files/transformation.py index 1266397636a..a7f1b380fe2 100644 --- a/litellm/llms/bedrock/files/transformation.py +++ b/litellm/llms/bedrock/files/transformation.py @@ -263,6 +263,35 @@ def _validate_file_id_against_configured_buckets( return validate_against(configured_bucket_names[-1]) +_REJECTED_FILE_ID_REQUEST_URL: Final = "https://docs.litellm.ai/docs" + + +def _rejected_file_id(reason: ValueError) -> BedrockError: + message: Final = str(reason) + return BedrockError( + status_code=400, + message=message, + response=httpx.Response( + status_code=400, + text=message, + request=httpx.Request(method="GET", url=_REJECTED_FILE_ID_REQUEST_URL), + ), + ) + + +def _resolve_managed_s3_object(file_id: str, litellm_params: Mapping[str, object]) -> tuple[str, str]: + configured_bucket_names: Final = get_configured_s3_bucket_names(litellm_params) + allow_legacy_cloud_file_ids: Final = should_allow_legacy_cloud_file_ids(litellm_params) + try: + return _validate_file_id_against_configured_buckets( + s3_uri=extract_s3_uri_from_file_id(file_id), + configured_bucket_names=configured_bucket_names, + allow_legacy_cloud_file_ids=allow_legacy_cloud_file_ids, + ) + except ValueError as reason: + raise _rejected_file_id(reason) from reason + + _ANY_MANAGED_LISTING_PREFIX: Final = os.path.commonprefix(BEDROCK_MANAGED_S3_PREFIXES) _MANAGED_LISTING_PREFIX_BY_PURPOSE: Final = MappingProxyType( { @@ -1279,11 +1308,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): ) -> tuple[str, dict[str, str]]: if not file_id: raise ValueError("file_id is required for Bedrock file deletion") - bucket_name, object_key = _validate_file_id_against_configured_buckets( - s3_uri=extract_s3_uri_from_file_id(file_id), - configured_bucket_names=get_configured_s3_bucket_names(litellm_params), - allow_legacy_cloud_file_ids=should_allow_legacy_cloud_file_ids(litellm_params), - ) + bucket_name, object_key = _resolve_managed_s3_object(file_id=file_id, litellm_params=litellm_params) target: Final = self._s3_request_target(optional_params=optional_params, litellm_params=litellm_params) url: Final = f"{target.endpoint_url}/{bucket_name}/{encode_s3_object_key_for_url(object_key)}" signed_headers: Final = self._sign_s3_empty_body_request( @@ -1307,6 +1332,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): status_code=raw_response.status_code, message=raw_response.text, headers=raw_response.headers, + response=raw_response, ) return FileDeleted(id=str(litellm_params.get(DELETED_FILE_ID_PARAM, "")), deleted=True, object="file") @@ -1371,6 +1397,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): status_code=raw_response.status_code, message=raw_response.text, headers=raw_response.headers, + response=raw_response, ) purpose: Final = _requested_listing_purpose(litellm_params) configured_bucket_name: Final = _listing_bucket_name(litellm_params, purpose) @@ -1406,12 +1433,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): if not file_id: raise ValueError("file_id is required for Bedrock file content retrieval") - s3_uri: Final = extract_s3_uri_from_file_id(file_id) - bucket_name, object_key = _validate_file_id_against_configured_buckets( - s3_uri=s3_uri, - configured_bucket_names=get_configured_s3_bucket_names(litellm_params), - allow_legacy_cloud_file_ids=should_allow_legacy_cloud_file_ids(litellm_params), - ) + bucket_name, object_key = _resolve_managed_s3_object(file_id=file_id, litellm_params=litellm_params) target: Final = self._s3_request_target(optional_params=optional_params, litellm_params=litellm_params) url: Final = f"{target.endpoint_url}/{bucket_name}/{encode_s3_object_key_for_url(object_key)}" @@ -1502,6 +1524,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): status_code=raw_response.status_code, message=raw_response.text, headers=raw_response.headers, + response=raw_response, ) return HttpxBinaryResponseContent(response=raw_response) diff --git a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py index 9bf9e468dca..12b9f63c99b 100644 --- a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py +++ b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py @@ -1930,11 +1930,12 @@ class TestBedrockFileContentTransformation: assert url == self.EXPECTED_URL def test_transform_file_content_request_rejects_foreign_bucket(self, monkeypatch): + from litellm.llms.bedrock.common_utils import BedrockError from litellm.llms.bedrock.files.transformation import BedrockFilesConfig monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") - with pytest.raises(ValueError, match="configured storage bucket"): + with pytest.raises(BedrockError, match="configured storage bucket") as rejection: BedrockFilesConfig().transform_file_content_request( file_content_request={ "file_id": "s3://other-bucket/litellm-batch-outputs/job/x.jsonl.out" @@ -1943,18 +1944,25 @@ class TestBedrockFileContentTransformation: litellm_params=self._litellm_params(), ) + assert rejection.value.status_code == 400 + + def test_transform_file_content_request_rejects_unmanaged_key(self, monkeypatch): + from litellm.llms.bedrock.common_utils import BedrockError from litellm.llms.bedrock.files.transformation import BedrockFilesConfig monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") - with pytest.raises(ValueError, match="LiteLLM-managed"): + with pytest.raises(BedrockError, match="LiteLLM-managed") as rejection: BedrockFilesConfig().transform_file_content_request( file_content_request={"file_id": "s3://my-bucket/private/x.jsonl"}, optional_params={}, litellm_params=self._litellm_params(), ) + assert rejection.value.status_code == 400 + + def test_extract_s3_uri_rejects_non_managed_file_id(self): """A file id that is neither an s3:// URI nor a unified id must be rejected.""" from litellm.llms.bedrock.files.transformation import ( @@ -2083,12 +2091,13 @@ class TestBedrockFileContentTransformation: def test_rejects_bucket_outside_input_and_output(self, monkeypatch): """A file id whose bucket is neither the input nor the output bucket is still rejected (SSRF / bucket-confusion guard).""" + from litellm.llms.bedrock.common_utils import BedrockError from litellm.llms.bedrock.files.transformation import BedrockFilesConfig monkeypatch.delenv("AWS_S3_BUCKET_NAME", raising=False) monkeypatch.delenv("AWS_S3_OUTPUT_BUCKET_NAME", raising=False) - with pytest.raises(ValueError, match="configured storage bucket"): + with pytest.raises(BedrockError, match="configured storage bucket") as rejection: BedrockFilesConfig().transform_file_content_request( file_content_request={ "file_id": "s3://other-bucket/litellm-batch-outputs/job/x.jsonl.out" @@ -2099,6 +2108,9 @@ class TestBedrockFileContentTransformation: ), ) + assert rejection.value.status_code == 400 + + def test_sign_request_without_botocore_raises_helpful_error(self, monkeypatch): """A missing botocore must surface an actionable 'install boto3' error rather than a raw import failure.""" @@ -2622,29 +2634,37 @@ class TestBedrockFileDeletionTransformation: assert url == self.EXPECTED_URL def test_transform_delete_file_request_rejects_foreign_bucket(self, monkeypatch): + from litellm.llms.bedrock.common_utils import BedrockError from litellm.llms.bedrock.files.transformation import BedrockFilesConfig monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") - with pytest.raises(ValueError, match="configured storage bucket"): + with pytest.raises(BedrockError, match="configured storage bucket") as rejection: BedrockFilesConfig().transform_delete_file_request( file_id="s3://other-bucket/litellm-bedrock-files/job-123/input.jsonl", optional_params={}, litellm_params=_bedrock_s3_params(), ) + assert rejection.value.status_code == 400 + + def test_transform_delete_file_request_rejects_unmanaged_key(self, monkeypatch): + from litellm.llms.bedrock.common_utils import BedrockError from litellm.llms.bedrock.files.transformation import BedrockFilesConfig monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") - with pytest.raises(ValueError, match="LiteLLM-managed"): + with pytest.raises(BedrockError, match="LiteLLM-managed") as rejection: BedrockFilesConfig().transform_delete_file_request( file_id="s3://my-bucket/private/x.jsonl", optional_params={}, litellm_params=_bedrock_s3_params(), ) + assert rejection.value.status_code == 400 + + def test_transform_delete_file_response_echoes_the_deleted_id(self): import httpx @@ -2728,6 +2748,57 @@ class TestBedrockFileDeletionTransformation: assert response.id == self.S3_URI assert response.deleted is True + def test_file_delete_end_to_end_answers_400_for_a_foreign_bucket(self, monkeypatch): + import respx + + import litellm + from litellm.llms.bedrock.common_utils import BedrockError + + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") + + with respx.mock, pytest.raises(BedrockError) as rejection: + litellm.file_delete( + file_id="s3://other-bucket/litellm-bedrock-files/job-123/input.jsonl", + custom_llm_provider="bedrock", + **_bedrock_s3_params(), + ) + + assert rejection.value.status_code == 400 + assert "configured storage bucket" in rejection.value.message + + def test_file_delete_end_to_end_answers_400_for_a_non_managed_id(self, monkeypatch): + import respx + + import litellm + from litellm.llms.bedrock.common_utils import BedrockError + + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") + + with respx.mock, pytest.raises(BedrockError) as rejection: + litellm.file_delete(file_id="file-1234567890", custom_llm_provider="bedrock", **_bedrock_s3_params()) + + assert rejection.value.status_code == 400 + assert "managed LiteLLM S3 file id" in rejection.value.message + + def test_file_delete_end_to_end_surfaces_the_s3_error_body(self, monkeypatch): + import httpx + import respx + + import litellm + from litellm.llms.bedrock.common_utils import BedrockError + + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") + + with respx.mock: + respx.delete(self.EXPECTED_URL).mock( + return_value=httpx.Response(403, text="AccessDenied") + ) + with pytest.raises(BedrockError) as denied: + litellm.file_delete(file_id=self.S3_URI, custom_llm_provider="bedrock", **_bedrock_s3_params()) + + assert denied.value.status_code == 403 + assert "AccessDenied" in denied.value.message + class TestBedrockFileListTransformation: """SigV4-signed S3 ListObjectsV2 over the LiteLLM-managed key prefixes.""" @@ -3305,3 +3376,22 @@ class TestBedrockFileListTransformation: ) self._assert_capped_listing(route, files) + + def test_file_list_end_to_end_surfaces_the_s3_error_body(self, monkeypatch): + import httpx + import respx + + import litellm + from litellm.llms.bedrock.common_utils import BedrockError + + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") + + with respx.mock: + respx.get(self.BUCKET_URL, params__contains=self.BATCH_QUERY).mock( + return_value=httpx.Response(403, text="AccessDenied") + ) + with pytest.raises(BedrockError) as denied: + litellm.file_list(custom_llm_provider="bedrock", purpose="batch", **_bedrock_s3_params()) + + assert denied.value.status_code == 403 + assert "AccessDenied" in denied.value.message diff --git a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py index a4b36487330..824170e6b3d 100644 --- a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py +++ b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py @@ -4733,3 +4733,52 @@ def test_list_files_target_model_names_passes_trusted_bedrock_credentials( assert isinstance(trusted_credentials, MappingProxyType) assert trusted_credentials["s3_bucket_name"] == "my-bucket" proxy_logging_obj.post_call_failure_hook.assert_not_called() + + +def test_delete_file_answers_400_for_an_id_outside_the_configured_bucket(mocker: MockerFixture, monkeypatch): + from urllib.parse import quote + + import litellm.proxy.proxy_server as ps + from litellm.proxy._types import LitellmUserRoles + + bedrock_router = Router( + model_list=[ + { + "model_name": "bedrock-claude", + "litellm_params": { + "model": "bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0", + "aws_access_key_id": "AKIAEXAMPLE", + "aws_secret_access_key": "secret", + "aws_region_name": "us-west-2", + "s3_bucket_name": "my-bucket", + }, + }, + ] + ) + + proxy_logging_obj = setup_proxy_logging_object(monkeypatch, bedrock_router) + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", bedrock_router) + proxy_logging_obj.update_request_status = mocker.AsyncMock() + proxy_logging_obj.post_call_success_hook = mocker.AsyncMock(return_value=[]) + proxy_logging_obj.post_call_failure_hook = mocker.AsyncMock() + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + api_key="test-key", + user_role=LitellmUserRoles.PROXY_ADMIN, + user_id="test-user", + ) + foreign_file_id: Final = quote("s3://other-bucket/litellm-bedrock-files/job-123/input.jsonl", safe="") + + try: + with respx.mock: + response = client.delete( + f"/v1/files/{foreign_file_id}?model=bedrock-claude", + headers={"Authorization": "Bearer test-key"}, + ) + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + assert response.status_code == 400, response.text + assert "configured storage bucket" in response.json()["error"]["message"] From 91391c1360ca9ffdb5add6f824a6afefb5d882a5 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:48:38 -0700 Subject: [PATCH 08/12] fix(files): page every provider listing, answer deleted true for managed ids, and skip S3 walks for purposes Bedrock never stores GET /v1/files through a provider config now returns the OpenAI page shape (object list, data, first_id, last_id, has_more) instead of a bare array, and DELETE /v1/files/{id} on a managed id answers the OpenAI FileDeleted shape with deleted true instead of an empty body Bedrock listing asks S3 for max-keys=0 when the purpose is one Bedrock never stores under LiteLLM's prefixes, and batch_output listing no longer requires an input bucket when only s3_output_bucket_name is configured. The mock request behind the 400 for a foreign file id uses the same https://litellm.ai URL the exception module uses --- .../proxy/hooks/managed_files.py | 26 ++---- litellm/llms/base_llm/files/transformation.py | 2 +- litellm/llms/bedrock/files/transformation.py | 33 ++++--- litellm/llms/custom_httpx/llm_http_handler.py | 12 ++- .../proxy/test_managed_files_hook.py | 1 + .../test_bedrock_files_transformation.py | 93 +++++++++++++++++-- 6 files changed, 125 insertions(+), 42 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py index 7a871e6e65d..8b22bd936a8 100644 --- a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py +++ b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py @@ -32,6 +32,8 @@ from litellm.integrations.custom_logger import CustomLogger from litellm.litellm_core_utils.prompt_templates.common_utils import ( extract_file_metadata, ) +from openai.types.file_deleted import FileDeleted + from litellm.llms.base_llm.files.transformation import BaseFileEndpoints from litellm.llms.base_llm.managed_resources.isolation import ( build_list_page, @@ -1765,7 +1767,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): litellm_parent_otel_span: Optional[Span], llm_router: Router, **data: Dict, - ) -> OpenAIFileObject: + ) -> FileDeleted: # Check if file deletion should be blocked due to batch references await self._check_file_deletion_allowed(file_id) @@ -1773,7 +1775,6 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): # file_id = convert_b64_uid_to_unified_uid(file_id) model_file_id_mapping = await self.get_model_file_id_mapping([file_id], litellm_parent_otel_span) - delete_response = None specific_model_file_id_mapping = model_file_id_mapping.get(file_id) if specific_model_file_id_mapping: # Remove conflicting keys from data to avoid duplicate keyword arguments @@ -1785,23 +1786,14 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): if credentials is not None else filtered_data ) - delete_response = await llm_router.afile_delete(model=model_id, file_id=model_file_id, **router_kwargs) + await llm_router.afile_delete(model=model_id, file_id=model_file_id, **router_kwargs) - stored_file_object = await self.delete_unified_file_id(file_id, litellm_parent_otel_span) + await self.delete_unified_file_id(file_id, litellm_parent_otel_span) - # Record successful deletion metric only on actual success - if stored_file_object or delete_response: - prom_logger = self._get_prometheus_logger() - if prom_logger: - prom_logger.record_managed_file_deleted(result="success") - - if stored_file_object: - return stored_file_object - elif delete_response: - delete_response.id = file_id - return delete_response - else: - raise Exception(f"LiteLLM Managed File object with id={file_id} not found") + prom_logger = self._get_prometheus_logger() + if prom_logger: + prom_logger.record_managed_file_deleted(result="success") + return FileDeleted(id=file_id, object="file", deleted=True) async def afile_content( self, diff --git a/litellm/llms/base_llm/files/transformation.py b/litellm/llms/base_llm/files/transformation.py index 3f8fec354b7..6d16a1cea69 100644 --- a/litellm/llms/base_llm/files/transformation.py +++ b/litellm/llms/base_llm/files/transformation.py @@ -267,7 +267,7 @@ class BaseFileEndpoints(ABC): litellm_parent_otel_span: Span | None, llm_router: Router, **data: dict, - ) -> OpenAIFileObject: + ) -> FileDeleted: pass @abstractmethod diff --git a/litellm/llms/bedrock/files/transformation.py b/litellm/llms/bedrock/files/transformation.py index a7f1b380fe2..201911737b6 100644 --- a/litellm/llms/bedrock/files/transformation.py +++ b/litellm/llms/bedrock/files/transformation.py @@ -263,7 +263,7 @@ def _validate_file_id_against_configured_buckets( return validate_against(configured_bucket_names[-1]) -_REJECTED_FILE_ID_REQUEST_URL: Final = "https://docs.litellm.ai/docs" +_REJECTED_FILE_ID_REQUEST_URL: Final = "https://litellm.ai" def _rejected_file_id(reason: ValueError) -> BedrockError: @@ -301,26 +301,37 @@ _MANAGED_LISTING_PREFIX_BY_PURPOSE: Final = MappingProxyType( ) -def _managed_listing_prefix(configured_prefix: str, purpose: str | None) -> str: - managed_prefix: Final = ( - _MANAGED_LISTING_PREFIX_BY_PURPOSE.get(purpose, _ANY_MANAGED_LISTING_PREFIX) - if purpose - else _ANY_MANAGED_LISTING_PREFIX - ) +_EMPTY_LISTING_QUERY: Final = (("list-type", "2"), ("max-keys", "0")) + + +def _managed_listing_prefix(configured_prefix: str, purpose: str | None) -> str | None: + managed_prefix: Final = _MANAGED_LISTING_PREFIX_BY_PURPOSE.get(purpose) if purpose else _ANY_MANAGED_LISTING_PREFIX + if managed_prefix is None: + return None return f"{configured_prefix}/{managed_prefix}" if configured_prefix else managed_prefix +def _listing_query(configured_prefix: str, purpose: str | None) -> tuple[tuple[str, str], ...]: + listing_prefix: Final = _managed_listing_prefix(configured_prefix, purpose) + if listing_prefix is None: + return _EMPTY_LISTING_QUERY + return (("list-type", "2"), ("prefix", listing_prefix)) + + def _requested_listing_purpose(litellm_params: Mapping[str, object]) -> str | None: requested_purpose: Final = litellm_params.get(LIST_FILES_PURPOSE_PARAM) return requested_purpose if isinstance(requested_purpose, str) else None def _listing_bucket_name(litellm_params: Mapping[str, object], purpose: str | None) -> str: - input_bucket_name: Final = get_configured_s3_bucket_name(litellm_params) if purpose != "batch_output": - return input_bucket_name + return get_configured_s3_bucket_name(litellm_params) trusted: Final = _trusted_s3_model_credentials(litellm_params) - return trusted.s3_output_bucket_name or os.getenv("AWS_S3_OUTPUT_BUCKET_NAME") or input_bucket_name + return ( + trusted.s3_output_bucket_name + or os.getenv("AWS_S3_OUTPUT_BUCKET_NAME") + or get_configured_s3_bucket_name(litellm_params) + ) def _listed_object_created_at(entry: ET.Element) -> int: @@ -1372,7 +1383,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): ) target: Final = self._s3_request_target(optional_params=optional_params, litellm_params=litellm_params) url: Final = f"{target.endpoint_url}/{bucket_name}/" - listing_query: Final = (("list-type", "2"), ("prefix", _managed_listing_prefix(configured_prefix, purpose))) + listing_query: Final = _listing_query(configured_prefix, purpose) continuation_query: Final = (("continuation-token", continuation_token),) if continuation_token else () query: Final[dict[str, str]] = dict( # mutable-ok: the base files contract returns the query as a dict listing_query + continuation_query diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 146f1d28386..b29185fec2e 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -59,6 +59,7 @@ from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig from litellm.llms.base_llm.image_generation.transformation import ( BaseImageGenerationConfig, ) +from litellm.llms.base_llm.managed_resources.isolation import build_list_page from litellm.llms.base_llm.ocr.transformation import BaseOCRConfig, OCRResponse from litellm.llms.base_llm.realtime.http_transformation import BaseRealtimeHTTPConfig from litellm.llms.base_llm.realtime.transformation import BaseRealtimeConfig @@ -120,6 +121,7 @@ from litellm.types.llms.openai import ( CreateBatchRequest, CreateFileRequest, FileContentRequest, + FileListPage, HttpxBinaryResponseContent, OpenAIFileObject, ResponseInputParam, @@ -4899,7 +4901,7 @@ class BaseLLMHTTPHandler: _is_async: bool = False, client: HTTPHandler | AsyncHTTPHandler | None = None, timeout: float | httpx.Timeout | None = None, - ) -> list[OpenAIFileObject] | Coroutine[object, object, list[OpenAIFileObject]]: + ) -> FileListPage | Coroutine[object, object, FileListPage]: """ List all files """ @@ -4954,9 +4956,10 @@ class BaseLLMHTTPHandler: files_per_page: Final = self._files_per_listing_page( response, provider_config, logging_obj, litellm_params, headers, sync_httpx_client, timeout ) - return [ # mutable-ok: the base files contract returns a list + listed_files: Final = [ # mutable-ok: build_list_page takes the list the files contract returns listed_file for page_files in files_per_page for listed_file in page_files ] + return FileListPage(**build_list_page(listed_files)) async def async_list_files( self, @@ -4967,7 +4970,7 @@ class BaseLLMHTTPHandler: logging_obj: LiteLLMLoggingObj, client: HTTPHandler | AsyncHTTPHandler | None = None, timeout: float | httpx.Timeout | None = None, - ) -> list[OpenAIFileObject]: + ) -> FileListPage: """ Async list all files """ @@ -5011,9 +5014,10 @@ class BaseLLMHTTPHandler: files_per_page: Final = self._files_per_async_listing_page( response, provider_config, logging_obj, litellm_params, headers, async_httpx_client, timeout ) - return [ # mutable-ok: the base files contract returns a list + listed_files: Final = [ # mutable-ok: build_list_page takes the list the files contract returns listed_file async for page_files in files_per_page for listed_file in page_files ] + return FileListPage(**build_list_page(listed_files)) def _files_per_listing_page( self, diff --git a/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py b/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py index 36359dd1110..da4853294ad 100644 --- a/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py +++ b/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py @@ -1721,4 +1721,5 @@ async def test_afile_delete_bedrock_unified_id_end_to_end(monkeypatch): assert route.called assert route.calls[0].request.headers["Authorization"].startswith("AWS4-HMAC-SHA256") assert response.id == unified_file_id + assert response.model_dump() == {"id": unified_file_id, "object": "file", "deleted": True} managed_files.delete_unified_file_id.assert_awaited_once_with(unified_file_id, None) diff --git a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py index 12b9f63c99b..f3506dc4045 100644 --- a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py +++ b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py @@ -3024,13 +3024,20 @@ class TestBedrockFileListTransformation: return_value=httpx.Response(200, content=self.LISTING) ) - files = litellm.file_list(custom_llm_provider="bedrock", purpose="batch", **_bedrock_s3_params()) + page = litellm.file_list(custom_llm_provider="bedrock", purpose="batch", **_bedrock_s3_params()) assert route.called request = route.calls[0].request assert request.headers["Authorization"].startswith("AWS4-HMAC-SHA256") assert _sent_signature(request.headers) == _s3_signature_for("GET", str(request.url), request.headers) - assert [file.id for file in files] == list(self.BATCH_IDS) + assert [file.id for file in page.data] == list(self.BATCH_IDS) + assert (page.object, page.first_id, page.last_id, page.has_more) == ( + "list", + self.BATCH_IDS[0], + self.BATCH_IDS[-1], + False, + ) + assert page.model_dump()["object"] == "list" def test_file_list_uses_trusted_snapshot_bucket_without_env(self, monkeypatch): import httpx @@ -3051,7 +3058,7 @@ class TestBedrockFileListTransformation: ) assert route.called - assert [file.id for file in files] == [*self.BATCH_IDS, self.OUTPUT_ID] + assert [file.id for file in files.data] == [*self.BATCH_IDS, self.OUTPUT_ID] @pytest.mark.asyncio async def test_afile_list_end_to_end_sends_signed_listing(self, monkeypatch): @@ -3076,7 +3083,8 @@ class TestBedrockFileListTransformation: assert route.called request = route.calls[0].request assert _sent_signature(request.headers) == _s3_signature_for("GET", str(request.url), request.headers) - assert [file.id for file in files] == [self.OUTPUT_ID] + assert [file.id for file in files.data] == [self.OUTPUT_ID] + assert (files.object, files.first_id, files.last_id, files.has_more) == ("list", self.OUTPUT_ID, self.OUTPUT_ID, False) def test_transform_list_files_request_narrows_prefix_to_requested_purpose(self, monkeypatch): from litellm.llms.bedrock.files.transformation import BedrockFilesConfig @@ -3133,6 +3141,73 @@ class TestBedrockFileListTransformation: assert (url, params) == (self.OUTPUT_BUCKET_URL, self.OUTPUT_QUERY) + EMPTY_LISTING = b""" + + my-bucket + + 0 + 0 + false +""" + NO_KEYS_QUERY = {"list-type": "2", "max-keys": "0"} + + def test_transform_list_files_request_asks_for_no_keys_when_bedrock_never_stores_the_purpose(self, monkeypatch): + import httpx + + from litellm.llms.bedrock.files.transformation import BedrockFilesConfig + + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") + litellm_params = _bedrock_s3_params() + config = BedrockFilesConfig() + + url, params = config.transform_list_files_request( + purpose="user_data", optional_params={}, litellm_params=litellm_params + ) + next_request = config.transform_list_files_next_request( + raw_response=httpx.Response(200, content=self.EMPTY_LISTING), + optional_params={}, + litellm_params=litellm_params, + ) + + assert (url, params) == (self.BUCKET_URL, self.NO_KEYS_QUERY) + assert next_request is None + + def test_file_list_never_walks_the_bucket_for_a_purpose_bedrock_never_stores(self, monkeypatch): + import httpx + import respx + + import litellm + + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") + + with respx.mock: + route = respx.get(self.BUCKET_URL, params__contains=self.NO_KEYS_QUERY).mock( + return_value=httpx.Response(200, content=self.EMPTY_LISTING) + ) + + page = litellm.file_list(custom_llm_provider="bedrock", purpose="user_data", **_bedrock_s3_params()) + + assert route.call_count == 1 + assert "prefix" not in route.calls[0].request.url.params + assert (page.data, page.has_more) == ([], False) + + def test_transform_list_files_request_lists_the_output_bucket_without_an_input_bucket(self, monkeypatch): + from litellm.llms.bedrock.files.transformation import BedrockFilesConfig + + monkeypatch.delenv("AWS_S3_BUCKET_NAME", raising=False) + monkeypatch.delenv("AWS_S3_OUTPUT_BUCKET_NAME", raising=False) + litellm_params = _trusted_bucket_snapshot(s3_output_bucket_name="my-output-bucket") + + url, params = BedrockFilesConfig().transform_list_files_request( + purpose="batch_output", optional_params={}, litellm_params=litellm_params + ) + + assert (url, params) == (self.OUTPUT_BUCKET_URL, self.OUTPUT_QUERY) + with pytest.raises(ValueError, match="s3_bucket_name"): + BedrockFilesConfig().transform_list_files_request( + purpose="batch", optional_params={}, litellm_params=dict(litellm_params) + ) + def test_transform_list_files_response_accepts_output_bucket_objects(self, monkeypatch): import httpx @@ -3178,7 +3253,7 @@ class TestBedrockFileListTransformation: assert route.called request = route.calls[0].request assert _sent_signature(request.headers) == _s3_signature_for("GET", str(request.url), request.headers) - assert [file.id for file in files] == [self.OUTPUT_BUCKET_ID] + assert [file.id for file in files.data] == [self.OUTPUT_BUCKET_ID] def test_transform_list_files_next_request_signs_the_continuation_page(self, monkeypatch): import httpx @@ -3275,7 +3350,7 @@ class TestBedrockFileListTransformation: **_trusted_bucket_snapshot(s3_bucket_name="my-bucket"), ) - self._assert_paged_listing(first_page, last_page, files) + self._assert_paged_listing(first_page, last_page, files.data) @pytest.mark.asyncio async def test_afile_list_follows_continuation_tokens_across_pages(self, monkeypatch): @@ -3297,7 +3372,7 @@ class TestBedrockFileListTransformation: **_trusted_bucket_snapshot(s3_bucket_name="my-bucket"), ) - self._assert_paged_listing(first_page, last_page, files) + self._assert_paged_listing(first_page, last_page, files.data) OVERSIZED_PAGE_SIZE = 3000 OVERSIZED_PAGE_COUNT = 6 @@ -3354,7 +3429,7 @@ class TestBedrockFileListTransformation: **_trusted_bucket_snapshot(s3_bucket_name="my-bucket"), ) - self._assert_capped_listing(route, files) + self._assert_capped_listing(route, files.data) @pytest.mark.asyncio async def test_afile_list_stops_at_the_openai_listing_ceiling(self, monkeypatch): @@ -3375,7 +3450,7 @@ class TestBedrockFileListTransformation: **_trusted_bucket_snapshot(s3_bucket_name="my-bucket"), ) - self._assert_capped_listing(route, files) + self._assert_capped_listing(route, files.data) def test_file_list_end_to_end_surfaces_the_s3_error_body(self, monkeypatch): import httpx From 5f4d667365c4ce99574bc9a36877a623154ebffe Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:17:37 -0700 Subject: [PATCH 09/12] fix(files): keep the SDK listing a list and build the OpenAI page at the proxy route --- litellm/llms/custom_httpx/llm_http_handler.py | 12 ++-- .../openai_files_endpoints/files_endpoints.py | 10 ++++ .../test_bedrock_files_transformation.py | 30 ++++------ .../test_files_endpoint.py | 57 +++++++++++++++++++ 4 files changed, 82 insertions(+), 27 deletions(-) diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index b29185fec2e..0c9c7ad2b7f 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -59,7 +59,6 @@ from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig from litellm.llms.base_llm.image_generation.transformation import ( BaseImageGenerationConfig, ) -from litellm.llms.base_llm.managed_resources.isolation import build_list_page from litellm.llms.base_llm.ocr.transformation import BaseOCRConfig, OCRResponse from litellm.llms.base_llm.realtime.http_transformation import BaseRealtimeHTTPConfig from litellm.llms.base_llm.realtime.transformation import BaseRealtimeConfig @@ -121,7 +120,6 @@ from litellm.types.llms.openai import ( CreateBatchRequest, CreateFileRequest, FileContentRequest, - FileListPage, HttpxBinaryResponseContent, OpenAIFileObject, ResponseInputParam, @@ -4901,7 +4899,7 @@ class BaseLLMHTTPHandler: _is_async: bool = False, client: HTTPHandler | AsyncHTTPHandler | None = None, timeout: float | httpx.Timeout | None = None, - ) -> FileListPage | Coroutine[object, object, FileListPage]: + ) -> list[OpenAIFileObject] | Coroutine[object, object, list[OpenAIFileObject]]: """ List all files """ @@ -4956,10 +4954,9 @@ class BaseLLMHTTPHandler: files_per_page: Final = self._files_per_listing_page( response, provider_config, logging_obj, litellm_params, headers, sync_httpx_client, timeout ) - listed_files: Final = [ # mutable-ok: build_list_page takes the list the files contract returns + return [ # mutable-ok: the files contract returns the listing as a list listed_file for page_files in files_per_page for listed_file in page_files ] - return FileListPage(**build_list_page(listed_files)) async def async_list_files( self, @@ -4970,7 +4967,7 @@ class BaseLLMHTTPHandler: logging_obj: LiteLLMLoggingObj, client: HTTPHandler | AsyncHTTPHandler | None = None, timeout: float | httpx.Timeout | None = None, - ) -> FileListPage: + ) -> list[OpenAIFileObject]: """ Async list all files """ @@ -5014,10 +5011,9 @@ class BaseLLMHTTPHandler: files_per_page: Final = self._files_per_async_listing_page( response, provider_config, logging_obj, litellm_params, headers, async_httpx_client, timeout ) - listed_files: Final = [ # mutable-ok: build_list_page takes the list the files contract returns + return [ # mutable-ok: the files contract returns the listing as a list listed_file async for page_files in files_per_page for listed_file in page_files ] - return FileListPage(**build_list_page(listed_files)) def _files_per_listing_page( self, diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index 00acc524eb8..28455270c17 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -33,6 +33,7 @@ from litellm.litellm_core_utils.cloud_storage_security import ( ) from litellm.litellm_core_utils.core_helpers import get_or_create_metadata_bucket from litellm.llms.base_llm.files.transformation import BaseFileEndpoints +from litellm.llms.base_llm.managed_resources.isolation import build_list_page from litellm.proxy._types import * from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing @@ -85,6 +86,7 @@ from litellm.router import Router from litellm.types.llms.openai import ( CREATE_FILE_REQUESTS_PURPOSE, FileExpiresAfter, + FileListPage, OpenAIFileObject, OpenAIFilesPurpose, ) @@ -92,6 +94,7 @@ from litellm.types.llms.openai import ( router: Final = APIRouter() _MAX_BATCH_FILE_SIZE_MB_ADAPTER: Final = TypeAdapter(int | None) +_LISTED_FILES_ADAPTER: Final = TypeAdapter(list[OpenAIFileObject]) class UploadedFileInfo(TypedDict): @@ -1441,6 +1444,12 @@ async def delete_file( ) +def _as_file_list_page(response: object) -> object: + if not isinstance(response, list): + return response + return FileListPage(**build_list_page(_LISTED_FILES_ADAPTER.validate_python(response))) + + @router.get( "/{provider}/v1/files", dependencies=[Depends(user_api_key_auth)], @@ -1587,6 +1596,7 @@ async def list_files( status_code=500, detail="Either 'provider' or 'target_model_names' must be provided e.g. `?target_model_names=gpt-4o`", ) + response = _as_file_list_page(response) # rebind-ok: each dispatch branch above binds response ## POST CALL HOOKS ### _response: Final = await proxy_logging_obj.post_call_success_hook( diff --git a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py index f3506dc4045..55f4b75ba02 100644 --- a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py +++ b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py @@ -3024,20 +3024,13 @@ class TestBedrockFileListTransformation: return_value=httpx.Response(200, content=self.LISTING) ) - page = litellm.file_list(custom_llm_provider="bedrock", purpose="batch", **_bedrock_s3_params()) + files = litellm.file_list(custom_llm_provider="bedrock", purpose="batch", **_bedrock_s3_params()) assert route.called request = route.calls[0].request assert request.headers["Authorization"].startswith("AWS4-HMAC-SHA256") assert _sent_signature(request.headers) == _s3_signature_for("GET", str(request.url), request.headers) - assert [file.id for file in page.data] == list(self.BATCH_IDS) - assert (page.object, page.first_id, page.last_id, page.has_more) == ( - "list", - self.BATCH_IDS[0], - self.BATCH_IDS[-1], - False, - ) - assert page.model_dump()["object"] == "list" + assert [file.id for file in files] == list(self.BATCH_IDS) def test_file_list_uses_trusted_snapshot_bucket_without_env(self, monkeypatch): import httpx @@ -3058,7 +3051,7 @@ class TestBedrockFileListTransformation: ) assert route.called - assert [file.id for file in files.data] == [*self.BATCH_IDS, self.OUTPUT_ID] + assert [file.id for file in files] == [*self.BATCH_IDS, self.OUTPUT_ID] @pytest.mark.asyncio async def test_afile_list_end_to_end_sends_signed_listing(self, monkeypatch): @@ -3083,8 +3076,7 @@ class TestBedrockFileListTransformation: assert route.called request = route.calls[0].request assert _sent_signature(request.headers) == _s3_signature_for("GET", str(request.url), request.headers) - assert [file.id for file in files.data] == [self.OUTPUT_ID] - assert (files.object, files.first_id, files.last_id, files.has_more) == ("list", self.OUTPUT_ID, self.OUTPUT_ID, False) + assert [file.id for file in files] == [self.OUTPUT_ID] def test_transform_list_files_request_narrows_prefix_to_requested_purpose(self, monkeypatch): from litellm.llms.bedrock.files.transformation import BedrockFilesConfig @@ -3185,11 +3177,11 @@ class TestBedrockFileListTransformation: return_value=httpx.Response(200, content=self.EMPTY_LISTING) ) - page = litellm.file_list(custom_llm_provider="bedrock", purpose="user_data", **_bedrock_s3_params()) + files = litellm.file_list(custom_llm_provider="bedrock", purpose="user_data", **_bedrock_s3_params()) assert route.call_count == 1 assert "prefix" not in route.calls[0].request.url.params - assert (page.data, page.has_more) == ([], False) + assert files == [] def test_transform_list_files_request_lists_the_output_bucket_without_an_input_bucket(self, monkeypatch): from litellm.llms.bedrock.files.transformation import BedrockFilesConfig @@ -3253,7 +3245,7 @@ class TestBedrockFileListTransformation: assert route.called request = route.calls[0].request assert _sent_signature(request.headers) == _s3_signature_for("GET", str(request.url), request.headers) - assert [file.id for file in files.data] == [self.OUTPUT_BUCKET_ID] + assert [file.id for file in files] == [self.OUTPUT_BUCKET_ID] def test_transform_list_files_next_request_signs_the_continuation_page(self, monkeypatch): import httpx @@ -3350,7 +3342,7 @@ class TestBedrockFileListTransformation: **_trusted_bucket_snapshot(s3_bucket_name="my-bucket"), ) - self._assert_paged_listing(first_page, last_page, files.data) + self._assert_paged_listing(first_page, last_page, files) @pytest.mark.asyncio async def test_afile_list_follows_continuation_tokens_across_pages(self, monkeypatch): @@ -3372,7 +3364,7 @@ class TestBedrockFileListTransformation: **_trusted_bucket_snapshot(s3_bucket_name="my-bucket"), ) - self._assert_paged_listing(first_page, last_page, files.data) + self._assert_paged_listing(first_page, last_page, files) OVERSIZED_PAGE_SIZE = 3000 OVERSIZED_PAGE_COUNT = 6 @@ -3429,7 +3421,7 @@ class TestBedrockFileListTransformation: **_trusted_bucket_snapshot(s3_bucket_name="my-bucket"), ) - self._assert_capped_listing(route, files.data) + self._assert_capped_listing(route, files) @pytest.mark.asyncio async def test_afile_list_stops_at_the_openai_listing_ceiling(self, monkeypatch): @@ -3450,7 +3442,7 @@ class TestBedrockFileListTransformation: **_trusted_bucket_snapshot(s3_bucket_name="my-bucket"), ) - self._assert_capped_listing(route, files.data) + self._assert_capped_listing(route, files) def test_file_list_end_to_end_surfaces_the_s3_error_body(self, monkeypatch): import httpx diff --git a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py index 824170e6b3d..3b700d80539 100644 --- a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py +++ b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py @@ -2441,6 +2441,63 @@ def test_list_files_resolves_wildcard_deployment_credentials( proxy_logging_obj.post_call_failure_hook.assert_not_called() +def test_list_files_by_model_returns_an_openai_page_for_a_provider_listing( + mocker: MockerFixture, monkeypatch, llm_router: Router +): + import litellm.proxy.proxy_server as ps + from litellm.proxy._types import LitellmUserRoles + from litellm.types.llms.openai import FileListPage + + proxy_logging_obj = setup_proxy_logging_object(monkeypatch, llm_router) + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router) + proxy_logging_obj.update_request_status = mocker.AsyncMock() + proxy_logging_obj.post_call_success_hook = mocker.AsyncMock(return_value=None) + proxy_logging_obj.post_call_failure_hook = mocker.AsyncMock() + + listed_files = [ + OpenAIFileObject( + id=f"file-{index}", + bytes=index, + created_at=index, + filename=f"{index}.jsonl", + object="file", + purpose="batch", + status="uploaded", + ) + for index in (1, 2) + ] + + async def _mock_afile_list(**kwargs): + return list(listed_files) + + monkeypatch.setattr(litellm, "afile_list", _mock_afile_list) + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + api_key="test-key", + user_role=LitellmUserRoles.PROXY_ADMIN, + user_id="test-user", + ) + + try: + response = client.get( + "/v1/files?target_model_names=gpt-3.5-turbo", + headers={"Authorization": "Bearer test-key"}, + ) + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + assert response.status_code == 200, response.text + body = response.json() + assert body["object"] == "list" + assert [listed["id"] for listed in body["data"]] == ["file-1", "file-2"] + assert (body["first_id"], body["last_id"], body["has_more"]) == ("file-1", "file-2", False) + hook_response = proxy_logging_obj.post_call_success_hook.call_args.kwargs["response"] + assert isinstance(hook_response, FileListPage) + assert [listed.id for listed in hook_response.data] == ["file-1", "file-2"] + + def test_list_files_model_routing_does_not_forward_custom_llm_provider_twice( mocker: MockerFixture, monkeypatch, llm_router: Router ): From 9fcc64fbf9fbf93c42d494b8d3aee81b239b2b6f Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:09:44 -0700 Subject: [PATCH 10/12] fix(files): only proxy admin keys may delete raw cloud storage file ids A key allowed to call a Bedrock model could delete any object under the deployment's buckets through DELETE /bedrock/v1/files/{s3 id}?model=... because the managed-file ownership check only runs for unified ids. Raw cloud storage ids now answer 403 on every delete route unless the caller is a proxy admin; managed ids and require_managed_files are unchanged --- .../openai_files_endpoints/files_endpoints.py | 5 + .../test_files_endpoint.py | 109 ++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index 28455270c17..2e98f7fa3b3 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -1285,6 +1285,11 @@ async def delete_file( user_api_key_dict=user_api_key_dict, managed_files_obj=proxy_logging_obj.get_proxy_hook("managed_files"), ) + if is_managed_cloud_storage_uri(file_id) and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException( + status_code=403, + detail="Raw cloud storage file ids can only be deleted by a proxy admin key. Use the LiteLLM managed file id returned when the file was created.", + ) custom_llm_provider: Final = ( provider diff --git a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py index 3b700d80539..d80321cd9ae 100644 --- a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py +++ b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py @@ -4839,3 +4839,112 @@ def test_delete_file_answers_400_for_an_id_outside_the_configured_bucket(mocker: assert response.status_code == 400, response.text assert "configured storage bucket" in response.json()["error"]["message"] + + +def _bedrock_batch_router() -> Router: + return Router( + model_list=[ + { + "model_name": "bedrock-claude", + "litellm_params": { + "model": "bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0", + "aws_access_key_id": "AKIAEXAMPLE", + "aws_secret_access_key": "secret", + "aws_region_name": "us-west-2", + "s3_bucket_name": "my-bucket", + }, + }, + ] + ) + + +RAW_S3_FILE_ID: Final = "s3://my-bucket/litellm-batch-outputs/job-123/abc/input.jsonl.out" + + +@pytest.mark.parametrize("route_prefix", ("/bedrock/v1/files", "/v1/files", "/files")) +def test_delete_file_answers_403_for_a_raw_cloud_id_from_a_non_admin_key( + mocker: MockerFixture, monkeypatch, route_prefix: str +): + from urllib.parse import quote + + import litellm.proxy.proxy_server as ps + from litellm.proxy._types import LitellmUserRoles + + bedrock_router = _bedrock_batch_router() + proxy_logging_obj = setup_proxy_logging_object(monkeypatch, bedrock_router) + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", bedrock_router) + proxy_logging_obj.update_request_status = mocker.AsyncMock() + proxy_logging_obj.post_call_failure_hook = mocker.AsyncMock() + afile_delete = mocker.AsyncMock() + monkeypatch.setattr(litellm, "afile_delete", afile_delete) + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + api_key="test-key", + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="test-user", + models=["bedrock-claude"], + ) + + try: + response = client.delete( + f"{route_prefix}/{quote(RAW_S3_FILE_ID, safe='')}?model=bedrock-claude", + headers={"Authorization": "Bearer test-key"}, + ) + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + assert response.status_code == 403, response.text + assert "proxy admin" in response.json()["error"]["message"] + afile_delete.assert_not_called() + + +def test_delete_file_forwards_a_raw_cloud_id_from_a_proxy_admin_key(mocker: MockerFixture, monkeypatch): + from urllib.parse import quote + + import litellm.proxy.proxy_server as ps + from litellm.proxy._types import LitellmUserRoles + + bedrock_router = _bedrock_batch_router() + proxy_logging_obj = setup_proxy_logging_object(monkeypatch, bedrock_router) + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", bedrock_router) + proxy_logging_obj.update_request_status = mocker.AsyncMock() + proxy_logging_obj.post_call_failure_hook = mocker.AsyncMock() + + captured_kwargs: dict = {} + + async def _mock_afile_delete(**kwargs): + captured_kwargs.update(kwargs) + return OpenAIFileObject( + id=RAW_S3_FILE_ID, + object="file", + bytes=2, + created_at=1234567890, + filename="input.jsonl.out", + purpose="batch_output", + status="processed", + ) + + monkeypatch.setattr(litellm, "afile_delete", _mock_afile_delete) + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + api_key="test-key", + user_role=LitellmUserRoles.PROXY_ADMIN, + user_id="test-user", + ) + + try: + response = client.delete( + f"/bedrock/v1/files/{quote(RAW_S3_FILE_ID, safe='')}?model=bedrock-claude", + headers={"Authorization": "Bearer test-key"}, + ) + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + assert response.status_code == 200, response.text + assert captured_kwargs.get("file_id") == RAW_S3_FILE_ID + assert captured_kwargs.get("custom_llm_provider") == "bedrock" + proxy_logging_obj.post_call_failure_hook.assert_not_called() From 50215c87173225ea4b6d66c4c504e7202468d78b Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:17:09 -0700 Subject: [PATCH 11/12] test(files): cover the admin-only raw cloud id rule for Vertex GCS ids --- .../test_files_endpoint.py | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py index d80321cd9ae..c8a705a729a 100644 --- a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py +++ b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py @@ -4841,7 +4841,7 @@ def test_delete_file_answers_400_for_an_id_outside_the_configured_bucket(mocker: assert "configured storage bucket" in response.json()["error"]["message"] -def _bedrock_batch_router() -> Router: +def _cloud_files_router() -> Router: return Router( model_list=[ { @@ -4854,23 +4854,41 @@ def _bedrock_batch_router() -> Router: "s3_bucket_name": "my-bucket", }, }, + { + "model_name": "vertex-gemini", + "litellm_params": { + "model": "vertex_ai/gemini-3.8-flash", + "vertex_project": "my-project", + "vertex_location": "us-central1", + "gcs_bucket_name": "my-gcs-bucket", + }, + }, ] ) RAW_S3_FILE_ID: Final = "s3://my-bucket/litellm-batch-outputs/job-123/abc/input.jsonl.out" +RAW_GCS_FILE_ID: Final = "gs://my-gcs-bucket/litellm-vertex-files/publishers/google/models/gemini-3.8-flash/abc123" -@pytest.mark.parametrize("route_prefix", ("/bedrock/v1/files", "/v1/files", "/files")) +@pytest.mark.parametrize( + ("route_prefix", "raw_file_id", "model_name"), + ( + ("/bedrock/v1/files", RAW_S3_FILE_ID, "bedrock-claude"), + ("/v1/files", RAW_S3_FILE_ID, "bedrock-claude"), + ("/files", RAW_S3_FILE_ID, "bedrock-claude"), + ("/vertex_ai/v1/files", RAW_GCS_FILE_ID, "vertex-gemini"), + ), +) def test_delete_file_answers_403_for_a_raw_cloud_id_from_a_non_admin_key( - mocker: MockerFixture, monkeypatch, route_prefix: str + mocker: MockerFixture, monkeypatch, route_prefix: str, raw_file_id: str, model_name: str ): from urllib.parse import quote import litellm.proxy.proxy_server as ps from litellm.proxy._types import LitellmUserRoles - bedrock_router = _bedrock_batch_router() + bedrock_router = _cloud_files_router() proxy_logging_obj = setup_proxy_logging_object(monkeypatch, bedrock_router) monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None) monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) @@ -4884,12 +4902,12 @@ def test_delete_file_answers_403_for_a_raw_cloud_id_from_a_non_admin_key( api_key="test-key", user_role=LitellmUserRoles.INTERNAL_USER, user_id="test-user", - models=["bedrock-claude"], + models=["bedrock-claude", "vertex-gemini"], ) try: response = client.delete( - f"{route_prefix}/{quote(RAW_S3_FILE_ID, safe='')}?model=bedrock-claude", + f"{route_prefix}/{quote(raw_file_id, safe='')}?model={model_name}", headers={"Authorization": "Bearer test-key"}, ) finally: @@ -4906,7 +4924,7 @@ def test_delete_file_forwards_a_raw_cloud_id_from_a_proxy_admin_key(mocker: Mock import litellm.proxy.proxy_server as ps from litellm.proxy._types import LitellmUserRoles - bedrock_router = _bedrock_batch_router() + bedrock_router = _cloud_files_router() proxy_logging_obj = setup_proxy_logging_object(monkeypatch, bedrock_router) monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None) monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) From b4919d9bd78386db9a65c9c381b45c0aecce3a24 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:53:57 -0700 Subject: [PATCH 12/12] fix(bedrock): walk the output location on an unfiltered files list A list without a purpose covered the input bucket only, so a deployment with a separate s3_output_bucket_name never saw its batch outputs unless the caller passed purpose=batch_output. The listing now follows the input location to its last page and then walks the output location whenever it differs from the input one, in bucket or in prefix, so the unfiltered list matches what OpenAI returns --- litellm/llms/bedrock/files/transformation.py | 27 +++- .../test_bedrock_files_transformation.py | 127 ++++++++++++++++++ 2 files changed, 149 insertions(+), 5 deletions(-) diff --git a/litellm/llms/bedrock/files/transformation.py b/litellm/llms/bedrock/files/transformation.py index 29732f8afe2..6e2b0c12090 100644 --- a/litellm/llms/bedrock/files/transformation.py +++ b/litellm/llms/bedrock/files/transformation.py @@ -69,6 +69,8 @@ S3_SIGNED_REQUEST_HEADERS_PARAM: Final = "_s3_signed_request_headers" LIST_FILES_PURPOSE_PARAM: Final = "_s3_list_files_purpose" +LIST_FILES_LOCATION_PARAM: Final = "_s3_list_files_location" + class _S3DeleteContext(BaseModel): file_id: str = Field(min_length=1) @@ -322,6 +324,17 @@ def _requested_listing_purpose(litellm_params: Mapping[str, object]) -> str | No return requested_purpose if isinstance(requested_purpose, str) else None +def _walked_listing_purpose(litellm_params: Mapping[str, object]) -> str | None: + walked_purpose: Final = litellm_params.get(LIST_FILES_LOCATION_PARAM) + return walked_purpose if isinstance(walked_purpose, str) else _requested_listing_purpose(litellm_params) + + +def _output_location_still_unlisted(litellm_params: Mapping[str, object]) -> bool: + if _walked_listing_purpose(litellm_params) is not None: + return False + return _listing_bucket_name(litellm_params, "batch_output") != _listing_bucket_name(litellm_params, None) + + def _listing_bucket_name(litellm_params: Mapping[str, object], purpose: str | None) -> str: if purpose != "batch_output": return get_configured_s3_bucket_name(litellm_params) @@ -1342,6 +1355,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): litellm_params: MutableMapping[str, object], ) -> tuple[str, dict[str, str]]: litellm_params[LIST_FILES_PURPOSE_PARAM] = purpose # rebind-ok: handed to the response transform + litellm_params[LIST_FILES_LOCATION_PARAM] = purpose # rebind-ok: names the location the next page walks return self._signed_listing_request(purpose, optional_params, litellm_params, continuation_token=None) def transform_list_files_next_request( @@ -1353,11 +1367,14 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): if raw_response.status_code >= 400: return None continuation_token: Final = ET.fromstring(raw_response.content).findtext("{*}NextContinuationToken") - if not continuation_token: + if continuation_token: + return self._signed_listing_request( + _walked_listing_purpose(litellm_params), optional_params, litellm_params, continuation_token + ) + if not _output_location_still_unlisted(litellm_params): return None - return self._signed_listing_request( - _requested_listing_purpose(litellm_params), optional_params, litellm_params, continuation_token - ) + litellm_params[LIST_FILES_LOCATION_PARAM] = "batch_output" # rebind-ok: the input location is fully listed + return self._signed_listing_request("batch_output", optional_params, litellm_params, continuation_token=None) def _signed_listing_request( self, @@ -1399,7 +1416,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): response=raw_response, ) purpose: Final = _requested_listing_purpose(litellm_params) - configured_bucket_name: Final = _listing_bucket_name(litellm_params, purpose) + configured_bucket_name: Final = _listing_bucket_name(litellm_params, _walked_listing_purpose(litellm_params)) allow_legacy_cloud_file_ids: Final = should_allow_legacy_cloud_file_ids(litellm_params) listing: Final = ET.fromstring(raw_response.content) bucket_name: Final = ( diff --git a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py index 84433ea79a9..c609455f3d8 100644 --- a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py +++ b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py @@ -3340,6 +3340,133 @@ class TestBedrockFileListTransformation: assert _sent_signature(request.headers) == _s3_signature_for("GET", str(request.url), request.headers) assert [file.id for file in files] == [self.OUTPUT_BUCKET_ID] + def test_file_list_without_purpose_also_walks_a_separate_output_bucket(self, monkeypatch): + import httpx + import respx + + import litellm + + monkeypatch.delenv("AWS_S3_BUCKET_NAME", raising=False) + monkeypatch.delenv("AWS_S3_OUTPUT_BUCKET_NAME", raising=False) + + with respx.mock: + input_route = respx.get(self.BUCKET_URL, params__contains=self.MANAGED_QUERY).mock( + return_value=httpx.Response(200, content=self.LISTING) + ) + output_route = respx.get(self.OUTPUT_BUCKET_URL, params__contains=self.OUTPUT_QUERY).mock( + return_value=httpx.Response(200, content=self.OUTPUT_BUCKET_LISTING) + ) + + files = litellm.file_list( + custom_llm_provider="bedrock", + **_trusted_bucket_snapshot(s3_bucket_name="my-bucket", s3_output_bucket_name="my-output-bucket"), + ) + + assert (input_route.call_count, output_route.call_count) == (1, 1) + output_request = output_route.calls[0].request + assert _sent_signature(output_request.headers) == _s3_signature_for( + "GET", str(output_request.url), output_request.headers + ) + assert [file.id for file in files] == [*self.BATCH_IDS, self.OUTPUT_ID, self.OUTPUT_BUCKET_ID] + + def test_file_list_without_purpose_walks_the_output_bucket_after_the_last_input_page(self, monkeypatch): + import httpx + import respx + + import litellm + + monkeypatch.delenv("AWS_S3_BUCKET_NAME", raising=False) + monkeypatch.delenv("AWS_S3_OUTPUT_BUCKET_NAME", raising=False) + + with respx.mock: + respx.get(self.BUCKET_URL, params__contains={"continuation-token": self.CONTINUATION_TOKEN}).mock( + return_value=httpx.Response(200, content=self.LAST_PAGE) + ) + respx.get(self.BUCKET_URL, params__contains=self.MANAGED_QUERY).mock( + return_value=httpx.Response(200, content=self.FIRST_PAGE) + ) + respx.get(self.OUTPUT_BUCKET_URL, params__contains=self.OUTPUT_QUERY).mock( + return_value=httpx.Response(200, content=self.OUTPUT_BUCKET_LISTING) + ) + + files = litellm.file_list( + custom_llm_provider="bedrock", + **_trusted_bucket_snapshot(s3_bucket_name="my-bucket", s3_output_bucket_name="my-output-bucket"), + ) + requested_urls = [str(call.request.url) for call in respx.calls] + + assert requested_urls == [ + f"{self.BUCKET_URL}?list-type=2&prefix=litellm-b", + f"{self.BUCKET_URL}?list-type=2&prefix=litellm-b" + "&continuation-token=1ueGcxLPRx1Tr%2FXYExHnhbYLgveDs2J%2Fwm36Hy4vbOwM%3D", + f"{self.OUTPUT_BUCKET_URL}?list-type=2&prefix=litellm-batch-outputs%2F", + ] + assert [file.id for file in files] == [*self.PAGED_IDS, self.OUTPUT_BUCKET_ID] + + @pytest.mark.parametrize( + ("purpose", "bucket_snapshot"), + [ + pytest.param(None, {"s3_bucket_name": "my-bucket"}, id="outputs-share-the-input-bucket"), + pytest.param( + "batch", + {"s3_bucket_name": "my-bucket", "s3_output_bucket_name": "my-output-bucket"}, + id="input-purpose-requested", + ), + ], + ) + def test_file_list_leaves_the_output_bucket_alone_unless_an_unfiltered_list_needs_it( + self, monkeypatch, purpose, bucket_snapshot + ): + import httpx + import respx + + import litellm + + monkeypatch.delenv("AWS_S3_BUCKET_NAME", raising=False) + monkeypatch.delenv("AWS_S3_OUTPUT_BUCKET_NAME", raising=False) + + with respx.mock: + input_route = respx.get(self.BUCKET_URL).mock(return_value=httpx.Response(200, content=self.LISTING)) + output_route = respx.get(self.OUTPUT_BUCKET_URL).mock( + return_value=httpx.Response(200, content=self.OUTPUT_BUCKET_LISTING) + ) + + files = litellm.file_list( + custom_llm_provider="bedrock", purpose=purpose, **_trusted_bucket_snapshot(**bucket_snapshot) + ) + + assert (input_route.call_count, output_route.call_count) == (1, 0) + assert [file.id for file in files] == [*self.BATCH_IDS, *(() if purpose else (self.OUTPUT_ID,))] + + def test_transform_list_files_next_request_walks_an_output_prefix_inside_the_input_bucket(self, monkeypatch): + import httpx + + from litellm.llms.bedrock.files.transformation import ( + S3_SIGNED_REQUEST_HEADERS_PARAM, + BedrockFilesConfig, + ) + + monkeypatch.delenv("AWS_S3_BUCKET_NAME", raising=False) + monkeypatch.delenv("AWS_S3_OUTPUT_BUCKET_NAME", raising=False) + litellm_params = _trusted_bucket_snapshot(s3_bucket_name="my-bucket", s3_output_bucket_name="my-bucket/out") + config = BedrockFilesConfig() + config.transform_list_files_request(purpose=None, optional_params={}, litellm_params=litellm_params) + litellm_params.pop(S3_SIGNED_REQUEST_HEADERS_PARAM) + + output_request = config.transform_list_files_next_request( + raw_response=httpx.Response(200, content=self.LISTING), optional_params={}, litellm_params=litellm_params + ) + after_output_request = config.transform_list_files_next_request( + raw_response=httpx.Response(200, content=self.LISTING), optional_params={}, litellm_params=litellm_params + ) + + assert output_request == (self.BUCKET_URL, {"list-type": "2", "prefix": "out/litellm-batch-outputs/"}) + signed_headers = litellm_params[S3_SIGNED_REQUEST_HEADERS_PARAM] + assert _sent_signature(signed_headers) == _s3_signature_for( + "GET", f"{self.BUCKET_URL}?list-type=2&prefix=out%2Flitellm-batch-outputs%2F", signed_headers + ) + assert after_output_request is None + def test_transform_list_files_next_request_signs_the_continuation_page(self, monkeypatch): import httpx