Skip to content

fix(vector-stores): paginate managed resource list by unified_resource_id cursor#34596

Open
mateo-berri wants to merge 1 commit into
litellm_internal_stagingfrom
litellm_fix_vector_store_list_pagination
Open

fix(vector-stores): paginate managed resource list by unified_resource_id cursor#34596
mateo-berri wants to merge 1 commit into
litellm_internal_stagingfrom
litellm_fix_vector_store_list_pagination

Conversation

@mateo-berri

Copy link
Copy Markdown
Collaborator

TLDR

Problem this solves:

  • Managed resource listing paginated on the wrong column
  • after compared a base64 id against uuid primary keys
  • Pages looped and dropped resources
  • build_list_page raised AttributeError on dict items

How it solves it:

  • Cursor on unified_resource_id, the id clients hold
  • unified_resource_id added as a total-order tie-breaker
  • Unresolvable after returns 400, not an empty page
  • List page ids read from dicts and objects alike

Relevant issues

Same bug class as #34192, in the sibling paginator

Linear ticket

Pre-Submission checklist

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review

Screenshots / Proof of Fix

A note on how this is proven: list_user_resources has no HTTP surface today. GET /v1/vector_stores routes through ProxyBaseLLMRequestProcessing to avector_store_list on the router, so it never consults the managed vector store hook the way GET /v1/batches consults the managed files hook. There is therefore no curl that reaches this code. The runs below drive alist_vector_stores, the exact method that endpoint would call once it is wired up, against the real local Postgres through Prisma, with four managed vector stores seeded into LiteLLM_ManagedVectorStoreTable (vs_000 oldest -> vs_003 newest, random uuid primary keys as @default(uuid()) produces) and a caller scoped to user_id=lit-vs-proof-user. A client walks with limit=2, feeding each response's last_id back as after. Labels are decoded from the base64 unified_resource_id returned as each item's id.

Before, at 7b019cf152 (the base commit):

page 1: after=none -> raised AttributeError detail='dict' object has no attribute 'id'
RESULT: 0/4 unique vector stores in 0 call(s)

Every non-empty page raised, because this listing builds plain dicts and build_list_page sourced first_id / last_id through attribute access.

Before, at 7b019cf152 with only the build_list_page part of this PR applied, so the cursor behaviour is observable:

page 1: after=none    -> ['vs_003', 'vs_002']  last_id=vs_002  has_more=True
page 2: after=vs_002  -> ['vs_002']            last_id=vs_002  has_more=False
RESULT: 2/4 unique vector stores in 2 call(s), duplicates=1
  -> last_id did not advance, vs_002 came back twice, vs_001 and vs_000 were never returned

unresolvable cursor:
  returned {"object": "list", "data": [], "first_id": null, "last_id": null, "has_more": false}

After, at f913d42abb:

page 1: after=none    -> ['vs_003', 'vs_002']  last_id=vs_002  has_more=True
page 2: after=vs_002  -> ['vs_001', 'vs_000']  last_id=vs_000  has_more=False
RESULT: 4/4 unique vector stores in 2 call(s), duplicates=0

unresolvable cursor:
  raised HTTPException status=400 detail=Invalid 'after' cursor: no vector_store found with id 'does-not-exist-xyz'.

The second page is also proof that Postgres accepts the new query shape: Prisma really does take a cursor on unified_resource_id together with skip=1 and a two-key order, and returns the four rows in reverse-chronological order across exactly two round trips.

Type

🐛 Bug Fix

Changes

list_user_resources on BaseManagedResource backs the managed vector store listing (alist_vector_stores). It carried the same broken cursor that #34192 fixes for managed batches:

if after:
    where_clause["id"] = {"gt": after}          # id is a random uuid() PK
...
order={"created_at": "desc"}                     # desc order, but gt cursor

The after value a client sends back is a resource's unified_resource_id, because that is what the listing returns as each item's id and as last_id. Comparing that base64 string against unrelated random uuids either re-selects the rows the previous page returned or excludes rows that should have come next, and ordering by created_at desc while filtering with gt points the two in opposite directions. The live run above shows both symptoms at once: a last_id that never advances and two of four resources that are never returned.

The fix is the same as #34192: a Prisma cursor on the unique unified_resource_id with skip=1, plus unified_resource_id as a secondary sort key so the ordering is total. A cursor is only well-defined when the ordering fully determines row order; created_at alone is not unique, so rows sharing a timestamp can come back in an order that shifts between requests and slip through the seam between pages.

