diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json
index 7fe02c6d8bc..0f7a933e77c 100644
--- a/litellm/proxy/_lazy_openapi_snapshot.json
+++ b/litellm/proxy/_lazy_openapi_snapshot.json
@@ -782,6 +782,13 @@
},
"ValidationError": {
"properties": {
+ "ctx": {
+ "title": "Context",
+ "type": "object"
+ },
+ "input": {
+ "title": "Input"
+ },
"loc": {
"items": {
"anyOf": [
@@ -1202,6 +1209,61 @@
"access_groups"
]
},
+ "patch": {
+ "operationId": "update_access_group_v1_access_group__access_group_id__patch",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "access_group_id",
+ "required": true,
+ "schema": {
+ "title": "Access Group Id",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AccessGroupUpdateRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AccessGroupResponse"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "security": [
+ {
+ "APIKeyHeader": []
+ }
+ ],
+ "summary": "Update Access Group",
+ "tags": [
+ "access_groups"
+ ]
+ },
"put": {
"operationId": "update_access_group_v1_access_group__access_group_id__put",
"parameters": [
@@ -1416,6 +1478,61 @@
"access_groups"
]
},
+ "patch": {
+ "operationId": "update_access_group_v1_unified_access_group__access_group_id__patch",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "access_group_id",
+ "required": true,
+ "schema": {
+ "title": "Access Group Id",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AccessGroupUpdateRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AccessGroupResponse"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "security": [
+ {
+ "APIKeyHeader": []
+ }
+ ],
+ "summary": "Update Access Group",
+ "tags": [
+ "access_groups"
+ ]
+ },
"put": {
"operationId": "update_access_group_v1_unified_access_group__access_group_id__put",
"parameters": [
diff --git a/litellm/proxy/management_endpoints/access_group_endpoints.py b/litellm/proxy/management_endpoints/access_group_endpoints.py
index 2271501d480..117b5b3f824 100644
--- a/litellm/proxy/management_endpoints/access_group_endpoints.py
+++ b/litellm/proxy/management_endpoints/access_group_endpoints.py
@@ -416,6 +416,10 @@ async def get_access_group(
return _record_to_response(record)
+@router.patch(
+ "/v1/access_group/{access_group_id}",
+ response_model=AccessGroupResponse,
+)
@router.put(
"/v1/access_group/{access_group_id}",
response_model=AccessGroupResponse,
@@ -637,6 +641,12 @@ router.add_api_route(
methods=["PUT"],
response_model=AccessGroupResponse,
)
+router.add_api_route(
+ "/v1/unified_access_group/{access_group_id}",
+ update_access_group,
+ methods=["PATCH"],
+ response_model=AccessGroupResponse,
+)
router.add_api_route(
"/v1/unified_access_group/{access_group_id}",
delete_access_group,
diff --git a/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py
index 016e10859b6..9331555bd62 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py
@@ -410,6 +410,7 @@ def test_get_access_group_forbidden_non_admin(client_and_mocks, user_role):
# ---------------------------------------------------------------------------
+@pytest.mark.parametrize("method", ["put", "patch"])
@pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS)
@pytest.mark.parametrize(
"update_payload",
@@ -419,18 +420,36 @@ def test_get_access_group_forbidden_non_admin(client_and_mocks, user_role):
{"assigned_team_ids": [], "assigned_key_ids": ["key-1"]},
],
)
-def test_update_access_group_success(client_and_mocks, base_path, update_payload):
- """Update access group with various payloads returns 200."""
+def test_update_access_group_success(client_and_mocks, method, base_path, update_payload):
+ """Update access group with various payloads returns 200 over both PUT and PATCH."""
client, _, mock_table, *_ = client_and_mocks
existing = _make_access_group_record(access_group_id="ag-update")
mock_table.find_unique = AsyncMock(return_value=existing)
- resp = client.put(f"{base_path}/ag-update", json=update_payload)
+ resp = client.request(method, f"{base_path}/ag-update", json=update_payload)
assert resp.status_code == 200
mock_table.update.assert_awaited_once()
+@pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS)
+def test_patch_access_group_writes_only_sent_fields(client_and_mocks, base_path):
+ """PATCH with one field leaves every other column out of the write, so untouched grants survive."""
+ client, _, mock_table, *_ = client_and_mocks
+
+ existing = _make_access_group_record(
+ access_group_id="ag-update", access_model_names=["model-1"], access_agent_ids=["agent-1"]
+ )
+ mock_table.find_unique = AsyncMock(return_value=existing)
+
+ resp = client.patch(f"{base_path}/ag-update", json={"description": "Only this changes"})
+ assert resp.status_code == 200
+ assert mock_table.update.call_args.kwargs["data"] == {
+ "updated_by": "admin_user",
+ "description": "Only this changes",
+ }
+
+
def test_update_access_group_not_found(client_and_mocks):
"""Update access group returns 404 when not found."""
client, _, mock_table, *_ = client_and_mocks
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx
index cf41f623fd6..ba256d713a2 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx
@@ -7,11 +7,11 @@ import { renderWithProviders } from "../../../../../tests/test-utils";
import { AccessGroupDetail } from "./AccessGroupsDetailsPage";
vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails");
-vi.mock("./AccessGroupsModal/AccessGroupEditModal", () => ({
- AccessGroupEditModal: ({ visible, onCancel }: { visible: boolean; onCancel: () => void }) =>
- visible ? (
+vi.mock("./access-group-edit/AccessGroupEditDialog", () => ({
+ AccessGroupEditDialog: ({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) =>
+ open ? (
-
+
) : null,
}));
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx
index 9476a8d98af..6d72489c052 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx
@@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
-import { AccessGroupEditModal } from "./AccessGroupsModal/AccessGroupEditModal";
+import { AccessGroupEditDialog } from "./access-group-edit/AccessGroupEditDialog";
interface AccessGroupDetailProps {
accessGroupId: string;
@@ -218,11 +218,7 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
- setIsEditModalVisible(false)}
- />
+
);
}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx
deleted file mode 100644
index 72b89e34301..00000000000
--- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx
+++ /dev/null
@@ -1,147 +0,0 @@
-import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
-import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
-import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
-import type { FormInstance } from "antd";
-import { Form, Input, Select, Space, Tabs } from "antd";
-import { BotIcon, InfoIcon, LayersIcon, ServerIcon } from "lucide-react";
-
-const { TextArea } = Input;
-
-export interface AccessGroupFormValues {
- name: string;
- description: string;
- modelIds: string[];
- mcpServerIds: string[];
- agentIds: string[];
-}
-
-interface AccessGroupBaseFormProps {
- form: FormInstance;
- isNameDisabled?: boolean;
-}
-
-export function AccessGroupBaseForm({ form, isNameDisabled = false }: AccessGroupBaseFormProps) {
- const { data: agentsData } = useAgents();
- const { data: mcpServersData } = useMCPServers();
-
- const agents = agentsData?.agents ?? [];
- const mcpServers = mcpServersData ?? [];
- const items = [
- {
- key: "1",
- label: (
-
-
- General Info
-
- ),
- children: (
-
-
-
-
-
-
-
-
- ),
- },
- {
- key: "2",
- label: (
-
-
- Models
-
- ),
- children: (
-
-
- form.setFieldsValue({ modelIds: values })}
- style={{ width: "100%" }}
- />
-
-
- ),
- },
- {
- key: "3",
- label: (
-
-
- MCP Servers
-
- ),
- children: (
-
-
-
-
- ),
- },
- {
- key: "4",
- label: (
-
-
- Agents
-
- ),
- children: (
-
-
-
-
- ),
- },
- ];
-
- return (
-
- );
-}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.tsx
deleted file mode 100644
index ec557260aa5..00000000000
--- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import React, { useEffect } from "react";
-import { Modal, Form } from "antd";
-import MessageManager from "@/components/molecules/message_manager";
-import { AccessGroupBaseForm, AccessGroupFormValues } from "./AccessGroupBaseForm";
-import { useEditAccessGroup, AccessGroupUpdateParams } from "@/app/(dashboard)/hooks/accessGroups/useEditAccessGroup";
-import { AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
-
-interface AccessGroupEditModalProps {
- visible: boolean;
- accessGroup: AccessGroupResponse;
- onCancel: () => void;
- onSuccess?: () => void;
-}
-
-export function AccessGroupEditModal({ visible, accessGroup, onCancel, onSuccess }: AccessGroupEditModalProps) {
- const [form] = Form.useForm();
- const editMutation = useEditAccessGroup();
-
- // Populate the form with initial values whenever the modal opens or the data changes
- useEffect(() => {
- if (visible && accessGroup) {
- form.setFieldsValue({
- name: accessGroup.access_group_name,
- description: accessGroup.description ?? "",
- modelIds: accessGroup.access_model_names ?? [],
- mcpServerIds: accessGroup.access_mcp_server_ids ?? [],
- agentIds: accessGroup.access_agent_ids ?? [],
- });
- }
- }, [visible, accessGroup, form]);
-
- const handleOk = () => {
- form
- .validateFields()
- .then((values) => {
- const params: AccessGroupUpdateParams = {
- access_group_name: values.name,
- description: values.description,
- access_model_names: values.modelIds,
- access_mcp_server_ids: values.mcpServerIds,
- access_agent_ids: values.agentIds,
- };
-
- editMutation.mutate(
- { accessGroupId: accessGroup.access_group_id, params },
- {
- onSuccess: () => {
- MessageManager.success("Access group updated successfully");
- onSuccess?.();
- onCancel();
- },
- },
- );
- })
- .catch((info) => {});
- };
-
- return (
-
-
-
- );
-}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.tsx
index 3f2205b4206..c60b659e06c 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.tsx
@@ -1,76 +1,18 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
-import { BotIcon, InfoIcon, LayersIcon, ServerIcon } from "lucide-react";
import * as React from "react";
import { accessGroupKeys } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
-import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
-import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
-import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
import NotificationsManager from "@/components/molecules/notifications_manager";
-import { FieldGroup } from "@/components/shared/form/field";
-import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
-import { Input } from "@/components/ui/input";
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { Textarea } from "@/components/ui/textarea";
import { useZodForm } from "@/lib/forms/useZodForm";
import { fetchClient } from "@/lib/http/api";
-import { buildAccessGroupCreateBody, emptyAccessGroupFormValues, type AccessGroupCreateBody } from "./mapper";
-import { accessGroupCreateSchema } from "./schema";
-
-const GENERAL_TAB = "general";
-
-interface MultiSelectOption {
- value: string;
- label: string;
-}
-
-interface MultiSelectProps {
- id: string;
- value: string[];
- onChange: (value: string[]) => void;
- options: MultiSelectOption[];
- placeholder: string;
- "aria-invalid": true | undefined;
- "aria-describedby": string | undefined;
-}
-
-const MultiSelect = ({
- id,
- value,
- onChange,
- options,
- placeholder,
- "aria-invalid": ariaInvalid,
- "aria-describedby": ariaDescribedBy,
-}: MultiSelectProps) => (
-
-);
+import { AccessGroupFormFields, GENERAL_TAB } from "../access-group-form/AccessGroupFormFields";
+import { accessGroupFormSchema, emptyAccessGroupFormValues } from "../access-group-form/schema";
+import { buildAccessGroupCreateBody, type AccessGroupCreateBody } from "./mapper";
const defaultCreateAccessGroup = async (body: AccessGroupCreateBody): Promise => {
const { data } = await fetchClient.POST("/v1/access_group", { body });
@@ -89,21 +31,9 @@ export const AccessGroupCreateDialog = ({
createAccessGroup = defaultCreateAccessGroup,
}: AccessGroupCreateDialogProps) => {
const queryClient = useQueryClient();
- const form = useZodForm(accessGroupCreateSchema, { defaultValues: emptyAccessGroupFormValues });
+ const form = useZodForm(accessGroupFormSchema, { defaultValues: emptyAccessGroupFormValues });
const [activeTab, setActiveTab] = React.useState(GENERAL_TAB);
- const { data: agentsData } = useAgents();
- const { data: mcpServersData } = useMCPServers();
-
- const mcpServerOptions = (mcpServersData ?? []).map((server) => ({
- value: server.server_id,
- label: server.server_name ?? server.server_id,
- }));
- const agentOptions = (agentsData?.agents ?? []).map((agent) => ({
- value: agent.agent_id,
- label: agent.agent_name,
- }));
-
const closeAndReset = () => {
form.reset(emptyAccessGroupFormValues);
setActiveTab(GENERAL_TAB);
@@ -147,82 +77,7 @@ export const AccessGroupCreateDialog = ({