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 <mateo-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-05-04 19:17:50 +00:00
parent 790d8bbe1a
commit f969eb8c48
No known key found for this signature in database
5 changed files with 112 additions and 12 deletions

View file

@ -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<ModelDashboardProps> = ({ 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<string>();
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<ModelDashboardProps> = ({ 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<string[]>(() => {
if (!modelDataResponse?.data) return [];

View file

@ -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(<AddModelForm {...props} />);
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));

View file

@ -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<AddModelFormProps> = ({
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<AddModelFormProps> = ({
};
const [isTeamOnly, setIsTeamOnly] = useState<boolean>(false);
const [modelAccessGroups, setModelAccessGroups] = useState<string[]>([]);
// Legacy access groups discovered from existing deployments' model_info.access_groups.
const [legacyModelAccessGroups, setLegacyModelAccessGroups] = useState<string[]>([]);
// Team admin specific state
const [teamAdminSelectedTeam, setTeamAdminSelectedTeam] = useState<string | null>(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<string[]>(() => {
const merged = new Set<string>(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 [];

View file

@ -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<AddAutoRouterTabProps> = ({ form, handleOk, acc
const [isTestingConnection, setIsTestingConnection] = useState<boolean>(false);
const [connectionTestId, setConnectionTestId] = useState<string>("");
const [modelAccessGroups, setModelAccessGroups] = useState<string[]>([]);
const [legacyModelAccessGroups, setLegacyModelAccessGroups] = useState<string[]>([]);
const { data: unifiedAccessGroups } = useAccessGroups();
const modelAccessGroups = useMemo<string[]>(() => {
const merged = new Set<string>(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<ModelGroup[]>([]);
const [showCustomDefaultModel, setShowCustomDefaultModel] = useState<boolean>(false);
const [showCustomEmbeddingModel, setShowCustomEmbeddingModel] = useState<boolean>(false);
@ -58,7 +69,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ 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]);

View file

@ -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<EditAutoRouterModalProps> = ({
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [modelAccessGroups, setModelAccessGroups] = useState<string[]>([]);
const [legacyModelAccessGroups, setLegacyModelAccessGroups] = useState<string[]>([]);
const { data: unifiedAccessGroups } = useAccessGroups();
const modelAccessGroups = useMemo<string[]>(() => {
const merged = new Set<string>(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<ModelGroup[]>([]);
const [showCustomDefaultModel, setShowCustomDefaultModel] = useState<boolean>(false);
const [showCustomEmbeddingModel, setShowCustomEmbeddingModel] = useState<boolean>(false);
@ -42,7 +53,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
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);
}