Two related truncations are fixed with it. An after that does not resolve to a resource the caller can list returned an empty page, which a client cannot distinguish from the end of the list; it now returns 400 naming the cursor. The lookup runs against the caller's own rows, so the cursor cannot be anchored to a resource the caller cannot see: a Prisma cursor resolves by unique column regardless of the where filter. And has_more came from whether the page was full rather than whether another row exists, so an exact multiple of limit cost an extra empty request and a page shortened by an unparseable row looked like the end of the list. The listing now fetches limit + 1 rows and reports has_more from the extra row.

Separately, build_list_page read first_id and last_id through attribute access only. The file and batch listings pass pydantic objects so they were fine, but this listing passes plain dicts, so every non-empty page raised AttributeError. It now reads the id from either shape.

Tests, all of which fail on the base commit and pass here: test_list_resources_pagination_uses_unified_resource_id_cursor pins the cursor, the skip, and the two-key order, and asserts no id filter leaks into the where clause; test_list_resources_pagination_walks_all_pages_without_loops_or_gaps walks every page the way a client does and asserts each resource comes back exactly once, newest first; test_list_resources_has_more_false_on_exactly_full_final_page covers the extra empty request; test_list_resources_rejects_unknown_after_cursor and test_list_resources_cursor_lookup_is_scoped_to_the_caller cover the 400 and the owner-scoped lookup; test_list_page_reads_ids_from_dict_items covers the AttributeError, alongside object-item and empty-page cases.

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

…e_id cursor

`list_user_resources`, which backs the managed vector store listing, carried
the same broken cursor as the managed batch listing: `after` was applied as
`where id > after` against the table's random-uuid primary key, while the value
a client holds is the `unified_resource_id` returned as each item's `id`. That
compares a base64 cursor to unrelated uuids, so a page could re-select the rows
it just returned or skip rows that should have come next. Ordering by
non-unique `created_at` alone left the order partial as well, so rows sharing a
timestamp could shift between requests.

The listing now uses a Prisma cursor on the unique `unified_resource_id` with
`unified_resource_id` as a secondary sort key, rejects an `after` that does not
resolve to a resource the caller can list with a 400 rather than an empty page,
and derives `has_more` from an extra fetched row instead of from whether the
page came back full.

`build_list_page` read `first_id` / `last_id` through attribute access only,
which raised AttributeError on every non-empty page of this listing because it
builds plain dicts. It now reads the id from either shape.
@greptile-apps

greptile-apps Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR corrects managed vector-store pagination and list response construction

  • Uses unified_resource_id as the scoped Prisma cursor with deterministic ordering
  • Fetches one lookahead row to calculate has_more
  • Rejects unknown or inaccessible cursors
  • Supports list items represented as either mappings or objects
  • Adds regression coverage for cursor traversal, isolation, exact page sizes, and response IDs

Confidence Score: 4/5

The PR is safe to merge with two non-blocking issues around the SDK dependency boundary and pagination metadata after parse failures

The cursor and isolation fixes are well covered, but SDK-only calls now require FastAPI and skipped malformed rows can produce a next-page signal without a usable cursor

Files Needing Attention: litellm/llms/base_llm/managed_resources/base_managed_resource.py

Important Files Changed

Filename Overview
litellm/llms/base_llm/managed_resources/base_managed_resource.py Corrects cursor pagination and lookahead handling, but introduces a proxy-only dependency and inconsistent pagination metadata when rows fail to parse
litellm/llms/base_llm/managed_resources/isolation.py Safely extracts pagination IDs from mapping-backed and attribute-backed list items
tests/test_litellm/llms/base_llm/test_base_managed_resource.py Adds focused regression tests covering cursor shape, traversal, lookahead behavior, invalid cursors, and ownership scope
tests/test_litellm/llms/base_llm/test_managed_resource_isolation.py Adds coverage for mapping-backed, object-backed, and empty list-page construction

Reviews (1): Last reviewed commit: "fix(vector-stores): paginate managed res..." | Re-trigger Greptile

Returns:
Dictionary with list of resources and pagination info
"""
from fastapi import HTTPException

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Proxy-only dependency in SDK layer

list_user_resources now imports FastAPI on every call, so SDK-only installations without the proxy extra raise ModuleNotFoundError before executing the listing. Keep proxy exception mapping outside this SDK-layer module or use an exception available in the core dependency set

Rule Used: What: Do not allow fastapi imports on files outsid... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


has_more = len(resources) > page_size

resource_objects: List[Any] = []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Raw rows misstate pagination state

has_more is calculated before resource parsing, while parse failures are silently removed from the returned page. When every sliced row is unparseable but a lookahead row exists, the response contains empty data, has_more=true, and no last_id, leaving clients without a cursor for the advertised next page. Derive pagination metadata from rows that can actually be emitted or preserve a cursor when rows are skipped

@codecov

codecov Bot commented Jul 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_fix_vector_store_list_pagination (f913d42) with litellm_internal_staging (7b019cf)

Open in CodSpeed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant