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