diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 2aaa987ba6a..25ebbbd4d55 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -60417,7 +60417,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": [ { @@ -60568,7 +60568,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 e67c1105d86..2ac22a6a27b 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1757,10 +1757,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/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 = () => { diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index 874cdb52fad..d944f312b5c 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -1020,6 +1020,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