From 85ad6971e997f2507d6fe0be51423f22d6bf782d Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:49:13 -0700 Subject: [PATCH] fix(batches): keep an empty `after` meaning "start from the beginning" Validating the cursor whenever `after` was non-None turned `?after=` into a 400, which the listing has always read as "no cursor". Only a cursor the client actually sent is looked up now, matching the sibling managed-resource listing. --- .../proxy/hooks/managed_files.py | 6 ++-- .../proxy/hooks/test_managed_files.py | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py index 56e7f7ba633..8821736d0ff 100644 --- a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py +++ b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py @@ -315,7 +315,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): where_clause: Dict[str, Any] = {"file_purpose": "batch", **owner_filter} - if after is not None: + if after: cursor_row = ( await self.prisma_client.db.litellm_managedobjecttable.find_first( where={**where_clause, "unified_object_id": after} @@ -329,9 +329,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): page_size = limit or 20 cursor_args: Dict[str, Any] = ( - {"cursor": {"unified_object_id": after}, "skip": 1} - if after is not None - else {} + {"cursor": {"unified_object_id": after}, "skip": 1} if after else {} ) batches = await self.prisma_client.db.litellm_managedobjecttable.find_many( diff --git a/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py b/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py index bbcdbac2709..50af6465d06 100644 --- a/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py +++ b/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py @@ -2309,6 +2309,35 @@ async def test_list_batches_rejects_unknown_after_cursor(): ) +@pytest.mark.asyncio +async def test_list_batches_treats_empty_after_as_no_cursor(): + """``?after=`` means "start from the beginning", as it always has. + + Only a cursor the client actually sent is validated, so an SDK that always + emits the query parameter does not get a 400 on its first page. + """ + from litellm.proxy._types import UserAPIKeyAuth + + rows = [_managed_batch_row(i) for i in range(2)] + prisma_client = _fake_managed_object_table(rows) + + proxy_managed_files = _PROXY_LiteLLMManagedFiles( + DualCache(), prisma_client=prisma_client + ) + + page = await proxy_managed_files.list_user_batches( + user_api_key_dict=UserAPIKeyAuth(user_id="test-user"), limit=2, after="" + ) + + assert [batch.id for batch in page["data"]] == [ + rows[1].unified_object_id, + rows[0].unified_object_id, + ] + prisma_client.db.litellm_managedobjecttable.find_first.assert_not_called() + _, call_kwargs = prisma_client.db.litellm_managedobjecttable.find_many.call_args + assert "cursor" not in call_kwargs + + @pytest.mark.asyncio async def test_list_batches_rejects_after_cursor_owned_by_another_user(): """The cursor lookup must be scoped to the rows the caller can list.