From f969eb8c4884835d6eacae55acfadd9a1990f71b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 4 May 2026 19:17:50 +0000 Subject: [PATCH] fix(ui): include unified access groups in Add Model dropdown (LIT-2783) The 'Model Access Group' picker in the Add Model flow only listed legacy access groups discovered from existing deployments' `model_info.access_groups` (via /models?only_model_access_groups=true). Unified access groups created through the Access Groups page (stored in LiteLLM_AccessGroupTable, served by /v1/access_group) were never merged in, so a freshly-created group like 'Engineering' did not appear and admins had to free-type it from memory. Pull both sources: - AddModelForm: combine the legacy /models response with useAccessGroups() - add_auto_router_tab and edit_auto_router_modal: same merge for parity - ModelsAndEndpointsView.availableModelAccessGroups (powers ModelInfoView's edit dropdown) now also folds in unified group names Names are deduped via a Set and sorted for stable rendering. The hook is gated on admin role inside useAccessGroups itself, so non-admins see the same list they did before. Adds an AddModelForm test that opens the dropdown and asserts both the legacy entries and a mocked unified group are listed. Co-authored-by: Mateo Wang --- .../ModelsAndEndpointsView.tsx | 21 +++++++-- .../add_model/AddModelForm.test.tsx | 45 +++++++++++++++++++ .../src/components/add_model/AddModelForm.tsx | 24 +++++++++- .../add_model/add_auto_router_tab.tsx | 17 +++++-- .../edit_auto_router_modal.tsx | 17 +++++-- 5 files changed, 112 insertions(+), 12 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx index 944c56833e5..97ae147f076 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx @@ -1,3 +1,4 @@ +import { useAccessGroups } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups"; import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials"; import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap"; import { useModelsInfo } from "@/app/(dashboard)/hooks/models/useModels"; @@ -92,10 +93,17 @@ const ModelsAndEndpointsView: React.FC = ({ premiumUser, te return Array.from(allModelGroups).sort(); }, [modelDataResponse?.data]); + // Unified access groups created via the Access Groups page (stored in + // LiteLLM_AccessGroupTable). These are managed independently of legacy + // per-deployment model_info.access_groups but should still appear anywhere + // the UI offers a "Model Access Group" picker, so admins can attach a + // freshly-added model to a unified group on the spot. + const { data: unifiedAccessGroupsData } = useAccessGroups(); + const availableModelAccessGroups = useMemo(() => { - if (!modelDataResponse?.data) return []; + if (!modelDataResponse?.data && !unifiedAccessGroupsData) return []; const allModelAccessGroups = new Set(); - for (const model of modelDataResponse.data) { + for (const model of modelDataResponse?.data ?? []) { const modelInfo = model.model_info; if (modelInfo?.access_groups) { for (const group of modelInfo.access_groups) { @@ -103,8 +111,13 @@ const ModelsAndEndpointsView: React.FC = ({ premiumUser, te } } } - return Array.from(allModelAccessGroups); - }, [modelDataResponse?.data]); + for (const group of unifiedAccessGroupsData ?? []) { + if (group?.access_group_name) { + allModelAccessGroups.add(group.access_group_name); + } + } + return Array.from(allModelAccessGroups).sort(); + }, [modelDataResponse?.data, unifiedAccessGroupsData]); const allModelsOnProxy = useMemo(() => { if (!modelDataResponse?.data) return []; diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx index 4a3dbaedf74..1f2a0aa1cec 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx @@ -1,4 +1,5 @@ import { renderHook, screen, waitFor, renderWithProviders } from "../../../tests/test-utils"; +import { act } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Form } from "antd"; import type { UploadProps } from "antd/es/upload"; @@ -100,6 +101,29 @@ vi.mock("@/app/(dashboard)/hooks/tags/useTags", () => ({ }), })); +vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({ + useAccessGroups: vi.fn().mockReturnValue({ + data: [ + { + access_group_id: "ag-1", + access_group_name: "engineering", + description: null, + access_model_names: [], + access_mcp_server_ids: [], + access_agent_ids: [], + assigned_team_ids: [], + assigned_key_ids: [], + created_at: "2024-01-01T00:00:00Z", + created_by: null, + updated_at: "2024-01-01T00:00:00Z", + updated_by: null, + }, + ], + isLoading: false, + isError: false, + }), +})); + const mockAuthorizedUser = (userRole: string, userId: string, premiumUser: boolean) => ({ token: "test-token", accessToken: "test-access-token", @@ -274,6 +298,27 @@ describe("AddModelForm", () => { expect(screen.queryByRole("switch")).not.toBeInTheDocument(); }); + it("should merge unified access groups (from /v1/access_group) with legacy model_info.access_groups in the dropdown", async () => { + const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized")); + mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("proxy_admin", "user-1", true)); + + const props = createTestProps("proxy_admin", "user-1", false); + + renderWithProviders(); + + await screen.findByText("Provider"); + + const accessGroupCombo = await screen.findByRole("combobox", { name: /Model Access Group/i }); + await act(async () => { + await userEvent.click(accessGroupCombo); + }); + + // legacy entries from modelAvailableCall + unified group from useAccessGroups + expect(await screen.findByTitle("model-group-1")).toBeInTheDocument(); + expect(await screen.findByTitle("model-group-2")).toBeInTheDocument(); + expect(await screen.findByTitle("engineering")).toBeInTheDocument(); + }); + it("should handle non-admin, non-team-admin users - should not see team selection or switch", async () => { const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized")); mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("user", "user-1", false)); diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx index f27d9bac4cd..edc7db8b9e1 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx @@ -1,6 +1,7 @@ import { useProviderFields } from "@/app/(dashboard)/hooks/providers/useProviderFields"; import { useGuardrails } from "@/app/(dashboard)/hooks/guardrails/useGuardrails"; import { useTags } from "@/app/(dashboard)/hooks/tags/useTags"; +import { useAccessGroups } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups"; import { all_admin_roles, isUserTeamAdminForAnyTeam } from "@/utils/roles"; import { Switch, Text } from "@tremor/react"; import type { FormInstance } from "antd"; @@ -66,6 +67,11 @@ const AddModelForm: React.FC = ({ const { data: guardrailsData } = useGuardrails(); const guardrailsList = guardrailsData?.guardrails.map((g) => g.guardrail_name); const { data: tagsList, isLoading: isTagsLoading, error: tagsError } = useTags(); + // Unified access groups (managed via /v1/access_group). These are stored in + // LiteLLM_AccessGroupTable, separate from the legacy per-deployment + // model_info.access_groups returned by /models. Both sources need to feed + // the dropdown so admins can attach a model to either kind. + const { data: unifiedAccessGroups } = useAccessGroups(); const handleTestConnection = async () => { setIsTestingConnection(true); @@ -74,18 +80,32 @@ const AddModelForm: React.FC = ({ }; const [isTeamOnly, setIsTeamOnly] = useState(false); - const [modelAccessGroups, setModelAccessGroups] = useState([]); + // Legacy access groups discovered from existing deployments' model_info.access_groups. + const [legacyModelAccessGroups, setLegacyModelAccessGroups] = useState([]); // Team admin specific state const [teamAdminSelectedTeam, setTeamAdminSelectedTeam] = useState(null); useEffect(() => { const fetchModelAccessGroups = async () => { const response = await modelAvailableCall(accessToken, "", "", false, null, true, true); - setModelAccessGroups(response["data"].map((model: any) => model["id"])); + setLegacyModelAccessGroups(response["data"].map((model: any) => model["id"])); }; fetchModelAccessGroups(); }, [accessToken]); + // Merge unified access group names with the legacy list, deduplicated and + // sorted. Without this, groups created on the Access Groups page never + // appear here and admins have to free-type them from memory. + const modelAccessGroups = useMemo(() => { + const merged = new Set(legacyModelAccessGroups); + for (const group of unifiedAccessGroups ?? []) { + if (group?.access_group_name) { + merged.add(group.access_group_name); + } + } + return Array.from(merged).sort(); + }, [legacyModelAccessGroups, unifiedAccessGroups]); + const sortedProviderMetadata: ProviderCreateInfo[] = useMemo(() => { if (!providerMetadata) { return []; diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx index e52c67539db..e6f27f7bf0d 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { Card, Form, Button, Tooltip, Typography, Select as AntdSelect, Modal, Radio, Badge, Space } from "antd"; import type { FormInstance } from "antd"; import { Text, TextInput } from "@tremor/react"; @@ -11,6 +11,7 @@ import RouterConfigBuilder from "./RouterConfigBuilder"; import ComplexityRouterConfig from "./ComplexityRouterConfig"; import NotificationManager from "../molecules/notifications_manager"; import { ThunderboltOutlined, BranchesOutlined } from "@ant-design/icons"; +import { useAccessGroups } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups"; interface AddAutoRouterTabProps { form: FormInstance; @@ -36,7 +37,17 @@ const AddAutoRouterTab: React.FC = ({ form, handleOk, acc const [isTestingConnection, setIsTestingConnection] = useState(false); const [connectionTestId, setConnectionTestId] = useState(""); - const [modelAccessGroups, setModelAccessGroups] = useState([]); + const [legacyModelAccessGroups, setLegacyModelAccessGroups] = useState([]); + const { data: unifiedAccessGroups } = useAccessGroups(); + const modelAccessGroups = useMemo(() => { + const merged = new Set(legacyModelAccessGroups); + for (const group of unifiedAccessGroups ?? []) { + if (group?.access_group_name) { + merged.add(group.access_group_name); + } + } + return Array.from(merged).sort(); + }, [legacyModelAccessGroups, unifiedAccessGroups]); const [modelInfo, setModelInfo] = useState([]); const [showCustomDefaultModel, setShowCustomDefaultModel] = useState(false); const [showCustomEmbeddingModel, setShowCustomEmbeddingModel] = useState(false); @@ -58,7 +69,7 @@ const AddAutoRouterTab: React.FC = ({ form, handleOk, acc useEffect(() => { const fetchModelAccessGroups = async () => { const response = await modelAvailableCall(accessToken, "", "", false, null, true, true); - setModelAccessGroups(response["data"].map((model: any) => model["id"])); + setLegacyModelAccessGroups(response["data"].map((model: any) => model["id"])); }; fetchModelAccessGroups(); }, [accessToken]); diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx index e4e6ebd1da9..c5d69048fe8 100644 --- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx +++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx @@ -1,10 +1,11 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { Modal, Form, Button, Select as AntdSelect } from "antd"; import { Text, TextInput } from "@tremor/react"; import { modelAvailableCall, modelPatchUpdateCall } from "../networking"; import { fetchAvailableModels, ModelGroup } from "../playground/llm_calls/fetch_models"; import RouterConfigBuilder from "../add_model/RouterConfigBuilder"; import NotificationsManager from "../molecules/notifications_manager"; +import { useAccessGroups } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups"; interface EditAutoRouterModalProps { isVisible: boolean; @@ -25,7 +26,17 @@ const EditAutoRouterModal: React.FC = ({ }) => { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); - const [modelAccessGroups, setModelAccessGroups] = useState([]); + const [legacyModelAccessGroups, setLegacyModelAccessGroups] = useState([]); + const { data: unifiedAccessGroups } = useAccessGroups(); + const modelAccessGroups = useMemo(() => { + const merged = new Set(legacyModelAccessGroups); + for (const group of unifiedAccessGroups ?? []) { + if (group?.access_group_name) { + merged.add(group.access_group_name); + } + } + return Array.from(merged).sort(); + }, [legacyModelAccessGroups, unifiedAccessGroups]); const [modelInfo, setModelInfo] = useState([]); const [showCustomDefaultModel, setShowCustomDefaultModel] = useState(false); const [showCustomEmbeddingModel, setShowCustomEmbeddingModel] = useState(false); @@ -42,7 +53,7 @@ const EditAutoRouterModal: React.FC = ({ if (!accessToken) return; try { const response = await modelAvailableCall(accessToken, "", "", false, null, true, true); - setModelAccessGroups(response["data"].map((model: any) => model["id"])); + setLegacyModelAccessGroups(response["data"].map((model: any) => model["id"])); } catch (error) { console.error("Error fetching model access groups:", error); }