mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-09 08:18:12 +00:00
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:
parent
790d8bbe1a
commit
f969eb8c48
5 changed files with 112 additions and 12 deletions
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue