fix(files): keep the list cursor usable on a filtered page

A page whose rows are all dropped by the purpose filter, or by a row
that does not parse, used to come back with an empty data list, has_more
true and last_id null, so the caller had no cursor to advance with and
stopped one page short of files it owns. last_id now falls back to the
last row the page read.

Also drops the OpenAIFilesPurpose import that the widened purpose
annotation left unused.
This commit is contained in:
mateo-berri 2026-08-21 17:46:35 -07:00
parent 138b0da21f
commit a15b81d3d7
3 changed files with 56 additions and 7 deletions

View file

@ -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:
"""

View file

@ -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,
}

View file

@ -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(