diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py index ca73a0574da..4841fad2ec9 100644 --- a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py +++ b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py @@ -67,7 +67,6 @@ from litellm.types.llms.openai import ( # pyright: ignore[reportAttributeAccess CreateFileRequest, FileObject, OpenAIFileObject, - OpenAIFilesPurpose, ResponsesAPIResponse, ) from litellm.types.utils import ( @@ -1386,7 +1385,10 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): Pagination is keyset based on ``unified_file_id`` so a key that owns every file on the proxy still reads one bounded page at a time. ``purpose`` is applied after parsing because the managed file table - keeps it inside the ``file_object`` blob instead of a column. + keeps it inside the ``file_object`` blob instead of a column, so a + narrowed page can hold fewer files than ``limit``. ``last_id`` then + falls back to the last row the page read, which keeps the cursor + usable even when every file on the page was filtered out. """ validate_file_list_limit(limit) if limit == 0: @@ -1416,14 +1418,19 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): **cursor_args, ) has_more: Final = len(rows) > page_size + page_rows: Final = rows[:page_size] files: Final = [ parsed_file_object.model_copy(update={"id": row.unified_file_id}) - for row in rows[:page_size] + for row in page_rows if (parsed_file_object := _parse_managed_file_object(row.file_object, row.unified_file_id)) is not None and (purpose is None or parsed_file_object.purpose == purpose) ] - return build_list_page(files, has_more=has_more) + return build_list_page( + files, + has_more=has_more, + next_cursor_id=page_rows[-1].unified_file_id if page_rows else None, + ) def _is_batch_polling_enabled(self) -> bool: """ diff --git a/litellm/llms/base_llm/managed_resources/isolation.py b/litellm/llms/base_llm/managed_resources/isolation.py index e1b204214d7..f1a54943de0 100644 --- a/litellm/llms/base_llm/managed_resources/isolation.py +++ b/litellm/llms/base_llm/managed_resources/isolation.py @@ -19,15 +19,23 @@ from litellm.proxy._types import ( ) -def build_list_page(items: list[Any], has_more: bool = False) -> dict[str, Any]: +def build_list_page( + items: list[Any], + has_more: bool = False, + next_cursor_id: str | None = None, +) -> dict[str, Any]: """Build the OpenAI-style paginated list response shape used by managed file/batch/vector-store listings. ``first_id`` and ``last_id`` are - sourced from each item's ``.id`` attribute.""" + sourced from each item's ``.id`` attribute. + + A listing that filters rows out after reading them can pass + ``next_cursor_id`` so an empty page still carries the cursor the caller + needs to reach the rows behind it.""" return { "object": "list", "data": items, "first_id": items[0].id if items else None, - "last_id": items[-1].id if items else None, + "last_id": items[-1].id if items else next_cursor_id, "has_more": has_more, } 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 49a1119c7a0..68ded79199d 100644 --- a/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py +++ b/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py @@ -334,6 +334,40 @@ async def test_afile_list_filters_by_purpose(): assert [file.id for file in response["data"]] == ["unified-batch"] +@pytest.mark.asyncio +async def test_afile_list_keeps_a_usable_cursor_when_a_page_filters_everything_out(): + managed_files, _ = _make_managed_files_over_rows( + [ + _make_managed_file_row("unified-0"), + _make_managed_file_row("unified-1"), + _make_managed_file_row("unified-2", purpose="batch"), + ] + ) + user_api_key_dict = _make_user_api_key_dict() + + first_page = await managed_files.afile_list( + purpose="batch", + litellm_parent_otel_span=None, + user_api_key_dict=user_api_key_dict, + limit=2, + ) + + assert first_page["data"] == [] + assert first_page["has_more"] is True + assert first_page["last_id"] == "unified-1" + + second_page = await managed_files.afile_list( + purpose="batch", + litellm_parent_otel_span=None, + user_api_key_dict=user_api_key_dict, + limit=2, + after=first_page["last_id"], + ) + + assert [file.id for file in second_page["data"]] == ["unified-2"] + assert second_page["has_more"] is False + + @pytest.mark.asyncio async def test_afile_list_honors_limit_and_reports_more_pages(): managed_files, table = _make_managed_files_over_rows(