diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useCreateModelAccessGroup.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useCreateModelAccessGroup.ts new file mode 100644 index 00000000000..22a661493e1 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useCreateModelAccessGroup.ts @@ -0,0 +1,31 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { fetchClient } from "@/lib/http/api"; +import type { components } from "@/lib/http/schema"; +import { modelKeys } from "@/app/(dashboard)/hooks/models/useModels"; +import { modelAccessGroupKeys } from "./useModelAccessGroups"; + +export type CreateModelAccessGroupParams = components["schemas"]["NewModelGroupRequest"]; +type CreateModelAccessGroupResponse = components["schemas"]["NewModelGroupResponse"]; + +const createModelAccessGroup = async ( + params: CreateModelAccessGroupParams, +): Promise => { + const { data } = await fetchClient.POST("/access_group/new", { body: params }); + return data; +}; + +/** + * Create a model access group by tagging every database deployment of the given model names with it. + * The proxy refuses a name that already exists and any model that only lives in config.yaml. + */ +export const useCreateModelAccessGroup = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createModelAccessGroup, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: modelAccessGroupKeys.all }); + queryClient.invalidateQueries({ queryKey: modelKeys.lists() }); + }, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts index 7231c126a63..19ed06f48cd 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { isAutoRouterDeployment, selectAutoRouterModelGroups, + selectDatabaseModelGroups, selectPlainModelGroups, useAllProxyModels, useAutoRouterModelGroups, @@ -1136,3 +1137,18 @@ describe("useAutoRouterModelGroups", () => { }); }); }); + +describe("selectDatabaseModelGroups", () => { + it("keeps only names with a database deployment, deduplicated and sorted", () => { + expect( + selectDatabaseModelGroups([ + { model_name: "zeta", model_info: { db_model: true } }, + { model_name: "alpha", model_info: { db_model: true } }, + { model_name: "alpha", model_info: { db_model: true } }, + { model_name: "config-only", model_info: { db_model: false } }, + { model_name: "untagged", model_info: {} }, + { model_name: null, model_info: { db_model: true } }, + ]), + ).toEqual(["alpha", "zeta"]); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts index a9f7c54698a..92b0b6bf978 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts @@ -22,7 +22,7 @@ export interface PaginatedModelInfoResponse { size: number; } -const modelKeys = createQueryKeys("models"); +export const modelKeys = createQueryKeys("models"); const modelHubKeys = createQueryKeys("modelHub"); const allProxyModelsKeys = createQueryKeys("allProxyModels"); const selectedTeamModelsKeys = createQueryKeys("selectedTeamModels"); @@ -196,6 +196,30 @@ export const usePlainModelGroups = (): ReadonlySet => { return data ?? NO_AUTO_ROUTERS; }; +const NO_MODEL_GROUPS: readonly string[] = []; + +/** Public model names with at least one database deployment, which is what /access_group/new can tag. */ +export const selectDatabaseModelGroups = (deployments: AutoRouterDeployment[]): string[] => + Array.from( + new Set( + deployments + .filter((deployment) => deployment.model_info?.db_model === true) + .map((deployment) => deployment.model_name) + .filter((modelName): modelName is string => Boolean(modelName)), + ), + ).sort(); + +export const useDatabaseModelGroups = (): { data: readonly string[]; isLoading: boolean } => { + const { accessToken, userId, userRole } = useAuthorized(); + const { data, isLoading } = useQuery({ + queryKey: autoRouterListKey(userId, userRole), + queryFn: async () => await fetchAllModelDeployments(accessToken!, userId!, userRole!), + enabled: Boolean(accessToken && userId && userRole), + select: selectDatabaseModelGroups, + }); + return { data: data ?? NO_MODEL_GROUPS, isLoading }; +}; + export const useAutoRouters = (): UseQueryResult => { const { accessToken, userId, userRole } = useAuthorized(); return useQuery({ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetModal.tsx index 8df2a6620f9..e94d0159951 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetModal.tsx @@ -1,12 +1,11 @@ "use client"; -import { CircleHelp } from "lucide-react"; import React from "react"; import { z } from "zod/v4"; import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown"; import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { TooltipProvider } from "@/components/ui/tooltip"; import NumericalInput from "@/components/shared/numerical_input"; import { Button } from "@/components/ui/button"; import { useZodForm } from "@/lib/forms/useZodForm"; @@ -14,16 +13,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u import { ModelAccessGroup } from "@/app/(dashboard)/hooks/modelAccessGroups/useModelAccessGroups"; import { SetModelAccessGroupBudgetParams } from "@/app/(dashboard)/hooks/modelAccessGroups/useSetModelAccessGroupBudget"; import { accessGroupBudgetFormValues, buildAccessGroupBudgetBody, hasAnyBudgetValue } from "./accessGroupBudgetPayload"; - -const labelWithHint = (label: React.ReactNode, hint: string): React.ReactNode => ( - <> - {label} - - } /> - {hint} - - -); +import { labelWithHint } from "./LabelWithHint"; const budgetSchema = z .object({ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/CreateAccessGroupModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/CreateAccessGroupModal.tsx new file mode 100644 index 00000000000..3a74932fe49 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/CreateAccessGroupModal.tsx @@ -0,0 +1,95 @@ +"use client"; + +import React, { useMemo } from "react"; +import { MultiSelect } from "@/components/shared/MultiSelect"; +import { FormField } from "@/components/shared/form/FormField"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { FieldGroup } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { useZodForm } from "@/lib/forms/useZodForm"; +import { CreateModelAccessGroupParams } from "@/app/(dashboard)/hooks/modelAccessGroups/useCreateModelAccessGroup"; +import { createAccessGroupSchema } from "./createAccessGroupSchema"; +import { labelWithHint } from "./LabelWithHint"; + +interface CreateAccessGroupModalProps { + existingGroups: readonly string[]; + modelOptions: readonly string[]; + isLoadingModels: boolean; + isSaving: boolean; + onCancel: () => void; + onSubmit: (params: CreateModelAccessGroupParams) => void; +} + +const CreateAccessGroupModal: React.FC = ({ + existingGroups, + modelOptions, + isLoadingModels, + isSaving, + onCancel, + onSubmit, +}) => { + const schema = useMemo(() => createAccessGroupSchema(new Set(existingGroups)), [existingGroups]); + const form = useZodForm(schema, { defaultValues: { access_group: "", model_names: [] } }); + const options = useMemo(() => modelOptions.map((name) => ({ label: name, value: name })), [modelOptions]); + + return ( + !open && onCancel()}> + + + Create access group + +

+ The group is added to every deployment of the models you pick. Keys and teams can then be granted the group by + name, and it can carry one shared budget. +

+
+ + + + {(field) => } + + + + {({ id, value, onChange }) => ( + + )} + + + +
+ + +
+
+
+
+
+ ); +}; + +export default CreateAccessGroupModal; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/LabelWithHint.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/LabelWithHint.tsx new file mode 100644 index 00000000000..d34aa119987 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/LabelWithHint.tsx @@ -0,0 +1,13 @@ +import { CircleHelp } from "lucide-react"; +import React from "react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +export const labelWithHint = (label: React.ReactNode, hint: string): React.ReactNode => ( + <> + {label} + + } /> + {hint} + + +); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/createAccessGroupSchema.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/createAccessGroupSchema.test.ts new file mode 100644 index 00000000000..b8b03bcf27c --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/createAccessGroupSchema.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { createAccessGroupSchema } from "./createAccessGroupSchema"; + +const schema = createAccessGroupSchema(new Set(["premium"])); + +describe("createAccessGroupSchema", () => { + it("accepts a fresh name with at least one model and trims the name", () => { + const result = schema.safeParse({ access_group: " gold ", model_names: ["gold-nano"] }); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ access_group: "gold", model_names: ["gold-nano"] }); + }); + + it.each([ + ["", "Enter a name for the access group"], + [" ", "Enter a name for the access group"], + ["openai/prod", 'A group name cannot contain "/"'], + ["premium", "An access group with this name already exists"], + [" premium ", "An access group with this name already exists"], + ])("rejects the name %j", (name, message) => { + const result = schema.safeParse({ access_group: name, model_names: ["gold-nano"] }); + + expect(result.success).toBe(false); + expect(result.error?.issues.map((issue) => issue.message)).toContain(message); + }); + + it("rejects an empty model list", () => { + const result = schema.safeParse({ access_group: "gold", model_names: [] }); + + expect(result.success).toBe(false); + expect(result.error?.issues.map((issue) => issue.message)).toEqual(["Pick at least one model"]); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/createAccessGroupSchema.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/createAccessGroupSchema.ts new file mode 100644 index 00000000000..cc6ba974ffa --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/createAccessGroupSchema.ts @@ -0,0 +1,15 @@ +import { z } from "zod/v4"; +import { isBudgetAddressable } from "./AccessGroupBudgetColumns"; + +export const createAccessGroupSchema = (existingGroups: ReadonlySet) => + z.object({ + access_group: z + .string() + .trim() + .min(1, "Enter a name for the access group") + .refine(isBudgetAddressable, 'A group name cannot contain "/"') + .refine((name) => !existingGroups.has(name), "An access group with this name already exists"), + model_names: z.array(z.string()).min(1, "Pick at least one model"), + }); + +export type CreateAccessGroupFormValues = z.input>; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.integration.test.tsx index 4d0818987ce..f7a67c16b80 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.integration.test.tsx @@ -1,19 +1,26 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const { GET, PUT, DELETE, userRole } = vi.hoisted(() => ({ +const { GET, PUT, DELETE, POST, modelInfoCall, userRole } = vi.hoisted(() => ({ GET: vi.fn(), PUT: vi.fn(), DELETE: vi.fn(), + POST: vi.fn(), + modelInfoCall: vi.fn(), userRole: { current: "Admin" }, })); -vi.mock("@/lib/http/api", () => ({ fetchClient: { GET, PUT, DELETE } })); +vi.mock("@/lib/http/api", () => ({ fetchClient: { GET, PUT, DELETE, POST } })); +vi.mock("@/components/networking", () => ({ + modelInfoCall, + modelHubCall: vi.fn(), + modelAvailableCall: vi.fn(), +})); vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ - default: () => ({ accessToken: "sk-test", userRole: userRole.current }), + default: () => ({ accessToken: "sk-test", userId: "user-1", userRole: userRole.current }), })); import AccessGroupBudgetsPanel from "./AccessGroupBudgetsPanel"; @@ -40,6 +47,16 @@ const FREE_GROUP = { budget: null, }; +const DATABASE_DEPLOYMENT = { model_name: "gold-nano", model_info: { id: "m-1", db_model: true } }; +const CONFIG_DEPLOYMENT = { model_name: "config-nano", model_info: { id: "m-2", db_model: false } }; +const MODEL_INFO_PAGE = { + data: [DATABASE_DEPLOYMENT, CONFIG_DEPLOYMENT], + total_count: 2, + current_page: 1, + total_pages: 1, + size: 1000, +}; + const renderPanel = () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( @@ -60,6 +77,8 @@ describe("AccessGroupBudgetsPanel", () => { GET.mockResolvedValue({ data: { access_groups: [BUDGETED_GROUP, FREE_GROUP] } }); PUT.mockResolvedValue({ data: { access_group: "shared", spend: 0, budget: null } }); DELETE.mockResolvedValue({ data: { access_group: "premium", budget_deleted: true, message: "ok" } }); + POST.mockResolvedValue({ data: { access_group: "gold", model_names: ["gold-nano"], models_updated: 1 } }); + modelInfoCall.mockResolvedValue(MODEL_INFO_PAGE); }); it("lists each group with the spend drawn against its shared budget", async () => { @@ -162,4 +181,82 @@ describe("AccessGroupBudgetsPanel", () => { }), ); }); + + describe("creating a group", () => { + const openCreate = async () => { + await userEvent.click(await screen.findByTestId("create-access-group")); + await screen.findByRole("dialog"); + }; + + const nameInput = () => screen.getByLabelText(/access group name/i); + const submitButton = () => within(screen.getByRole("dialog")).getByRole("button", { name: /create access group/i }); + + // Base UI marks the dialog inert while the option list is open, so dismiss it before touching the form + const pickModel = async (modelName: string) => { + await userEvent.click(within(screen.getByRole("dialog")).getByRole("combobox")); + await userEvent.click(await screen.findByText(modelName)); + await userEvent.click(nameInput()); + }; + + it("hides creation from an admin viewer", async () => { + userRole.current = "Admin Viewer"; + renderPanel(); + + expect(await screen.findByText("premium")).toBeInTheDocument(); + expect(screen.queryByTestId("create-access-group")).not.toBeInTheDocument(); + }); + + it("offers only models that have a database deployment", async () => { + renderPanel(); + await openCreate(); + + await userEvent.click(within(screen.getByRole("dialog")).getByRole("combobox")); + + expect(await screen.findByText("gold-nano")).toBeInTheDocument(); + expect(screen.queryByText("config-nano")).not.toBeInTheDocument(); + }); + + it("tags the chosen models with the trimmed name and lists the new group", async () => { + renderPanel(); + await openCreate(); + + fireEvent.change(nameInput(), { target: { value: " gold " } }); + await pickModel("gold-nano"); + GET.mockResolvedValue({ + data: { access_groups: [BUDGETED_GROUP, FREE_GROUP, { ...FREE_GROUP, access_group: "gold" }] }, + }); + await userEvent.click(submitButton()); + + await waitFor(() => + expect(POST).toHaveBeenCalledWith("/access_group/new", { + body: { access_group: "gold", model_names: ["gold-nano"] }, + }), + ); + expect(await screen.findByText("gold")).toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("refuses a name the proxy already has without sending a request", async () => { + renderPanel(); + await openCreate(); + + fireEvent.change(nameInput(), { target: { value: "premium" } }); + await pickModel("gold-nano"); + await userEvent.click(submitButton()); + + expect(await screen.findByText("An access group with this name already exists")).toBeInTheDocument(); + expect(POST).not.toHaveBeenCalled(); + }); + + it("requires at least one model", async () => { + renderPanel(); + await openCreate(); + + fireEvent.change(nameInput(), { target: { value: "gold" } }); + await userEvent.click(submitButton()); + + expect(await screen.findByText("Pick at least one model")).toBeInTheDocument(); + expect(POST).not.toHaveBeenCalled(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.tsx index ee8baab81ab..466da3cde02 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.tsx @@ -1,21 +1,28 @@ "use client"; import { SortingState } from "@tanstack/react-table"; -import { Inbox } from "lucide-react"; +import { Inbox, Plus } from "lucide-react"; import React, { useMemo, useState } from "react"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; import { DataTable } from "@/components/shared/DataTable"; +import { Button } from "@/components/ui/button"; import { toast } from "@/lib/toast"; import { isProxyAdminRole } from "@/utils/roles"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useDatabaseModelGroups } from "@/app/(dashboard)/hooks/models/useModels"; import { ModelAccessGroup, useModelAccessGroups } from "@/app/(dashboard)/hooks/modelAccessGroups/useModelAccessGroups"; +import { + CreateModelAccessGroupParams, + useCreateModelAccessGroup, +} from "@/app/(dashboard)/hooks/modelAccessGroups/useCreateModelAccessGroup"; import { useDeleteModelAccessGroupBudget } from "@/app/(dashboard)/hooks/modelAccessGroups/useDeleteModelAccessGroupBudget"; import { SetModelAccessGroupBudgetParams, useSetModelAccessGroupBudget, } from "@/app/(dashboard)/hooks/modelAccessGroups/useSetModelAccessGroupBudget"; import AccessGroupBudgetModal from "@/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetModal"; +import CreateAccessGroupModal from "@/app/(dashboard)/models-and-endpoints/components/CreateAccessGroupModal"; import { getAccessGroupBudgetColumns } from "@/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetColumns"; const DEFAULT_SORTING: SortingState = [{ id: "access_group", desc: false }]; @@ -28,7 +35,7 @@ function EmptyState() {
No model access groups yet
- Put a deployment in an access group from its model settings, then give the group a shared budget here. + Create one here or put a deployment in a group from its model settings, then give the group a shared budget.
); @@ -39,10 +46,13 @@ export default function AccessGroupBudgetsPanel() { const { data: accessGroups, isLoading } = useModelAccessGroups(); const setBudget = useSetModelAccessGroupBudget(); const clearBudget = useDeleteModelAccessGroupBudget(); + const createGroup = useCreateModelAccessGroup(); + const { data: databaseModelGroups, isLoading: isLoadingModels } = useDatabaseModelGroups(); const [sorting, setSorting] = useState(DEFAULT_SORTING); const [editing, setEditing] = useState(null); const [clearing, setClearing] = useState(null); + const [creating, setCreating] = useState(false); const canWrite = isProxyAdminRole(userRole ?? ""); const columns = useMemo( @@ -50,6 +60,17 @@ export default function AccessGroupBudgetsPanel() { [canWrite], ); + const existingGroups = useMemo(() => (accessGroups ?? []).map((group) => group.access_group), [accessGroups]); + + const handleCreate = (params: CreateModelAccessGroupParams) => { + createGroup.mutate(params, { + onSuccess: () => { + toast.success(`Access group "${params.access_group}" created`); + setCreating(false); + }, + }); + }; + const handleSubmit = (params: SetModelAccessGroupBudgetParams) => { if (!editing) return; const accessGroup = editing.access_group; @@ -77,10 +98,18 @@ export default function AccessGroupBudgetsPanel() { return (
-

- A model access group can carry one budget that every key granted the group by name draws from together. Keys - that reach the group's models through a wildcard or all-proxy-models are not charged against it. -

+
+

+ A model access group can carry one budget that every key granted the group by name draws from together. Keys + that reach the group's models through a wildcard or all-proxy-models are not charged against it. +

+ {canWrite && ( + + )} +
+ {creating && ( + setCreating(false)} + onSubmit={handleCreate} + /> + )} +