From 1188ea351c7701227452cb487434c8f840d88a9b Mon Sep 17 00:00:00 2001 From: Tanishq <74068132+tanishqbaweja@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:21:00 +0530 Subject: [PATCH 1/3] fix(vector-stores): return stable management pages --- litellm/proxy/_lazy_openapi_snapshot.json | 4 +- .../management_endpoints.py | 32 ++++-- .../test_vector_store_config_ownership.py | 44 +++++--- .../test_vector_store_endpoints.py | 88 ++++++++++++++- .../test_vector_store_rbac.py | 24 ++--- ui/litellm-dashboard/eslint-suppressions.json | 4 +- .../src/components/networking.test.ts | 100 ++++++++++++++++++ .../src/components/networking.tsx | 32 +++--- ui/litellm-dashboard/src/lib/http/schema.d.ts | 16 +-- 9 files changed, 275 insertions(+), 69 deletions(-) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 4e183bf9d5c..d2030add200 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -60223,7 +60223,7 @@ "paths": { "/v1/vector_store/list": { "get": { - "description": "List all available vector stores with optional filtering and pagination.\nCombines both in-memory vector stores and those stored in the database.\nDatabase is the source of truth for stores it owns: deleted stores are removed from memory, updated stores\nsync to memory. Stores declared in the config file are owned by the config file, are always listed, and are\nnever overwritten by database rows.\n\nParameters:\n- page: int - Page number for pagination (default: 1)\n- page_size: int - Number of items per page (default: 100)", + "description": "List all available vector stores with optional filtering and pagination.\nCombines both in-memory vector stores and those stored in the database.\nDatabase is the source of truth for stores it owns: deleted stores are removed from memory, updated stores\nsync to memory. Stores declared in the config file are owned by the config file, remain in the catalog, and\nare never overwritten by database rows. Accessible stores are ordered by vector_store_id before pagination.\n\nParameters:\n- page: int - Positive page number for pagination (default: 1)\n- page_size: int - Positive number of items per page (default: 100)", "operationId": "list_vector_stores_v1_vector_store_list_get", "parameters": [ { @@ -60374,7 +60374,7 @@ }, "/vector_store/list": { "get": { - "description": "List all available vector stores with optional filtering and pagination.\nCombines both in-memory vector stores and those stored in the database.\nDatabase is the source of truth for stores it owns: deleted stores are removed from memory, updated stores\nsync to memory. Stores declared in the config file are owned by the config file, are always listed, and are\nnever overwritten by database rows.\n\nParameters:\n- page: int - Page number for pagination (default: 1)\n- page_size: int - Number of items per page (default: 100)", + "description": "List all available vector stores with optional filtering and pagination.\nCombines both in-memory vector stores and those stored in the database.\nDatabase is the source of truth for stores it owns: deleted stores are removed from memory, updated stores\nsync to memory. Stores declared in the config file are owned by the config file, remain in the catalog, and\nare never overwritten by database rows. Accessible stores are ordered by vector_store_id before pagination.\n\nParameters:\n- page: int - Positive page number for pagination (default: 1)\n- page_size: int - Positive number of items per page (default: 100)", "operationId": "list_vector_stores_vector_store_list_get", "parameters": [ { diff --git a/litellm/proxy/vector_store_endpoints/management_endpoints.py b/litellm/proxy/vector_store_endpoints/management_endpoints.py index 6bfccd2426b..8e0e73956b5 100644 --- a/litellm/proxy/vector_store_endpoints/management_endpoints.py +++ b/litellm/proxy/vector_store_endpoints/management_endpoints.py @@ -365,13 +365,18 @@ async def list_vector_stores( List all available vector stores with optional filtering and pagination. Combines both in-memory vector stores and those stored in the database. Database is the source of truth for stores it owns: deleted stores are removed from memory, updated stores - sync to memory. Stores declared in the config file are owned by the config file, are always listed, and are - never overwritten by database rows. + sync to memory. Stores declared in the config file are owned by the config file, remain in the catalog, and + are never overwritten by database rows. Accessible stores are ordered by vector_store_id before pagination. Parameters: - - page: int - Page number for pagination (default: 1) - - page_size: int - Number of items per page (default: 100) + - page: int - Positive page number for pagination (default: 1) + - page_size: int - Positive number of items per page (default: 100) """ + if page < 1: + raise HTTPException( + status_code=400, + detail=f"page must be >= 1, got page={page}", + ) if page_size < 1: raise HTTPException( status_code=400, @@ -434,19 +439,24 @@ async def list_vector_stores( ) # Filter vector stores based on access control - accessible_vector_stores: Final = [] - for vs in await filter_listable_vector_stores(vector_store_map.values(), user_api_key_dict): - redacted = _with_ownership(vs) - redacted["litellm_params"] = _redact_sensitive_litellm_params(vs.get("litellm_params")) - accessible_vector_stores.append(redacted) - + accessible_vector_stores: Final = tuple( + sorted( + await filter_listable_vector_stores(vector_store_map.values(), user_api_key_dict), + key=lambda store: store.get("vector_store_id", ""), + ) + ) total_count: Final = len(accessible_vector_stores) total_pages: Final = (total_count + page_size - 1) // page_size + page_start: Final = (page - 1) * page_size # Format response using LiteLLM_ManagedVectorStoreListResponse response: Final = LiteLLM_ManagedVectorStoreListResponse( object="list", - data=accessible_vector_stores, + data=[ + _with_ownership(vs) + | LiteLLM_ManagedVectorStore(litellm_params=_redact_sensitive_litellm_params(vs.get("litellm_params"))) + for vs in accessible_vector_stores[page_start : page_start + page_size] + ], total_count=total_count, current_page=page, total_pages=total_pages, diff --git a/tests/integration/management/test_vector_store_config_ownership.py b/tests/integration/management/test_vector_store_config_ownership.py index 7dcf0a6a51b..5ffa5cc6fe4 100644 --- a/tests/integration/management/test_vector_store_config_ownership.py +++ b/tests/integration/management/test_vector_store_config_ownership.py @@ -67,22 +67,42 @@ def assert_config_write_refused(gateway: Gateway) -> None: assert "config file" in str(error["error"]), refused.text -def test_list_page_zero_returns_same_stores_as_page_one_and_page_size_zero_is_400(gateway: Gateway) -> None: - page_one: Final = gateway.request("GET", "/vector_store/list?page=1&page_size=100") +@pytest.mark.parametrize("path", ["/vector_store/list", "/v1/vector_store/list"]) +@pytest.mark.parametrize(("parameter", "value"), [("page", 0), ("page", -1), ("page_size", 0), ("page_size", -1)]) +def test_list_rejects_non_positive_pagination(gateway: Gateway, path: str, parameter: str, value: int) -> None: + page_one: Final = gateway.request("GET", f"{path}?page=1&page_size=100") assert page_one.status_code == 200, page_one.text - page_zero: Final = gateway.request("GET", "/vector_store/list?page=0&page_size=100") - assert page_zero.status_code == 200, page_zero.text - page_one_ids: Final = {str(row["vector_store_id"]) for row in listed_rows(page_one)} - page_zero_ids: Final = {str(row["vector_store_id"]) for row in listed_rows(page_zero)} - assert page_one_ids == page_zero_ids assert CONFIG_STORE_ID in page_one_ids - assert CONFIG_STORE_ID in page_zero_ids - assert object_value(page_zero.json())["current_page"] == 0, page_zero.text - zero_page_size: Final = gateway.request("GET", "/vector_store/list?page=1&page_size=0") - assert zero_page_size.status_code == 400, zero_page_size.text - assert "page_size must be >= 1" in zero_page_size.text, zero_page_size.text + invalid: Final = gateway.request("GET", f"{path}?{parameter}={value}") + assert invalid.status_code == 400, invalid.text + assert f"{parameter} must be >= 1" in invalid.text, invalid.text + + +@pytest.mark.parametrize("path", ["/vector_store/list", "/v1/vector_store/list"]) +def test_list_pages_cover_config_and_database_stores_once(gateway: Gateway, path: str) -> None: + initial: Final = gateway.request("GET", f"{path}?page_size=100") + assert initial.status_code == 200, initial.text + initial_ids: Final = tuple(str(row["vector_store_id"]) for row in listed_rows(initial)) + assert len(initial_ids) == object_value(initial.json())["total_count"], initial.text + + with gateway.scenario() as scenario: + prefix: Final = f"vs_pagination_{uuid.uuid4().hex}" + created_ids: Final = tuple(f"{prefix}_{suffix}" for suffix in ("c", "a", "b")) + for vector_store_id in created_ids: + gateway.post("/vector_store/new", {"vector_store_id": vector_store_id, "custom_llm_provider": "openai"}) + scenario.cleanups.callback(gateway.post, "/vector_store/delete", {"vector_store_id": vector_store_id}) + + expected_ids: Final = sorted((*initial_ids, *created_ids)) + total_pages: Final = (len(expected_ids) + 1) // 2 + for page in range(1, total_pages + 2): + response = gateway.request("GET", f"{path}?page={page}&page_size=2") + assert response.status_code == 200, response.text + body = object_value(response.json()) + assert [row["vector_store_id"] for row in listed_rows(response)] == expected_ids[(page - 1) * 2 : page * 2] + assert body["object"] == "list" and body["current_page"] == page, response.text + assert body["total_count"] == len(expected_ids) and body["total_pages"] == total_pages, response.text def burst_list(gateway: Gateway) -> tuple[int, str]: diff --git a/tests/unit/proxy/vector_store_endpoints/test_vector_store_endpoints.py b/tests/unit/proxy/vector_store_endpoints/test_vector_store_endpoints.py index 48642a9b50d..1fbcbd47ff7 100644 --- a/tests/unit/proxy/vector_store_endpoints/test_vector_store_endpoints.py +++ b/tests/unit/proxy/vector_store_endpoints/test_vector_store_endpoints.py @@ -1,5 +1,6 @@ import json from datetime import datetime, timezone +from typing import Final from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -14,7 +15,7 @@ from litellm.llms.base_llm.vector_store.transformation import ( LiteLLMVectorStoreEmbeddingExecutor, RouterVectorStoreEmbeddingExecutor, ) -from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy._types import CommonProxyErrors, LiteLLM_ObjectPermissionTable, LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.vector_store_endpoints.endpoints import ( _update_request_data_with_litellm_managed_vector_store_registry, index_create, @@ -23,6 +24,7 @@ from litellm.proxy.vector_store_endpoints.endpoints import ( from litellm.proxy.vector_store_endpoints.management_endpoints import ( _check_vector_store_access, create_vector_store_in_db, + list_vector_stores, new_vector_store, ) from litellm.proxy.vector_store_endpoints.utils import ( @@ -36,6 +38,7 @@ from litellm.proxy.vector_store_files_endpoints.endpoints import ( from litellm.types.utils import EmbeddingResponse, LlmProviders from litellm.types.vector_stores import IndexCreateRequest, IndexListResponse from litellm.vector_stores.main import _direct_vector_store_embedding_executor +from litellm.vector_stores.vector_store_registry import VectorStoreRegistry def _serialize_litellm_params(litellm_params): @@ -3118,7 +3121,10 @@ class TestConfigOwnedVectorStores: first = await list_vector_stores(user_api_key_dict=self._admin()) second = await list_vector_stores(user_api_key_dict=self._admin()) - assert [(vs["vector_store_id"], vs["is_config"]) for vs in first["data"]] == [(self.DB_ID, False), (self.CONFIG_ID, True)] + assert [(vs["vector_store_id"], vs["is_config"]) for vs in first["data"]] == [ + (self.CONFIG_ID, True), + (self.DB_ID, False), + ] assert second["data"] == first["data"] assert [vs["vector_store_id"] for vs in registry.vector_stores] == [self.CONFIG_ID, self.DB_ID] @@ -3413,3 +3419,81 @@ def test_proxy_vector_store_writes_send_only_the_callers_metadata_when_the_raw_b assert response.status_code == 200, response.text assert len(sent_bodies) == 1 assert sent_bodies[0].get("metadata") == CALLERS_METADATA + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("store_ids", "page", "expected_ids", "total_pages"), + [ + (("vs_c", "vs_a", "vs_b"), 1, ("vs_a", "vs_b"), 2), + (("vs_b", "vs_c", "vs_a"), 1, ("vs_a", "vs_b"), 2), + (("vs_c", "vs_a", "vs_b"), 2, ("vs_c",), 2), + (("vs_c", "vs_a", "vs_b"), 3, (), 2), + (("vs_b", "vs_a"), 1, ("vs_a", "vs_b"), 1), + (("vs_b", "vs_a"), 2, (), 1), + ((), 1, (), 0), + ], +) +async def test_list_vector_stores_returns_the_requested_page_in_id_order( + monkeypatch: pytest.MonkeyPatch, + store_ids: tuple[str, ...], + page: int, + expected_ids: tuple[str, ...], + total_pages: int, +) -> None: + from litellm.proxy import proxy_server + + stores: Final = [ + LiteLLM_ManagedVectorStore(vector_store_id=identifier, custom_llm_provider="openai", is_config=True) + for identifier in store_ids + ] + monkeypatch.setattr(proxy_server, "prisma_client", None) + monkeypatch.setattr(litellm, "vector_store_registry", VectorStoreRegistry(vector_stores=stores)) + admin: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN) + + response: Final = await list_vector_stores(user_api_key_dict=admin, page=page, page_size=2) + + assert response == { + "object": "list", + "data": [ + {"vector_store_id": identifier, "custom_llm_provider": "openai", "is_config": True, "litellm_params": None} + for identifier in expected_ids + ], + "total_count": len(store_ids), + "current_page": page, + "total_pages": total_pages, + } + assert tuple(store["vector_store_id"] for store in stores) == store_ids + + +@pytest.mark.asyncio +async def test_list_vector_stores_paginates_only_the_stores_granted_to_the_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from litellm.proxy import proxy_server + + stores: Final = [ + LiteLLM_ManagedVectorStore(vector_store_id=identifier, custom_llm_provider="openai", is_config=True) + for identifier in ("vs_c", "vs_b", "vs_a") + ] + monkeypatch.setattr(proxy_server, "prisma_client", None) + monkeypatch.setattr(proxy_server, "general_settings", {}) + monkeypatch.setattr(litellm, "vector_store_registry", VectorStoreRegistry(vector_stores=stores)) + user: Final = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + object_permission=LiteLLM_ObjectPermissionTable( + object_permission_id="op-paged-catalog", vector_stores=["vs_a", "vs_c"] + ), + ) + + response: Final = await list_vector_stores(user_api_key_dict=user, page=2, page_size=1) + + assert response == { + "object": "list", + "data": [ + {"vector_store_id": "vs_c", "custom_llm_provider": "openai", "is_config": True, "litellm_params": None} + ], + "total_count": 2, + "current_page": 2, + "total_pages": 2, + } diff --git a/tests/unit/proxy/vector_store_endpoints/test_vector_store_rbac.py b/tests/unit/proxy/vector_store_endpoints/test_vector_store_rbac.py index f5934962f75..4d5e9f25fe5 100644 --- a/tests/unit/proxy/vector_store_endpoints/test_vector_store_rbac.py +++ b/tests/unit/proxy/vector_store_endpoints/test_vector_store_rbac.py @@ -142,30 +142,26 @@ async def test_list_vector_stores_rejects_non_positive_page_size_with_400(page_s @pytest.mark.asyncio @pytest.mark.parametrize("page", [0, -1]) -async def test_list_vector_stores_accepts_non_positive_page_like_base(page): +async def test_list_vector_stores_rejects_non_positive_page_with_400( + page: int, monkeypatch: pytest.MonkeyPatch +) -> None: from litellm.proxy.vector_store_endpoints.management_endpoints import ( list_vector_stores, ) import litellm + from litellm.proxy import proxy_server admin: Final = UserAPIKeyAuth( user_role=LitellmUserRoles.PROXY_ADMIN.value, user_id="admin-1", ) - mock_prisma: Final = MagicMock() - mock_prisma.db.litellm_managedvectorstorestable.find_many = AsyncMock(return_value=[]) + monkeypatch.setattr(proxy_server, "prisma_client", None) + monkeypatch.setattr(litellm, "vector_store_registry", None) - with patch.dict("litellm.proxy.proxy_server.general_settings", _DISABLED_GS, clear=True): - with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): - with patch.object(litellm, "vector_store_registry", None): - with patch( - "litellm.proxy.vector_store_endpoints.management_endpoints.VectorStoreRegistry.get_vector_stores_from_db", - new=AsyncMock(return_value=[]), - ): - response: Final = await list_vector_stores(user_api_key_dict=admin, page=page, page_size=10) + with pytest.raises(HTTPException) as exc_info: + await list_vector_stores(user_api_key_dict=admin, page=page, page_size=10) - assert response["current_page"] == page - assert response["total_count"] == 0 - assert response["data"] == [] + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == f"page must be >= 1, got page={page}" diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 38d4c435e9a..78c9263bcc6 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1765,10 +1765,10 @@ "count": 5 }, "no-restricted-syntax": { - "count": 139 + "count": 138 }, "prefer-const": { - "count": 28 + "count": 27 } }, "src/components/object_permissions_view.tsx": { diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index e7a55870345..80173fc2382 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -1156,6 +1156,19 @@ describe("getUiConfig runtime backend discovery", () => { expect(requestUrl.pathname).toBe("/rag/ingest"); }); + it("keeps vector store inventory on the management backend", async () => { + const { networking, mockFetch } = await loadNetworkingWithGateway("https://gateway.example.com"); + const inventory = { object: "list", data: [], total_count: 0, current_page: 1, total_pages: 0 }; + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify(inventory))); + + const result = await networking.vectorStoreListCall("token"); + + expect(result.data).toEqual([]); + const requestUrl = new URL(String(mockFetch.mock.calls.at(-1)?.[0])); + expect(requestUrl.origin).toBe("https://backend.example.com"); + expect(requestUrl.pathname).toBe("/vector_store/list"); + }); + it("lists indexes from the gateway", async () => { const { networking, mockFetch } = await loadNetworkingWithGateway("https://gateway.example.com"); await networking.indexesListCall("token"); @@ -1178,3 +1191,90 @@ describe("getUiConfig runtime backend discovery", () => { }); }); }); + +describe("vectorStoreListCall complete dashboard inventory", () => { + const stores = Array.from({ length: 203 }, (_, index) => ({ + vector_store_id: `vs_${String(index).padStart(3, "0")}`, + custom_llm_provider: "openai", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + })); + + afterEach(() => { + vi.unstubAllGlobals(); + Networking.setGlobalLitellmHeaderName("Authorization"); + }); + + it("loads every page with the configured authentication header", async () => { + const requests: { path: string; page: string | null; pageSize: string | null; auth: string | null }[] = []; + const mockFetch = vi.fn().mockImplementation(async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost"); + const request = { + path: url.pathname, + page: url.searchParams.get("page"), + pageSize: url.searchParams.get("page_size"), + auth: new Headers(init?.headers).get("x-litellm-key"), + }; + requests.push(request); + const page = requests.length; + const inventory = { + object: "list", + data: stores.slice((page - 1) * 100, page * 100), + current_page: page, + total_count: stores.length, + total_pages: 3, + }; + return new Response(JSON.stringify(inventory)); + }); + vi.stubGlobal("fetch", mockFetch); + Networking.setGlobalLitellmHeaderName("x-litellm-key"); + + const response = await Networking.vectorStoreListCall("inventory-token"); + + expect(response.data).toEqual(stores); + expect(requests).toEqual([ + { path: "/vector_store/list", page: "1", pageSize: "100", auth: "Bearer inventory-token" }, + { path: "/vector_store/list", page: "2", pageSize: "100", auth: "Bearer inventory-token" }, + { path: "/vector_store/list", page: "3", pageSize: "100", auth: "Bearer inventory-token" }, + ]); + }); + + it("does not duplicate an older backend's already-complete first response", async () => { + const inventory = { object: "list", data: stores, total_count: stores.length, current_page: 1, total_pages: 3 }; + const mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify(inventory))); + vi.stubGlobal("fetch", mockFetch); + + const response = await Networking.vectorStoreListCall("inventory-token"); + + expect(response.data).toEqual(stores); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("keeps an empty inventory empty without requesting another page", async () => { + const inventory = { object: "list", data: [], total_count: 0, current_page: 1, total_pages: 0 }; + const mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify(inventory))); + vi.stubGlobal("fetch", mockFetch); + + const response = await Networking.vectorStoreListCall("inventory-token"); + + expect(response.data).toEqual([]); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("reports a later page error instead of returning a partial inventory", async () => { + const inventory = { + object: "list", + data: stores.slice(0, 100), + total_count: stores.length, + current_page: 1, + total_pages: 3, + }; + const mockFetch = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify(inventory))) + .mockResolvedValueOnce(new Response(JSON.stringify({ detail: "Catalog page unavailable" }), { status: 503 })); + vi.stubGlobal("fetch", mockFetch); + + await expect(Networking.vectorStoreListCall("inventory-token")).rejects.toThrow("Catalog page unavailable"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index fd25242ec34..3a9084f2325 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -119,6 +119,7 @@ import { MCP_TOOLS_PREVIEW_FORBIDDEN_MESSAGE } from "./mcp_tools/constants"; import type { ComplexityRouterConfigPayload } from "./add_model/build_complexity_router_config"; import type { AutoRouterPresetsResponse } from "@/lib/autorouter_presets"; import type { VectorStoreIndex } from "@/app/(dashboard)/vector-stores/_components/IndexesTab"; +import type { VectorStoreListResponse } from "./vector_store_management/types"; import type { RoutingDecision } from "./logs/detail/RoutingDecisionCard"; import type { SpanDetail, @@ -5545,28 +5546,23 @@ export const vectorStoreCreateCall = async (accessToken: string, formValues: Rec } }; -export const vectorStoreListCall = async ( - accessToken: string, - page: number = 1, - page_size: number = 100, -): Promise => { +export const vectorStoreListCall = async (accessToken: string): Promise> => { try { - let url = proxyBaseUrl ? `${proxyBaseUrl}/vector_store/list` : `/vector_store/list`; + const fetchPage = (page: number) => + apiClient.get("/vector_store/list", { + accessToken, + query: { page, page_size: 100 }, + }); + const firstPage = await fetchPage(1); + const data = [...firstPage.data]; - const response = await fetch(url, { - method: "GET", - headers: { - "Content-Type": "application/json", - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - }, - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.detail || "Failed to list vector stores"); + // Dashboard tables and selectors need the full catalog. Older gateways may already return it on page one. + for (let page = 2; page <= firstPage.total_pages && data.length < firstPage.total_count; page++) { + const response = await fetchPage(page); + data.push(...response.data); } - return await response.json(); + return { data }; } catch (error) { console.error("Error listing vector stores:", error); throw error; diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 262e8b6deae..9d3c81a9be6 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -22792,12 +22792,12 @@ export interface paths { * @description List all available vector stores with optional filtering and pagination. * Combines both in-memory vector stores and those stored in the database. * Database is the source of truth for stores it owns: deleted stores are removed from memory, updated stores - * sync to memory. Stores declared in the config file are owned by the config file, are always listed, and are - * never overwritten by database rows. + * sync to memory. Stores declared in the config file are owned by the config file, remain in the catalog, and + * are never overwritten by database rows. Accessible stores are ordered by vector_store_id before pagination. * * Parameters: - * - page: int - Page number for pagination (default: 1) - * - page_size: int - Number of items per page (default: 100) + * - page: int - Positive page number for pagination (default: 1) + * - page_size: int - Positive number of items per page (default: 100) */ get: operations["list_vector_stores_v1_vector_store_list_get"]; put?: never; @@ -24098,12 +24098,12 @@ export interface paths { * @description List all available vector stores with optional filtering and pagination. * Combines both in-memory vector stores and those stored in the database. * Database is the source of truth for stores it owns: deleted stores are removed from memory, updated stores - * sync to memory. Stores declared in the config file are owned by the config file, are always listed, and are - * never overwritten by database rows. + * sync to memory. Stores declared in the config file are owned by the config file, remain in the catalog, and + * are never overwritten by database rows. Accessible stores are ordered by vector_store_id before pagination. * * Parameters: - * - page: int - Page number for pagination (default: 1) - * - page_size: int - Number of items per page (default: 100) + * - page: int - Positive page number for pagination (default: 1) + * - page_size: int - Positive number of items per page (default: 100) */ get: operations["list_vector_stores_vector_store_list_get"]; put?: never; From 0b0a7e88338a445092aabae09b3ef2ff54d39207 Mon Sep 17 00:00:00 2001 From: Tanishq Date: Wed, 28 Oct 2026 07:39:15 +0530 Subject: [PATCH 2/3] test(ui): move vector store inventory tests next to ragIngestCall Both this branch and main (#48478) appended a describe block at the end of networking.test.ts, so the PR conflicted with main. Keeping the vector store tests next to the other RAG and vector store test avoids that --- .../src/components/networking.test.ts | 174 +++++++++--------- 1 file changed, 87 insertions(+), 87 deletions(-) diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index 80173fc2382..3a018aa4c78 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -999,6 +999,93 @@ describe("ragIngestCall", () => { }); }); +describe("vectorStoreListCall complete dashboard inventory", () => { + const stores = Array.from({ length: 203 }, (_, index) => ({ + vector_store_id: `vs_${String(index).padStart(3, "0")}`, + custom_llm_provider: "openai", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + })); + + afterEach(() => { + vi.unstubAllGlobals(); + Networking.setGlobalLitellmHeaderName("Authorization"); + }); + + it("loads every page with the configured authentication header", async () => { + const requests: { path: string; page: string | null; pageSize: string | null; auth: string | null }[] = []; + const mockFetch = vi.fn().mockImplementation(async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost"); + const request = { + path: url.pathname, + page: url.searchParams.get("page"), + pageSize: url.searchParams.get("page_size"), + auth: new Headers(init?.headers).get("x-litellm-key"), + }; + requests.push(request); + const page = requests.length; + const inventory = { + object: "list", + data: stores.slice((page - 1) * 100, page * 100), + current_page: page, + total_count: stores.length, + total_pages: 3, + }; + return new Response(JSON.stringify(inventory)); + }); + vi.stubGlobal("fetch", mockFetch); + Networking.setGlobalLitellmHeaderName("x-litellm-key"); + + const response = await Networking.vectorStoreListCall("inventory-token"); + + expect(response.data).toEqual(stores); + expect(requests).toEqual([ + { path: "/vector_store/list", page: "1", pageSize: "100", auth: "Bearer inventory-token" }, + { path: "/vector_store/list", page: "2", pageSize: "100", auth: "Bearer inventory-token" }, + { path: "/vector_store/list", page: "3", pageSize: "100", auth: "Bearer inventory-token" }, + ]); + }); + + it("does not duplicate an older backend's already-complete first response", async () => { + const inventory = { object: "list", data: stores, total_count: stores.length, current_page: 1, total_pages: 3 }; + const mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify(inventory))); + vi.stubGlobal("fetch", mockFetch); + + const response = await Networking.vectorStoreListCall("inventory-token"); + + expect(response.data).toEqual(stores); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("keeps an empty inventory empty without requesting another page", async () => { + const inventory = { object: "list", data: [], total_count: 0, current_page: 1, total_pages: 0 }; + const mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify(inventory))); + vi.stubGlobal("fetch", mockFetch); + + const response = await Networking.vectorStoreListCall("inventory-token"); + + expect(response.data).toEqual([]); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("reports a later page error instead of returning a partial inventory", async () => { + const inventory = { + object: "list", + data: stores.slice(0, 100), + total_count: stores.length, + current_page: 1, + total_pages: 3, + }; + const mockFetch = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify(inventory))) + .mockResolvedValueOnce(new Response(JSON.stringify({ detail: "Catalog page unavailable" }), { status: 503 })); + vi.stubGlobal("fetch", mockFetch); + + await expect(Networking.vectorStoreListCall("inventory-token")).rejects.toThrow("Catalog page unavailable"); + }); +}); + describe("getUiConfig runtime backend discovery", () => { const discoveryConfig = () => ({ server_root_path: "/", @@ -1191,90 +1278,3 @@ describe("getUiConfig runtime backend discovery", () => { }); }); }); - -describe("vectorStoreListCall complete dashboard inventory", () => { - const stores = Array.from({ length: 203 }, (_, index) => ({ - vector_store_id: `vs_${String(index).padStart(3, "0")}`, - custom_llm_provider: "openai", - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-01T00:00:00Z", - })); - - afterEach(() => { - vi.unstubAllGlobals(); - Networking.setGlobalLitellmHeaderName("Authorization"); - }); - - it("loads every page with the configured authentication header", async () => { - const requests: { path: string; page: string | null; pageSize: string | null; auth: string | null }[] = []; - const mockFetch = vi.fn().mockImplementation(async (input, init) => { - const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost"); - const request = { - path: url.pathname, - page: url.searchParams.get("page"), - pageSize: url.searchParams.get("page_size"), - auth: new Headers(init?.headers).get("x-litellm-key"), - }; - requests.push(request); - const page = requests.length; - const inventory = { - object: "list", - data: stores.slice((page - 1) * 100, page * 100), - current_page: page, - total_count: stores.length, - total_pages: 3, - }; - return new Response(JSON.stringify(inventory)); - }); - vi.stubGlobal("fetch", mockFetch); - Networking.setGlobalLitellmHeaderName("x-litellm-key"); - - const response = await Networking.vectorStoreListCall("inventory-token"); - - expect(response.data).toEqual(stores); - expect(requests).toEqual([ - { path: "/vector_store/list", page: "1", pageSize: "100", auth: "Bearer inventory-token" }, - { path: "/vector_store/list", page: "2", pageSize: "100", auth: "Bearer inventory-token" }, - { path: "/vector_store/list", page: "3", pageSize: "100", auth: "Bearer inventory-token" }, - ]); - }); - - it("does not duplicate an older backend's already-complete first response", async () => { - const inventory = { object: "list", data: stores, total_count: stores.length, current_page: 1, total_pages: 3 }; - const mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify(inventory))); - vi.stubGlobal("fetch", mockFetch); - - const response = await Networking.vectorStoreListCall("inventory-token"); - - expect(response.data).toEqual(stores); - expect(mockFetch).toHaveBeenCalledTimes(1); - }); - - it("keeps an empty inventory empty without requesting another page", async () => { - const inventory = { object: "list", data: [], total_count: 0, current_page: 1, total_pages: 0 }; - const mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify(inventory))); - vi.stubGlobal("fetch", mockFetch); - - const response = await Networking.vectorStoreListCall("inventory-token"); - - expect(response.data).toEqual([]); - expect(mockFetch).toHaveBeenCalledTimes(1); - }); - - it("reports a later page error instead of returning a partial inventory", async () => { - const inventory = { - object: "list", - data: stores.slice(0, 100), - total_count: stores.length, - current_page: 1, - total_pages: 3, - }; - const mockFetch = vi - .fn() - .mockResolvedValueOnce(new Response(JSON.stringify(inventory))) - .mockResolvedValueOnce(new Response(JSON.stringify({ detail: "Catalog page unavailable" }), { status: 503 })); - vi.stubGlobal("fetch", mockFetch); - - await expect(Networking.vectorStoreListCall("inventory-token")).rejects.toThrow("Catalog page unavailable"); - }); -}); From 0cbba873798d3ed953a51c68cc3f3014cb568f96 Mon Sep 17 00:00:00 2001 From: Tanishq Date: Wed, 28 Oct 2026 08:03:37 +0530 Subject: [PATCH 3/3] test(ui): keep the add model tests off the network for vector stores vectorStoreListCall now goes through apiClient, which resolves the path against the window origin, so the advanced settings and add model form tests sent real requests to http://localhost:3000/vector_store/list. On CI the connection error can get logged while the vitest worker shuts down, and ui-unit then fails with "Closing rpc while onUserConsoleLog was pending". The advanced settings tests now mock VectorStoreSelector like the other tests that render it, and the add model form test stubs vectorStoreListCall next to its other networking stubs --- .../components/add_model/AddModelForm.integration.test.tsx | 1 + .../src/components/add_model/advanced_settings.test.tsx | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx index a95d9ffbfb8..0384db4ce7c 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx @@ -35,6 +35,7 @@ vi.mock("../networking", async () => { guardrails: [{ guardrail_name: "test-guardrail-1" }, { guardrail_name: "test-guardrail-2" }], }), tagListCall: vi.fn().mockResolvedValue({}), + vectorStoreListCall: vi.fn().mockResolvedValue({ data: [] }), modelAvailableCall: vi.fn().mockResolvedValue({ data: [{ id: "model-group-1" }, { id: "model-group-2" }], }), diff --git a/ui/litellm-dashboard/src/components/add_model/advanced_settings.test.tsx b/ui/litellm-dashboard/src/components/add_model/advanced_settings.test.tsx index e3d98492cb9..1a6435e9f11 100644 --- a/ui/litellm-dashboard/src/components/add_model/advanced_settings.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/advanced_settings.test.tsx @@ -11,6 +11,11 @@ vi.mock("@/app/(dashboard)/hooks/uiSettings/usePtuCostAttributionEnabled", () => usePtuCostAttributionEnabled: () => mockUsePtuCostAttributionEnabled(), })); +// The real selector requests /vector_store/list from the proxy as soon as it mounts +vi.mock("@/components/vector_store_management/VectorStoreSelector", () => ({ + default: () =>
, +})); + const PTU_LABELS = ["PTU Count", "Calculated Cost per PTU / Hour (USD)", "PTU Effective From (UTC)"]; const KubernetesPodDiscoveryValue = () => {