This commit is contained in:
Tanishq 2026-10-29 09:21:39 +05:30 • committed by GitHub
commit a505ddb927
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 281 additions and 69 deletions

View file

@ -60492,7 +60492,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": [
{
@ -60643,7 +60643,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": [
{

View file

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

View file

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

View file

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

View file

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

View file

@ -1754,10 +1754,10 @@
"count": 5
},
"no-restricted-syntax": {
"count": 139
"count": 138
},
"prefer-const": {
"count": 28
"count": 27
}
},
"src/components/object_permissions_view.tsx": {

View file

@ -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" }],
}),

View file

@ -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: () => <div data-testid="vector-store-selector" />,
}));
const PTU_LABELS = ["PTU Count", "Calculated Cost per PTU / Hour (USD)", "PTU Effective From (UTC)"];
const KubernetesPodDiscoveryValue = () => {

View file

@ -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<typeof fetch>().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<typeof fetch>().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<typeof fetch>().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<typeof fetch>()
.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: "/",
@ -1177,6 +1264,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");

View file

@ -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,
@ -5550,28 +5551,23 @@ export const vectorStoreCreateCall = async (accessToken: string, formValues: Rec
}
};
export const vectorStoreListCall = async (
accessToken: string,
page: number = 1,
page_size: number = 100,
): Promise<any> => {
export const vectorStoreListCall = async (accessToken: string): Promise<Pick<VectorStoreListResponse, "data">> => {
try {
let url = proxyBaseUrl ? `${proxyBaseUrl}/vector_store/list` : `/vector_store/list`;
const fetchPage = (page: number) =>
apiClient.get<VectorStoreListResponse>("/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;

View file

@ -22817,12 +22817,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;
@ -24125,12 +24125,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;