diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.integration.test.tsx similarity index 92% rename from litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx rename to litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.integration.test.tsx index f3d9b67eb4b..3cc8fa2a2be 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.integration.test.tsx @@ -63,6 +63,12 @@ const createMockAccessGroup = (overrides: Partial = {}): Ac access_mcp_servers: [{ id: "mcp-1", name: "GitHub MCP" }], access_agents: [{ id: "agent-1", name: "Support Agent" }], access_skill_names: ["private-plugin"], + access_vector_store_ids: ["store-a", "store-b", "store-c"], + access_vector_stores: [ + { id: "store-a", name: "Parks knowledge base" }, + { id: "store-b", name: null }, + { id: "store-c", name: "HR policies" }, + ], assigned_teams: [{ id: "team-1", name: "Platform Team" }], assigned_keys: [ { id: "key-1", name: "ci-key" }, @@ -480,6 +486,41 @@ describe("AccessGroupDetail", () => { }); }); + describe("Vector Stores tab", () => { + it("should count the group's vector stores and show each by name, or by id when it has none", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const tab = screen.getByRole("tab", { name: /Vector Stores/i }); + expect(tab).toHaveTextContent("3"); + await user.click(tab); + + expect(screen.getByText("Parks knowledge base")).toBeInTheDocument(); + expect(screen.getByText("HR policies")).toBeInTheDocument(); + expect(screen.getByText("store-b")).toBeInTheDocument(); + expect(screen.queryByText("store-a")).not.toBeInTheDocument(); + }); + + it("should fall back to the ids when the proxy sends no names", async () => { + const user = userEvent.setup(); + renderWith({ access_vector_stores: undefined }); + + await user.click(screen.getByRole("tab", { name: /Vector Stores/i })); + + expect(screen.getByText("store-a")).toBeInTheDocument(); + expect(screen.getByText("store-c")).toBeInTheDocument(); + }); + + it("should show empty state when none assigned", async () => { + const user = userEvent.setup(); + renderWith({ access_vector_store_ids: [], access_vector_stores: [] }); + + await user.click(screen.getByRole("tab", { name: /Vector Stores/i })); + + expect(screen.getByText("No vector stores assigned to this group")).toBeInTheDocument(); + }); + }); + it("should show empty state in Models tab when no models assigned", () => { renderWith({ access_model_names: [] }); diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx index cd8a0863e92..60324ea3f8c 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx @@ -3,6 +3,7 @@ import type { TFunction } from "i18next"; import { ArrowLeftIcon, BotIcon, + DatabaseIcon, EditIcon, KeyIcon, LayersIcon, @@ -146,6 +147,8 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr const mcpServers = accessGroup.access_mcp_servers; const agents = accessGroup.access_agents; const skills = (accessGroup.access_skill_names ?? []).map((name) => ({ id: name, name: null })); + const vectorStores = + accessGroup.access_vector_stores ?? (accessGroup.access_vector_store_ids ?? []).map((id) => ({ id, name: null })); const keys = accessGroup.assigned_keys; const teams = accessGroup.assigned_teams; @@ -273,7 +276,10 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr - + {t("accessGroups.detail.tabs.models")} @@ -294,6 +300,11 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr {t("accessGroups.detail.tabs.skills")} {skills.length} + + + {t("accessGroups.detail.tabs.vectorStores")} + {vectorStores.length} + @@ -308,6 +319,9 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr + + + diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx index 35089234d06..7ed9aa87bc6 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { BotIcon, InfoIcon, LayersIcon, ServerIcon, SparklesIcon } from "lucide-react"; +import { BotIcon, DatabaseIcon, InfoIcon, LayersIcon, ServerIcon, SparklesIcon } from "lucide-react"; import type { UseFormReturn } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { z } from "zod"; @@ -17,6 +17,7 @@ import SkillSelector from "@/components/skills/SkillSelector"; import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; +import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector"; import i18n from "@/i18n/i18n"; export const accessGroupFormSchema = z.object({ @@ -27,6 +28,7 @@ export const accessGroupFormSchema = z.object({ mcpToolPermissions: z.record(z.string(), z.array(z.string())), agentIds: z.array(z.string()), skillNames: z.array(z.string()), + vectorStoreIds: z.array(z.string()), }); export type AccessGroupFormValues = z.output; @@ -36,6 +38,7 @@ export const MODELS_TAB = "models"; export const MCP_SERVERS_TAB = "mcp-servers"; export const AGENTS_TAB = "agents"; export const SKILLS_TAB = "skills"; +export const VECTOR_STORES_TAB = "vector-stores"; interface AccessGroupBaseFormProps { form: UseFormReturn; @@ -98,7 +101,7 @@ export function AccessGroupBaseForm({ return ( - + {t("accessGroups.form.tabs.general")} @@ -119,6 +122,10 @@ export function AccessGroupBaseForm({ {t("accessGroups.form.tabs.skills")} + + + {t("accessGroups.form.tabs.vectorStores")} + @@ -198,6 +205,26 @@ export function AccessGroupBaseForm({ )} + + + + {({ id, value, onChange, "aria-describedby": describedBy }) => ( + + )} + + ); } diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.integration.test.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.integration.test.tsx index ca1e09d1d46..c4f8e4236ff 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.integration.test.tsx +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.integration.test.tsx @@ -45,6 +45,10 @@ vi.mock("@/components/skills/SkillSelector", () => ({ ), })); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ accessToken: "sk-test" }), +})); + vi.mock("@/lib/toast", () => ({ toast: { success: vi.fn(), fromError: vi.fn(), error: vi.fn() }, })); @@ -61,6 +65,8 @@ const accessGroup: AccessGroupResponse = { mcp_tool_permissions: { "srv-1": ["read_file"] }, access_agent_ids: ["agent-1"], access_skill_names: ["old-plugin"], + access_vector_store_ids: ["store-old"], + access_vector_stores: [{ id: "store-old", name: "Old store" }], assigned_team_ids: [], assigned_key_ids: [], access_mcp_servers: [{ id: "srv-1", name: "Server One" }], @@ -80,10 +86,23 @@ const save = async (user: User) => user.click(screen.getByRole("button", { name: const variables = () => mutate.mock.calls.at(-1)?.[0] as { accessGroupId: string; params: Record }; +const pickVectorStore = async (user: User, label: string) => { + await screen.findByLabelText("Old store (store-old)"); + await user.click(screen.getByLabelText("Allowed Vector Stores")); + await user.click(await screen.findByRole("option", { name: (optionName) => optionName.includes(label) })); + await user.keyboard("{Escape}"); +}; + describe("AccessGroupEditModal submit payload", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]); + vi.mocked(networking.vectorStoreListCall).mockResolvedValue({ + data: [ + { vector_store_id: "store-old", vector_store_name: "Old store" }, + { vector_store_id: "store-new", vector_store_name: "New store" }, + ], + }); vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]); vi.mocked(networking.fetchMCPServers).mockResolvedValue([ { server_id: "srv-1", server_name: "Files", alias: "Files" }, @@ -115,6 +134,7 @@ describe("AccessGroupEditModal submit payload", () => { access_mcp_server_ids: undefined, access_agent_ids: undefined, access_skill_names: undefined, + access_vector_store_ids: undefined, }); }); @@ -145,6 +165,72 @@ describe("AccessGroupEditModal submit payload", () => { expect(variables().params.access_skill_names).toStrictEqual([]); }); + it("sends the vector stores picked on the Vector Stores tab", async () => { + const user = setup(); + renderModal(); + await screen.findByDisplayValue("Engineering"); + + await user.click(screen.getByRole("tab", { name: /Vector Stores/ })); + await pickVectorStore(user, "New store (store-new)"); + await save(user); + + await waitFor(() => expect(mutate).toHaveBeenCalled()); + expect(variables().params.access_vector_store_ids).toStrictEqual(["store-old", "store-new"]); + expect(variables().params.access_skill_names).toBeUndefined(); + }); + + it("ties the vector store picker to its label and to the text that explains the limit", async () => { + const user = setup(); + renderModal(); + await screen.findByDisplayValue("Engineering"); + + await user.click(screen.getByRole("tab", { name: /Vector Stores/ })); + + expect(screen.getByLabelText("Allowed Vector Stores")).toHaveAccessibleDescription( + /adding one here limits a team or key that has no limit today/, + ); + }); + + it("sends the saved vector stores unchanged when the tab is opened and nothing is picked", async () => { + const user = setup(); + renderModal(); + await screen.findByDisplayValue("Engineering"); + + await user.click(screen.getByRole("tab", { name: /Vector Stores/ })); + await screen.findByLabelText("Old store (store-old)"); + await save(user); + + await waitFor(() => expect(mutate).toHaveBeenCalled()); + expect(variables().params.access_vector_store_ids).toStrictEqual(["store-old"]); + }); + + it("keeps a saved vector store that the store list no longer returns", async () => { + const user = setup(); + renderModal({ ...accessGroup, access_vector_store_ids: ["retired-store", "store-old"] }); + await screen.findByDisplayValue("Engineering"); + + await user.click(screen.getByRole("tab", { name: /Vector Stores/ })); + expect(await screen.findByLabelText("retired-store")).toHaveAttribute("data-slot", "combobox-chip"); + await pickVectorStore(user, "New store (store-new)"); + await save(user); + + await waitFor(() => expect(mutate).toHaveBeenCalled()); + expect(variables().params.access_vector_store_ids).toStrictEqual(["retired-store", "store-old", "store-new"]); + }); + + it("sends an empty list when every vector store is removed on the Vector Stores tab", async () => { + const user = setup(); + renderModal(); + await screen.findByDisplayValue("Engineering"); + + await user.click(screen.getByRole("tab", { name: /Vector Stores/ })); + await user.click(within(await screen.findByLabelText("Old store (store-old)")).getByRole("button")); + await save(user); + + await waitFor(() => expect(mutate).toHaveBeenCalled()); + expect(variables().params.access_vector_store_ids).toStrictEqual([]); + }); + it("sends a tab's field only once that tab has been visited", async () => { const user = setup(); renderModal(); @@ -163,6 +249,7 @@ describe("AccessGroupEditModal submit payload", () => { access_mcp_server_ids: ["srv-1"], access_agent_ids: ["agent-1"], access_skill_names: undefined, + access_vector_store_ids: undefined, }); }); diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.tsx index 4744c909b4b..8e80c86fd67 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.tsx +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.tsx @@ -18,6 +18,7 @@ import { GENERAL_TAB, MCP_SERVERS_TAB, MODELS_TAB, + VECTOR_STORES_TAB, type AccessGroupFormValues, } from "./AccessGroupBaseForm"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; @@ -37,6 +38,7 @@ const toFormValues = (accessGroup: AccessGroupResponse): AccessGroupFormValues = mcpToolPermissions: accessGroup.mcp_tool_permissions ?? {}, agentIds: accessGroup.access_agent_ids ?? [], skillNames: accessGroup.access_skill_names ?? [], + vectorStoreIds: accessGroup.access_vector_store_ids ?? [], }); function AccessGroupEditForm({ accessGroup, onCancel, onSuccess }: Omit) { @@ -62,6 +64,7 @@ function AccessGroupEditForm({ accessGroup, onCancel, onSuccess }: Omit { mockUseAuthorized.mockReturnValue({ userRole: "Admin", accessToken: "sk-test" }); }); + it("shows how many vector stores each group grants", () => { + renderWithProviders(); + + expect(screen.getByTitle("2 Vector Stores")).toHaveTextContent("2"); + expect(screen.getByTitle("0 Vector Stores")).toHaveTextContent("0"); + }); + + it("counts no vector stores for a group from an older proxy that sends no list", () => { + const { access_vector_store_ids: _unsent, ...olderGroup } = mockAccessGroups[0]; + mockUseAccessGroups.mockReturnValue({ data: [olderGroup as AccessGroupResponse], isLoading: false }); + + renderWithProviders(); + + expect(screen.getByTitle("0 Vector Stores")).toHaveTextContent("0"); + }); + it("renders the page title and subtitle", () => { renderWithProviders(); expect(screen.getByRole("heading", { name: "Access Groups" })).toBeInTheDocument(); diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx index 9e83860cf54..f4ac512f524 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx @@ -24,6 +24,7 @@ function mapResponseToAccessGroup(r: AccessGroupResponse): AccessGroup { mcpServerIds: r.access_mcp_server_ids, agentIds: r.access_agent_ids, skillNames: r.access_skill_names ?? [], + vectorStoreIds: r.access_vector_store_ids ?? [], keyIds: r.assigned_key_ids, teamIds: r.assigned_team_ids, createdAt: r.created_at, diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsTableColumns.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsTableColumns.tsx index a151ff4aa6b..a9845fcf7c6 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsTableColumns.tsx +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsTableColumns.tsx @@ -2,7 +2,7 @@ import { ColumnDef } from "@tanstack/react-table"; import type { TFunction } from "i18next"; -import { Bot, Layers, MoreHorizontal, Server, Sparkles, Trash2 } from "lucide-react"; +import { Bot, Database, Layers, MoreHorizontal, Server, Sparkles, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { DataTableSortHeader } from "@/components/shared/DataTable"; @@ -23,7 +23,7 @@ interface ResourceTone { className: string; } -const RESOURCE_TONES: Record<"models" | "mcpServers" | "agents" | "skills", ResourceTone> = { +const RESOURCE_TONES: Record<"models" | "mcpServers" | "agents" | "skills" | "vectorStores", ResourceTone> = { models: { icon: Layers, className: "bg-info/10 text-info ring-blue-600/20" }, mcpServers: { icon: Server, className: "bg-info/10 text-info ring-cyan-600/20" }, agents: { @@ -36,6 +36,11 @@ const RESOURCE_TONES: Record<"models" | "mcpServers" | "agents" | "skills", Reso className: "bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-300 dark:ring-amber-400/30", }, + vectorStores: { + icon: Database, + className: + "bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-950 dark:text-emerald-300 dark:ring-emerald-400/30", + }, }; function ResourcesCell({ group }: { group: AccessGroup }) { @@ -45,6 +50,11 @@ function ResourcesCell({ group }: { group: AccessGroup }) { { key: "mcpServers" as const, label: "accessGroups.table.resources.mcpServers", count: group.mcpServerIds.length }, { key: "agents" as const, label: "accessGroups.table.resources.agents", count: group.agentIds.length }, { key: "skills" as const, label: "accessGroups.table.resources.skills", count: group.skillNames.length }, + { + key: "vectorStores" as const, + label: "accessGroups.table.resources.vectorStores", + count: group.vectorStoreIds.length, + }, ] as const; return ( diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.test.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.integration.test.tsx similarity index 86% rename from litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.test.tsx rename to litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.integration.test.tsx index 97afcca51c3..51aca7de9b2 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.test.tsx +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.integration.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import React from "react"; import { describe, expect, it, vi, type Mock } from "vitest"; @@ -11,6 +11,21 @@ vi.mock("@/components/ModelSelect/ModelSelect", () => ({ ), })); +vi.mock("@/components/vector_store_management/VectorStoreSelector", () => ({ + default: ({ + id, + onChange, + "aria-describedby": describedBy, + }: { + id?: string; + onChange: (values: string[]) => void; + "aria-describedby"?: string; + }) => ( + + ), +})); vi.mock("@/app/(dashboard)/hooks/agents/useAgents", () => ({ useAgents: () => ({ data: { agents: [{ agent_id: "agent-1", agent_name: "Support Agent" }] } }), })); @@ -123,6 +138,24 @@ describe("AccessGroupCreateDialog", () => { }); }); + it("sends the vector stores picked on the Vector Stores tab", async () => { + const user = userEvent.setup(); + const { createAccessGroup } = renderDialog(); + + fireEvent.change(screen.getByLabelText("Group Name"), { target: { value: "kb-readers" } }); + await user.click(screen.getByRole("tab", { name: "Vector Stores" })); + const picker = screen.getByLabelText("Allowed Vector Stores"); + expect(picker).toHaveAccessibleDescription(/adding one here limits a team or key that has no limit today/); + await user.click(picker); + await user.click(screen.getByRole("button", { name: "Create Group" })); + + await waitFor(() => expect(createAccessGroup).toHaveBeenCalledTimes(1)); + expect(createAccessGroup.mock.calls[0][0]).toStrictEqual({ + access_group_name: "kb-readers", + access_vector_store_ids: ["store-a"], + }); + }); + it("keeps the dialog open with the entered values when the create fails", async () => { const user = userEvent.setup(); const { createAccessGroup } = renderDialog({ diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.tsx index 041f33e5c0b..9d60d0a2e5d 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.tsx +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.tsx @@ -1,7 +1,7 @@ "use client"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { BotIcon, InfoIcon, LayersIcon, ServerIcon, SparklesIcon } from "lucide-react"; +import { BotIcon, DatabaseIcon, InfoIcon, LayersIcon, ServerIcon, SparklesIcon } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; @@ -20,6 +20,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from " import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; +import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector"; import { useZodForm } from "@/lib/forms/useZodForm"; import { fetchClient } from "@/lib/http/api"; @@ -100,14 +101,14 @@ export const AccessGroupCreateDialog = ({ return ( - + {t("accessGroups.create.title")}
- + {t("accessGroups.form.tabs.general")} @@ -128,6 +129,10 @@ export const AccessGroupCreateDialog = ({ {t("accessGroups.form.tabs.skills")} + + + {t("accessGroups.form.tabs.vectorStores")} + @@ -206,6 +211,26 @@ export const AccessGroupCreateDialog = ({ )} + + + + {({ id, value, onChange, "aria-describedby": describedBy }) => ( + + )} + + diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/mapper.test.ts b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/mapper.test.ts index 2dd2c7e3de8..85d70e90bbf 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/mapper.test.ts +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/mapper.test.ts @@ -25,6 +25,7 @@ describe("buildAccessGroupCreateBody", () => { mcpToolPermissions: { "srv-1": ["read_file"], "srv-2": [] }, agentIds: ["agent-1"], skillNames: ["private-plugin"], + vectorStoreIds: ["store-a"], }), ).toStrictEqual({ access_group_name: "g", @@ -34,6 +35,7 @@ describe("buildAccessGroupCreateBody", () => { mcp_tool_permissions: { "srv-1": ["read_file"], "srv-2": [] }, access_agent_ids: ["agent-1"], access_skill_names: ["private-plugin"], + access_vector_store_ids: ["store-a"], }); }); diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/mapper.ts b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/mapper.ts index 14810bf55d5..f38866084c1 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/mapper.ts +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/mapper.ts @@ -12,6 +12,7 @@ export const emptyAccessGroupFormValues: AccessGroupCreateFormValues = { mcpToolPermissions: {}, agentIds: [], skillNames: [], + vectorStoreIds: [], }; export const buildAccessGroupCreateBody = (values: AccessGroupCreateFormValues): AccessGroupCreateBody => ({ @@ -23,4 +24,5 @@ export const buildAccessGroupCreateBody = (values: AccessGroupCreateFormValues): Object.keys(values.mcpToolPermissions).length > 0 && { mcp_tool_permissions: values.mcpToolPermissions }), ...(values.agentIds.length > 0 && { access_agent_ids: values.agentIds }), ...(values.skillNames.length > 0 && { access_skill_names: values.skillNames }), + ...(values.vectorStoreIds.length > 0 && { access_vector_store_ids: values.vectorStoreIds }), }); diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/schema.ts b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/schema.ts index da6f0985bcb..fef2ba0426d 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/schema.ts +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/schema.ts @@ -10,6 +10,7 @@ export const accessGroupCreateSchema = z.object({ mcpToolPermissions: z.record(z.string(), z.array(z.string())), agentIds: z.array(z.string()), skillNames: z.array(z.string()), + vectorStoreIds: z.array(z.string()), }); export type AccessGroupCreateFormValues = z.output; diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/types.ts b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/types.ts index 56b4e3863e8..e76e8db4c4b 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/types.ts +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/types.ts @@ -6,6 +6,7 @@ export interface AccessGroup { mcpServerIds: string[]; agentIds: string[]; skillNames: string[]; + vectorStoreIds: string[]; keyIds: string[]; teamIds: string[]; createdAt: string; diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts b/litellm/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts index 8a5a4676611..28710fa903e 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts @@ -45,6 +45,8 @@ const mockAccessGroups: AccessGroupResponse[] = [ access_mcp_server_ids: [], access_agent_ids: [], access_skill_names: [], + access_vector_store_ids: [], + access_vector_stores: [], assigned_team_ids: [], assigned_key_ids: [], access_mcp_servers: [], diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts b/litellm/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts index 017ad123a0e..fe06a9a0b8a 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts @@ -12,6 +12,7 @@ export interface AccessGroupUpdateParams { access_mcp_server_ids?: string[]; access_agent_ids?: string[]; access_skill_names?: string[]; + access_vector_store_ids?: string[]; assigned_team_ids?: string[]; assigned_key_ids?: string[]; } diff --git a/litellm/ui/litellm-dashboard/src/components/object_permissions_view.test.tsx b/litellm/ui/litellm-dashboard/src/components/object_permissions_view.test.tsx index 85f3ac79c8f..76b1b3d24f9 100644 --- a/litellm/ui/litellm-dashboard/src/components/object_permissions_view.test.tsx +++ b/litellm/ui/litellm-dashboard/src/components/object_permissions_view.test.tsx @@ -2,12 +2,33 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import ObjectPermissionsView from "./object_permissions_view"; -vi.mock("./permissions/VectorStorePermissions", () => ({ default: () =>
})); +vi.mock("./permissions/VectorStorePermissions", () => ({ + default: ({ inheritedVectorStores }: { inheritedVectorStores?: { id: string }[] }) => ( +
{(inheritedVectorStores ?? []).map((grant) => grant.id).join(",")}
+ ), +})); vi.mock("./permissions/MCPServerPermissions", () => ({ default: () =>
})); vi.mock("./permissions/AgentPermissions", () => ({ default: () =>
})); const objectPermission = { object_permission_id: "perm-1", skills: [] as string[] }; +describe("ObjectPermissionsView vector stores card", () => { + it("hands the card the stores that come from access groups", () => { + render( + , + ); + + expect(screen.getByTestId("vector-stores")).toHaveTextContent("store-a,store-b"); + }); +}); + describe("ObjectPermissionsView skills card", () => { it("shows plugin names granted only through access groups with their source", () => { render( diff --git a/litellm/ui/litellm-dashboard/src/components/object_permissions_view.tsx b/litellm/ui/litellm-dashboard/src/components/object_permissions_view.tsx index 65aabbd9c49..453142c8ce1 100644 --- a/litellm/ui/litellm-dashboard/src/components/object_permissions_view.tsx +++ b/litellm/ui/litellm-dashboard/src/components/object_permissions_view.tsx @@ -11,6 +11,7 @@ interface ObjectPermissionsViewProps { inheritedMcpServers?: InheritedGrant[]; inheritedAgents?: InheritedGrant[]; inheritedSkills?: InheritedGrant[]; + inheritedVectorStores?: InheritedGrant[]; unresolvedAccessGroupIds?: string[] | null; variant?: "card" | "inline"; className?: string; @@ -22,6 +23,7 @@ export function ObjectPermissionsView({ inheritedMcpServers = [], inheritedAgents = [], inheritedSkills, + inheritedVectorStores, unresolvedAccessGroupIds, variant = "card", className = "", @@ -78,7 +80,11 @@ export function ObjectPermissionsView({ const content = (
- + { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(networking.vectorStoreListCall).mockResolvedValue({ + data: [ + { vector_store_id: "store-a", vector_store_name: "Parks knowledge base" }, + { vector_store_id: "store-b", vector_store_name: "HR policies" }, + ], + }); + }); + + it("lists stores that come only from access groups, counts them, and names the groups", async () => { + render( + , + ); + + const chip = await screen.findByTitle("Granted via access groups kb-readers, support. Full ID: store-a"); + expect(chip).toHaveTextContent("Parks knowledge base (store-a) (access group)"); + expect(screen.getByText("1")).toBeInTheDocument(); + expect(screen.queryByText("No vector stores configured")).not.toBeInTheDocument(); + expect(networking.vectorStoreListCall).toHaveBeenCalledWith(accessToken); + }); + + it("lists a store once, as the team's own, when a group grants it too", async () => { + render( + , + ); + + expect(await screen.findByText("Parks knowledge base (store-a)")).toBeInTheDocument(); + expect(screen.getByTitle("Granted via access group kb-readers. Full ID: store-b")).toHaveTextContent( + "HR policies (store-b) (access group)", + ); + expect(screen.queryByTitle(/Full ID: store-a/)).not.toBeInTheDocument(); + expect(screen.getByText("2")).toBeInTheDocument(); + }); + + it("keeps the empty state and skips the store list when nothing is granted", () => { + render(); + + expect(screen.getByText("No vector stores configured")).toBeInTheDocument(); + expect(screen.getByText("0")).toBeInTheDocument(); + expect(networking.vectorStoreListCall).not.toHaveBeenCalled(); + }); +}); diff --git a/litellm/ui/litellm-dashboard/src/components/permissions/VectorStorePermissions.tsx b/litellm/ui/litellm-dashboard/src/components/permissions/VectorStorePermissions.tsx index dfb579e4ccb..0dc86e5e001 100644 --- a/litellm/ui/litellm-dashboard/src/components/permissions/VectorStorePermissions.tsx +++ b/litellm/ui/litellm-dashboard/src/components/permissions/VectorStorePermissions.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { DatabaseIcon } from "@heroicons/react/outline"; import { Badge } from "@/components/ui/badge"; import { vectorStoreListCall } from "../networking"; +import { InheritedGrant, inheritedGrantTooltip } from "./inheritedGrants"; interface VectorStoreDetails { vector_store_id: string; @@ -11,17 +12,27 @@ interface VectorStoreDetails { interface VectorStorePermissionsProps { vectorStores: string[]; + inheritedVectorStores?: InheritedGrant[]; accessToken?: string | null; } -export function VectorStorePermissions({ vectorStores, accessToken }: VectorStorePermissionsProps) { +const STORE_CHIP = + "inline-flex min-w-0 items-center px-3 py-1.5 rounded-lg bg-info/10 border border-info/20 text-info text-sm font-medium break-words"; + +export function VectorStorePermissions({ + vectorStores, + inheritedVectorStores = [], + accessToken, +}: VectorStorePermissionsProps) { const { t } = useTranslation(); const [vectorStoreDetails, setVectorStoreDetails] = useState([]); + const inheritedOnlyStores = inheritedVectorStores.filter((grant) => !vectorStores.includes(grant.id)); + const storeCount = vectorStores.length + inheritedOnlyStores.length; // Fetch vector store details when component mounts useEffect(() => { const fetchVectorStores = async () => { - if (!accessToken || vectorStores.length === 0) return; + if (!accessToken || storeCount === 0) return; try { const response = await vectorStoreListCall(accessToken); @@ -39,7 +50,7 @@ export function VectorStorePermissions({ vectorStores, accessToken }: VectorStor }; fetchVectorStores(); - }, [accessToken, vectorStores.length]); + }, [accessToken, storeCount]); // Function to get display name for vector store const getVectorStoreDisplayName = (storeId: string) => { @@ -55,19 +66,22 @@ export function VectorStorePermissions({ vectorStores, accessToken }: VectorStor

{t("permissions.vectorStorePermissions.title")}

- {vectorStores.length} + {storeCount}
- {vectorStores.length > 0 ? ( + {storeCount > 0 ? (
{vectorStores.map((store, index) => ( -
+
{getVectorStoreDisplayName(store)}
))} + {inheritedOnlyStores.map((grant) => ( +
+ {getVectorStoreDisplayName(grant.id)} + {t("permissions.vectorStorePermissions.accessGroupSuffix")} +
+ ))}
) : (
diff --git a/litellm/ui/litellm-dashboard/src/components/shared/MultiSelect.test.tsx b/litellm/ui/litellm-dashboard/src/components/shared/MultiSelect.test.tsx index cc4084a952e..69509ca38c4 100644 --- a/litellm/ui/litellm-dashboard/src/components/shared/MultiSelect.test.tsx +++ b/litellm/ui/litellm-dashboard/src/components/shared/MultiSelect.test.tsx @@ -69,6 +69,17 @@ describe("MultiSelect", () => { expect(screen.getByLabelText("beta-kb (vs-beta)")).toBeInTheDocument(); }); + it("points the input at the text that describes it", () => { + render( + <> + +

Limits the team to these stores.

+ , + ); + + expect(screen.getByRole("combobox")).toHaveAccessibleDescription("Limits the team to these stores."); + }); + it("labels an unknown selected value with its raw id", () => { renderMultiSelect({ value: ["vs-deleted"] }); diff --git a/litellm/ui/litellm-dashboard/src/components/shared/MultiSelect.tsx b/litellm/ui/litellm-dashboard/src/components/shared/MultiSelect.tsx index 9d31e9c8db9..25c6b272f4c 100644 --- a/litellm/ui/litellm-dashboard/src/components/shared/MultiSelect.tsx +++ b/litellm/ui/litellm-dashboard/src/components/shared/MultiSelect.tsx @@ -26,6 +26,7 @@ export interface MultiSelectOption { interface MultiSelectProps { id?: string; + "aria-describedby"?: string; options: MultiSelectOption[]; value?: string[]; onValueChange: (value: string[]) => void; @@ -56,6 +57,7 @@ const matchesQuery = (option: MultiSelectOption, query: string): boolean => { export function MultiSelect({ id, + "aria-describedby": describedBy, options, value = [], onValueChange, @@ -124,6 +126,7 @@ export function MultiSelect({ ))} { expect(screen.queryByRole("option", { name: "gpt-4o" })).not.toBeInTheDocument(); }); }); + +describe("TeamInfoView - vector stores from access groups", () => { + beforeEach(() => { + seedDefaultMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.mocked(networking.vectorStoreListCall).mockResolvedValue({ data: [] }); + }); + + it("lists the stores a team gets through its access groups and names the group", async () => { + const user = userEvent.setup({ delay: null }); + vi.mocked(networking.vectorStoreListCall).mockResolvedValue({ + data: [{ vector_store_id: "store-a", vector_store_name: "Parks knowledge base" }], + }); + vi.mocked(networking.teamInfoCall).mockResolvedValue( + createMockTeamData({ + access_group_ids: ["ag-1"], + access_group_vector_store_ids: ["store-a"], + access_group_details: [ + { access_group_id: "ag-1", access_group_name: "kb-readers", models: [], vector_store_ids: ["store-a"] }, + ], + }) as TeamData, + ); + + renderWithProviders( + , + ); + + expect(await screen.findByTitle("Granted via access group kb-readers. Full ID: store-a")).toHaveTextContent( + "Parks knowledge base (store-a) (access group)", + ); + expect(screen.queryByText("No vector stores configured")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("tab", { name: "Settings" })); + + await waitFor(() => + expect(screen.getAllByTitle("Granted via access group kb-readers. Full ID: store-a")).toHaveLength(2), + ); + }); +}); diff --git a/litellm/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/litellm/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index ad4c88dd75f..c1598cd5ddf 100644 --- a/litellm/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/litellm/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -340,6 +340,7 @@ export interface TeamData { access_group_mcp_server_ids?: string[]; access_group_agent_ids?: string[]; access_group_skill_names?: string[]; + access_group_vector_store_ids?: string[]; unresolved_access_group_ids?: string[] | null; access_group_details?: TeamAccessGroupModelGrant[]; router_settings?: Record; @@ -1370,6 +1371,11 @@ const TeamInfoView: React.FC = ({ info.access_group_details, (grant) => grant.skill_names, ); + const inheritedVectorStores = computeInheritedGrants( + info.access_group_vector_store_ids, + info.access_group_details, + (grant) => grant.vector_store_ids, + ); const initialKillSwitchOn = info.metadata?.disable_global_guardrails === true; @@ -1538,6 +1544,7 @@ const TeamInfoView: React.FC = ({ inheritedMcpServers={inheritedMcpServers} inheritedAgents={inheritedAgents} inheritedSkills={inheritedSkills} + inheritedVectorStores={inheritedVectorStores} unresolvedAccessGroupIds={info.unresolved_access_group_ids} variant="card" accessToken={accessToken} @@ -2643,6 +2650,7 @@ const TeamInfoView: React.FC = ({ inheritedMcpServers={inheritedMcpServers} inheritedAgents={inheritedAgents} inheritedSkills={inheritedSkills} + inheritedVectorStores={inheritedVectorStores} unresolvedAccessGroupIds={info.unresolved_access_group_ids} variant="inline" className="pt-4 border-t border-border" diff --git a/litellm/ui/litellm-dashboard/src/components/team/teamModelAccess.ts b/litellm/ui/litellm-dashboard/src/components/team/teamModelAccess.ts index 21382080a89..75d22f57f20 100644 --- a/litellm/ui/litellm-dashboard/src/components/team/teamModelAccess.ts +++ b/litellm/ui/litellm-dashboard/src/components/team/teamModelAccess.ts @@ -14,6 +14,7 @@ export interface TeamAccessGroupModelGrant { mcp_server_ids?: string[]; agent_ids?: string[]; skill_names?: string[]; + vector_store_ids?: string[]; } export type TeamModelBadgeKind = "all-proxy" | "no-default" | "direct" | "access-group"; diff --git a/litellm/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreSelector.tsx b/litellm/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreSelector.tsx index a420dcc4893..6d14c9ee530 100644 --- a/litellm/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreSelector.tsx +++ b/litellm/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreSelector.tsx @@ -4,6 +4,8 @@ import { vectorStoreListCall } from "../networking"; import { MultiSelect } from "@/components/shared/MultiSelect"; import i18n from "@/i18n/i18n"; interface VectorStoreSelectorProps { + id?: string; + "aria-describedby"?: string; onChange: (selectedVectorStores: string[]) => void; value?: string[]; className?: string; @@ -13,6 +15,8 @@ interface VectorStoreSelectorProps { } const VectorStoreSelector: React.FC = ({ + id, + "aria-describedby": describedBy, onChange, value, className, @@ -46,6 +50,8 @@ const VectorStoreSelector: React.FC = ({ return (