diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index fbd464bac6c..5af45b29226 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -1183,7 +1183,7 @@ "paths": { "/access_group/list": { "get": { - "description": "List all access groups.\n\nReturns a list of all access groups with their model names and deployment counts.\n\nExample:\n```bash\ncurl -X GET 'http://localhost:4000/access_group/list' \\\n -H 'Authorization: Bearer sk-1234'\n```\n\nReturns:\n- ListAccessGroupsResponse with all access groups", + "description": "List all access groups.\n\nReturns a list of all access groups with their model names, deployment counts, shared budget\nand the spend drawn against it.\n\nExample:\n```bash\ncurl -X GET 'http://localhost:4000/access_group/list' \\\n -H 'Authorization: Bearer sk-1234'\n```\n\nReturns:\n- ListAccessGroupsResponse with all access groups", "operationId": "list_access_groups_access_group_list_get", "responses": { "200": { diff --git a/litellm/proxy/management_endpoints/model_access_group_management_endpoints.py b/litellm/proxy/management_endpoints/model_access_group_management_endpoints.py index dabb9334b16..a48130a4f22 100644 --- a/litellm/proxy/management_endpoints/model_access_group_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_access_group_management_endpoints.py @@ -15,6 +15,7 @@ Endpoints here: import json from collections.abc import Mapping, Sequence from datetime import datetime +from types import MappingProxyType from typing import TYPE_CHECKING, Annotated, Any, Final, Protocol from fastapi import APIRouter, Depends, HTTPException @@ -133,6 +134,9 @@ class _BudgetRow(Protocol): class _ModelAccessGroupBudgetRow(Protocol): + @property + def access_group_name(self) -> str: ... + @property def spend(self) -> float: ... @@ -156,6 +160,10 @@ class _ModelAccessGroupBudgetTableClient(Protocol): include: Mapping[str, object] | None = None, ) -> _ModelAccessGroupBudgetRow: ... + async def find_many( + self, *, include: Mapping[str, object] | None = None + ) -> Sequence[_ModelAccessGroupBudgetRow]: ... + async def delete(self, *, where: Mapping[str, object]) -> _ModelAccessGroupBudgetRow | None: ... @@ -205,6 +213,28 @@ async def _model_access_group_budget_row( return await _model_access_group_budget_table(prisma_client).find_unique(where=where, include=include) +async def _model_access_group_budget_rows( + prisma_client: PrismaClient, +) -> Mapping[str, _ModelAccessGroupBudgetRow]: + """Every group's budget row in one read, so listing groups does not fan out into one query + per group.""" + include: Final[_BudgetInclude] = {"litellm_budget_table": True} + rows: Final = await _model_access_group_budget_table(prisma_client).find_many(include=include) + return MappingProxyType({row.access_group_name: row for row in rows}) + + +def _with_budget(info: AccessGroupInfo, row: _ModelAccessGroupBudgetRow | None) -> AccessGroupInfo: + """The group as listed, plus whatever budget hangs off it. A group with no row has spent + nothing, because clearing a budget drops the row that recorded the spend.""" + return AccessGroupInfo( + access_group=info.access_group, + model_names=info.model_names, + deployment_count=info.deployment_count, + spend=row.spend if row is not None else 0.0, + budget=_budget_or_none(row), + ) + + def _budget_or_none(row: _ModelAccessGroupBudgetRow | None) -> AccessGroupBudget | None: budget: Final = row.litellm_budget_table if row is not None else None if budget is None: @@ -698,7 +728,8 @@ async def list_access_groups( """ List all access groups. - Returns a list of all access groups with their model names and deployment counts. + Returns a list of all access groups with their model names, deployment counts, shared budget + and the spend drawn against it. Example: ```bash @@ -719,11 +750,11 @@ async def list_access_groups( try: access_groups_map: Final = await get_all_access_groups_from_db(prisma_client=prisma_client) + budget_rows: Final = await _model_access_group_budget_rows(prisma_client) - # Sort by access group name access_groups_list: Final = sorted( - access_groups_map.values(), - key=lambda x: x.access_group, + (_with_budget(info, budget_rows.get(info.access_group)) for info in access_groups_map.values()), + key=lambda group: group.access_group, ) return ListAccessGroupsResponse(access_groups=access_groups_list) @@ -780,14 +811,9 @@ async def get_access_group_info( detail={"error": f"Access group '{access_group}' not found"}, ) - info: Final = access_groups_map[access_group] - budget_row: Final = await _model_access_group_budget_row(access_group, prisma_client) - return AccessGroupInfo( - access_group=info.access_group, - model_names=info.model_names, - deployment_count=info.deployment_count, - spend=budget_row.spend if budget_row is not None else 0.0, - budget=_budget_or_none(budget_row), + return _with_budget( + access_groups_map[access_group], + await _model_access_group_budget_row(access_group, prisma_client), ) except HTTPException: diff --git a/litellm/types/proxy/management_endpoints/model_management_endpoints.py b/litellm/types/proxy/management_endpoints/model_management_endpoints.py index 9d4663631fe..8315ac0d4d2 100644 --- a/litellm/types/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/types/proxy/management_endpoints/model_management_endpoints.py @@ -88,7 +88,7 @@ class AccessGroupInfo(BaseModel): access_group: str model_names: list[str] # List of model names in this access group deployment_count: int # Total number of deployments with this access group - spend: float | None = None # Only populated by /access_group/{access_group}/info + spend: float | None = None # Spend drawn against the group's shared budget budget: AccessGroupBudget | None = None diff --git a/tests/test_litellm/proxy/management_endpoints/test_access_group_management.py b/tests/test_litellm/proxy/management_endpoints/test_access_group_management.py index de5fc96c7c3..a43f20da329 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_access_group_management.py +++ b/tests/test_litellm/proxy/management_endpoints/test_access_group_management.py @@ -665,6 +665,10 @@ class _FakeAccessGroupBudgetTable: async def find_unique(self, where, include=None): return self._resolve(self.rows.get(where["access_group_name"]), include) + async def find_many(self, include=None): + self.journal.append("access_group_budget.find_many") + return [self._resolve(row, include) for row in self.rows.values()] + async def upsert(self, where, data, include=None): access_group_name = where["access_group_name"] self.upsert_calls.append(dict(data)) @@ -1126,6 +1130,53 @@ async def test_access_group_info_surfaces_the_budget_and_spend(): assert info.budget.soft_budget == 50.0 +@pytest.mark.asyncio +async def test_list_access_groups_carries_each_group_budget_and_spend(): + """The dashboard renders the budget column straight off the listing, so a group's budget has to + ride along with it rather than needing a follow-up read per row.""" + from litellm.proxy.management_endpoints.model_access_group_management_endpoints import ( + list_access_groups, + ) + + journal: list[str] = [] + prisma = _FakePrismaClient( + journal, + deployments=[ + _deployment(model_id="deploy-1", model_name="gpt-4o", access_groups=("prod-models",)), + _deployment(model_id="deploy-2", model_name="gpt-4o-mini", access_groups=("free-models",)), + ], + ) + _seed_budget(prisma, "prod-models", spend=9.5, max_budget=100.0, budget_duration="30d") + + with _proxy(prisma): + listing = await list_access_groups(user_api_key_dict=_admin()) + + by_name = {group.access_group: group for group in listing.access_groups} + assert [group.access_group for group in listing.access_groups] == ["free-models", "prod-models"] + assert by_name["prod-models"].spend == 9.5 + assert by_name["prod-models"].budget is not None + assert by_name["prod-models"].budget.max_budget == 100.0 + assert by_name["prod-models"].budget.budget_duration == "30d" + assert journal.count("access_group_budget.find_many") == 1 + + +@pytest.mark.asyncio +async def test_list_access_groups_reports_a_budgetless_group_as_unbudgeted_rather_than_omitting_it(): + from litellm.proxy.management_endpoints.model_access_group_management_endpoints import ( + list_access_groups, + ) + + prisma = _FakePrismaClient([], deployments=[_deployment(access_groups=("free-models",))]) + + with _proxy(prisma): + listing = await list_access_groups(user_api_key_dict=_admin()) + + assert len(listing.access_groups) == 1 + assert listing.access_groups[0].access_group == "free-models" + assert listing.access_groups[0].budget is None + assert listing.access_groups[0].spend == 0.0 + + @pytest.mark.asyncio async def test_put_access_group_budget_evicts_both_auth_cache_keys(): """Auth reads the per-group row and the registry of budgeted groups cache-first with no diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useDeleteModelAccessGroupBudget.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useDeleteModelAccessGroupBudget.ts new file mode 100644 index 00000000000..3ed8af15cde --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useDeleteModelAccessGroupBudget.ts @@ -0,0 +1,30 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { fetchClient } from "@/lib/http/api"; +import type { components } from "@/lib/http/schema"; +import { modelAccessGroupKeys } from "./useModelAccessGroups"; + +type DeleteModelAccessGroupBudgetResponse = components["schemas"]["DeleteAccessGroupBudgetResponse"]; + +const deleteModelAccessGroupBudget = async ( + accessGroup: string, +): Promise => { + const { data } = await fetchClient.DELETE("/access_group/{access_group}/budget", { + params: { path: { access_group: accessGroup } }, + }); + return data; +}; + +/** + * Clear a model access group's shared budget. The group and its deployments are untouched, + * and the recorded spend goes with the budget row. + */ +export const useDeleteModelAccessGroupBudget = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deleteModelAccessGroupBudget, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: modelAccessGroupKeys.all }); + }, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useModelAccessGroups.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useModelAccessGroups.ts new file mode 100644 index 00000000000..703c51b75b1 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useModelAccessGroups.ts @@ -0,0 +1,31 @@ +import { useQuery } from "@tanstack/react-query"; +import { createQueryKeys } from "../common/queryKeysFactory"; +import { all_admin_roles } from "@/utils/roles"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { fetchClient } from "@/lib/http/api"; +import type { components } from "@/lib/http/schema"; + +export type ModelAccessGroupBudget = components["schemas"]["AccessGroupBudget"]; +export type ModelAccessGroup = components["schemas"]["AccessGroupInfo"]; + +export const modelAccessGroupKeys = createQueryKeys("modelAccessGroups"); + +const fetchModelAccessGroups = async (): Promise => { + const { data } = await fetchClient.GET("/access_group/list"); + return data?.access_groups ?? []; +}; + +/** + * Model access groups: the free-text labels on a deployment's `model_info.access_groups`, + * with the shared budget each one carries. Unrelated to the `/v1/access_group` table that + * the Access Groups page drives. + */ +export const useModelAccessGroups = () => { + const { accessToken, userRole } = useAuthorized(); + + return useQuery({ + queryKey: modelAccessGroupKeys.list({}), + queryFn: fetchModelAccessGroups, + enabled: Boolean(accessToken) && all_admin_roles.includes(userRole || ""), + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useSetModelAccessGroupBudget.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useSetModelAccessGroupBudget.ts new file mode 100644 index 00000000000..cdea15bedb4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useSetModelAccessGroupBudget.ts @@ -0,0 +1,35 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { fetchClient } from "@/lib/http/api"; +import type { components } from "@/lib/http/schema"; +import { modelAccessGroupKeys } from "./useModelAccessGroups"; + +export type SetModelAccessGroupBudgetParams = components["schemas"]["AccessGroupBudgetRequest"]; +type SetModelAccessGroupBudgetResponse = components["schemas"]["AccessGroupBudgetResponse"]; + +export interface SetModelAccessGroupBudgetVariables { + accessGroup: string; + params: SetModelAccessGroupBudgetParams; +} + +const setModelAccessGroupBudget = async ({ + accessGroup, + params, +}: SetModelAccessGroupBudgetVariables): Promise => { + const { data } = await fetchClient.PUT("/access_group/{access_group}/budget", { + params: { path: { access_group: accessGroup } }, + body: params, + }); + return data; +}; + +/** Set or replace a model access group's shared budget. The write is idempotent. */ +export const useSetModelAccessGroupBudget = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: setModelAccessGroupBudget, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: modelAccessGroupKeys.all }); + }, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetColumns.tsx new file mode 100644 index 00000000000..4401941c2f4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetColumns.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { MoreHorizontal, Trash2, Wallet } from "lucide-react"; + +import { getBudgetDurationLabel } from "@/components/common_components/budget_duration_dropdown"; +import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { ModelsCell, SpendBudgetCell } from "@/components/shared/table_cells"; +import { buttonVariants } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/cva.config"; +import { ModelAccessGroup } from "@/app/(dashboard)/hooks/modelAccessGroups/useModelAccessGroups"; + +const budgetDecimals = (maxBudget: number | null | undefined): number => + maxBudget != null && maxBudget > 0 && maxBudget < 0.01 ? 5 : 2; + +/** + * A group name is a free-text path segment on the budget routes, so a `/` in it splits the path and + * no encoding recovers it. Such a group is listed but its budget is unreachable. + */ +export const isBudgetAddressable = (accessGroup: string): boolean => !accessGroup.includes("/"); + +const writeBlockedReason = (accessGroup: ModelAccessGroup, canWrite: boolean): string | undefined => { + if (!canWrite) return "Only a proxy admin can change an access group budget"; + if (!isBudgetAddressable(accessGroup.access_group)) { + return "A budget cannot be set on a group whose name contains a slash"; + } + return undefined; +}; + +interface AccessGroupRowActionsProps { + accessGroup: ModelAccessGroup; + canWrite: boolean; + onSetBudget: (accessGroup: ModelAccessGroup) => void; + onClearBudget: (accessGroup: ModelAccessGroup) => void; +} + +function AccessGroupRowActions({ accessGroup, canWrite, onSetBudget, onClearBudget }: AccessGroupRowActionsProps) { + const hasBudget = accessGroup.budget != null; + const blocked = writeBlockedReason(accessGroup, canWrite); + + return ( + + + + + + onSetBudget(accessGroup)} + > + + {hasBudget ? "Edit budget" : "Set budget"} + + onClearBudget(accessGroup)} + > + + Clear budget + + + + ); +} + +interface AccessGroupBudgetColumnsDeps { + canWrite: boolean; + onSetBudget: (accessGroup: ModelAccessGroup) => void; + onClearBudget: (accessGroup: ModelAccessGroup) => void; +} + +export const getAccessGroupBudgetColumns = ({ + canWrite, + onSetBudget, + onClearBudget, +}: AccessGroupBudgetColumnsDeps): ColumnDef[] => [ + { + id: "access_group", + accessorKey: "access_group", + meta: { title: "Access Group" }, + header: ({ column }) => , + size: 220, + enableSorting: true, + cell: ({ row }) => ( + + {row.original.access_group} + + ), + }, + { + id: "models", + meta: { title: "Models", skeleton: "chips" }, + header: "Models", + size: 280, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "deployment_count", + accessorKey: "deployment_count", + meta: { title: "Deployments", numeric: true }, + header: ({ column }) => , + size: 120, + enableSorting: true, + cell: ({ row }) => row.original.deployment_count, + }, + { + id: "spend", + accessorKey: "spend", + meta: { title: "Shared Spend" }, + header: ({ column }) => , + size: 180, + enableSorting: true, + cell: ({ row }) => ( + + ), + }, + { + id: "budget_duration", + meta: { title: "Resets" }, + header: "Resets", + size: 110, + enableSorting: false, + cell: ({ row }) => ( + + {getBudgetDurationLabel(row.original.budget?.budget_duration)} + + ), + }, + { + id: "actions", + meta: { className: "text-right", headerClassName: "text-right" }, + header: () => Actions, + size: 64, + enableSorting: false, + enableHiding: false, + cell: ({ row }) => ( +
+ +
+ ), + }, +]; 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 new file mode 100644 index 00000000000..8df2a6620f9 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetModal.tsx @@ -0,0 +1,125 @@ +"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 NumericalInput from "@/components/shared/numerical_input"; +import { Button } from "@/components/ui/button"; +import { useZodForm } from "@/lib/forms/useZodForm"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +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} + + +); + +const budgetSchema = z + .object({ + max_budget: z.string().optional(), + soft_budget: z.string().optional(), + budget_duration: z.string().optional(), + }) + .refine(hasAnyBudgetValue, { + message: "Set at least one of max budget, soft budget or reset window", + path: ["max_budget"], + }); + +interface AccessGroupBudgetModalProps { + accessGroup: ModelAccessGroup | null; + isSaving: boolean; + onCancel: () => void; + onSubmit: (params: SetModelAccessGroupBudgetParams) => void; +} + +const AccessGroupBudgetModal: React.FC = ({ + accessGroup, + isSaving, + onCancel, + onSubmit, +}) => { + const budget = accessGroup?.budget ?? null; + const form = useZodForm(budgetSchema, { values: accessGroupBudgetFormValues(budget) }); + + return ( + !open && onCancel()}> + + + + {budget ? "Edit" : "Set"} budget for "{accessGroup?.access_group}" + + +

+ Every key granted this access group by name draws from this one budget. A key that reaches the group's + models through a wildcard or all-proxy-models is not charged against it. +

+
onSubmit(buildAccessGroupBudgetBody(values)))} noValidate> + + + + {({ ref, value, ...field }) => } + + + + {({ ref, value, ...field }) => } + + + + {({ id, value, onChange }) => ( + + )} + + + +

+ A field left blank keeps whatever the budget already has. Use Clear budget to remove the budget itself. +

+ +
+ + +
+
+
+
+
+ ); +}; + +export default AccessGroupBudgetModal; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/accessGroupBudgetPayload.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/accessGroupBudgetPayload.test.ts new file mode 100644 index 00000000000..ee420fe438d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/accessGroupBudgetPayload.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { accessGroupBudgetFormValues, buildAccessGroupBudgetBody, hasAnyBudgetValue } from "./accessGroupBudgetPayload"; + +describe("accessGroupBudgetFormValues", () => { + it("gives every field an empty string when the group has no budget", () => { + expect(accessGroupBudgetFormValues(null)).toEqual({ + max_budget: "", + soft_budget: "", + budget_duration: "", + }); + }); + + it("fills the form from a stored budget", () => { + expect( + accessGroupBudgetFormValues({ + budget_id: "budget-1", + max_budget: 2.5, + soft_budget: 1, + budget_duration: "30d", + budget_reset_at: null, + }), + ).toEqual({ max_budget: "2.5", soft_budget: "1", budget_duration: "30d" }); + }); + + it("shows a zero max budget rather than treating it as unset", () => { + expect(accessGroupBudgetFormValues({ budget_id: "budget-1", max_budget: 0 }).max_budget).toBe("0"); + }); +}); + +describe("buildAccessGroupBudgetBody", () => { + it("sends numbers, not the strings the inputs hold", () => { + expect(buildAccessGroupBudgetBody({ max_budget: "2.5", soft_budget: "1", budget_duration: "30d" })).toEqual({ + max_budget: 2.5, + soft_budget: 1, + budget_duration: "30d", + }); + }); + + it("leaves a blank field out entirely, because the proxy ignores an explicit null", () => { + const body = buildAccessGroupBudgetBody({ max_budget: "10", soft_budget: "", budget_duration: "" }); + + expect(body).toEqual({ max_budget: 10 }); + expect(body).not.toHaveProperty("soft_budget"); + expect(body).not.toHaveProperty("budget_duration"); + }); + + it("sends a reset window on its own", () => { + expect(buildAccessGroupBudgetBody({ budget_duration: "7d" })).toEqual({ budget_duration: "7d" }); + }); +}); + +describe("hasAnyBudgetValue", () => { + it("rejects a form where every field is blank, which the proxy answers with a 400", () => { + expect(hasAnyBudgetValue({ max_budget: "", soft_budget: "", budget_duration: "" })).toBe(false); + expect(hasAnyBudgetValue({})).toBe(false); + }); + + it("accepts a form with any one field filled", () => { + expect(hasAnyBudgetValue({ soft_budget: "1" })).toBe(true); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/accessGroupBudgetPayload.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/accessGroupBudgetPayload.ts new file mode 100644 index 00000000000..0d7cc201144 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/accessGroupBudgetPayload.ts @@ -0,0 +1,29 @@ +import type { ModelAccessGroupBudget } from "@/app/(dashboard)/hooks/modelAccessGroups/useModelAccessGroups"; +import type { SetModelAccessGroupBudgetParams } from "@/app/(dashboard)/hooks/modelAccessGroups/useSetModelAccessGroupBudget"; + +export interface AccessGroupBudgetFormValues { + max_budget?: string; + soft_budget?: string; + budget_duration?: string; +} + +export const accessGroupBudgetFormValues = ( + budget: ModelAccessGroupBudget | null | undefined, +): Required => ({ + max_budget: budget?.max_budget != null ? String(budget.max_budget) : "", + soft_budget: budget?.soft_budget != null ? String(budget.soft_budget) : "", + budget_duration: budget?.budget_duration ?? "", +}); + +/** + * Blank fields are left out rather than sent as null: the proxy drops nulls when merging a + * budget update, so sending one would look like a clear and silently change nothing. + */ +export const buildAccessGroupBudgetBody = (values: AccessGroupBudgetFormValues): SetModelAccessGroupBudgetParams => ({ + ...(values.max_budget ? { max_budget: Number(values.max_budget) } : {}), + ...(values.soft_budget ? { soft_budget: Number(values.soft_budget) } : {}), + ...(values.budget_duration ? { budget_duration: values.budget_duration } : {}), +}); + +export const hasAnyBudgetValue = (values: AccessGroupBudgetFormValues): boolean => + Object.keys(buildAccessGroupBudgetBody(values)).length > 0; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx index 94737d88d0f..9ae7dc12f81 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx @@ -22,6 +22,7 @@ import PassThroughPanel from "@/app/(dashboard)/models-and-endpoints/panels/Pass import HealthStatusPanel from "@/app/(dashboard)/models-and-endpoints/panels/HealthStatusPanel"; import ModelRetrySettingsPanel from "@/app/(dashboard)/models-and-endpoints/panels/ModelRetrySettingsPanel"; import ModelGroupAliasPanel from "@/app/(dashboard)/models-and-endpoints/panels/ModelGroupAliasPanel"; +import AccessGroupBudgetsPanel from "@/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel"; import PriceDataPanel from "@/app/(dashboard)/models-and-endpoints/panels/PriceDataPanel"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -34,6 +35,7 @@ type ModelTabSlug = | "health" | "retry-settings" | "model-group-alias" + | "access-group-budgets" | "price-data"; const BASE_TAB_KEY = "all-models"; @@ -46,6 +48,7 @@ const TAB_LABELS: Record = { health: "Health Status", "retry-settings": "Model Retry Settings", "model-group-alias": "Model Group Alias", + "access-group-budgets": "Model Access Group Budgets", "price-data": "Price Data Reload", }; @@ -67,6 +70,8 @@ const renderPanel = (key: string) => { return ; case "model-group-alias": return ; + case "access-group-budgets": + return ; case "price-data": return ; default: @@ -102,7 +107,15 @@ export default function ModelsAndEndpointsPage() { ...(canCreate ? (["add"] as const) : []), ...(isAdmin || canCreate ? (["auto-routers"] as const) : []), ...(isAdmin - ? (["llm-credentials", "pass-through", "health", "retry-settings", "model-group-alias", "price-data"] as const) + ? ([ + "llm-credentials", + "pass-through", + "health", + "retry-settings", + "model-group-alias", + "access-group-budgets", + "price-data", + ] as const) : []), ], [canCreate, isAdmin], @@ -111,7 +124,7 @@ export default function ModelsAndEndpointsPage() { const allModelsLabel = isAdmin ? "All Models" : "Your Models"; const tabLabel = (slug: "" | ModelTabSlug): React.ReactNode => { if (!slug) return allModelsLabel; - if (slug === "auto-routers") { + if (slug === "auto-routers" || slug === "access-group-budgets") { return ( {TAB_LABELS[slug]} 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 new file mode 100644 index 00000000000..4d0818987ce --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.integration.test.tsx @@ -0,0 +1,165 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } 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(() => ({ + GET: vi.fn(), + PUT: vi.fn(), + DELETE: vi.fn(), + userRole: { current: "Admin" }, +})); +vi.mock("@/lib/http/api", () => ({ fetchClient: { GET, PUT, DELETE } })); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ accessToken: "sk-test", userRole: userRole.current }), +})); + +import AccessGroupBudgetsPanel from "./AccessGroupBudgetsPanel"; + +const BUDGETED_GROUP = { + access_group: "premium", + model_names: ["premium-nano"], + deployment_count: 1, + spend: 1.25, + budget: { + budget_id: "budget-1", + max_budget: 2.5, + soft_budget: null, + budget_duration: "30d", + budget_reset_at: null, + }, +}; + +const FREE_GROUP = { + access_group: "shared", + model_names: ["shared-nano"], + deployment_count: 2, + spend: 0, + budget: null, +}; + +const renderPanel = () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +}; + +const openActions = async (accessGroup: string) => { + await userEvent.click(await screen.findByTestId(`access-group-actions-${accessGroup}`)); +}; + +describe("AccessGroupBudgetsPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + userRole.current = "Admin"; + 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" } }); + }); + + it("lists each group with the spend drawn against its shared budget", async () => { + renderPanel(); + + expect(await screen.findByText("premium")).toBeInTheDocument(); + expect(screen.getByText("$1.2500")).toBeInTheDocument(); + expect(screen.getByText("of $2.50")).toBeInTheDocument(); + expect(screen.getByText("monthly")).toBeInTheDocument(); + expect(GET).toHaveBeenCalledWith("/access_group/list"); + }); + + it("keeps a sub-cent budget readable instead of rounding it away to $0.00", async () => { + GET.mockResolvedValue({ + data: { access_groups: [{ ...BUDGETED_GROUP, budget: { ...BUDGETED_GROUP.budget, max_budget: 0.00002 } }] }, + }); + renderPanel(); + + expect(await screen.findByText("of $0.00002")).toBeInTheDocument(); + }); + + it("shows a group with no budget as unlimited and offers nothing to clear", async () => { + renderPanel(); + + expect(await screen.findByText("· Unlimited")).toBeInTheDocument(); + + await openActions("shared"); + + expect(await screen.findByText("Set budget")).toBeInTheDocument(); + expect(screen.getByTestId("access-group-action-clear-budget")).toHaveAttribute("aria-disabled", "true"); + }); + + it("sends the filled fields to the group's budget route", async () => { + renderPanel(); + await openActions("shared"); + await userEvent.click(await screen.findByText("Set budget")); + + fireEvent.change(await screen.findByLabelText(/Max Budget/), { target: { value: "12.5" } }); + await userEvent.click(screen.getByRole("button", { name: "Save Budget" })); + + await waitFor(() => + expect(PUT).toHaveBeenCalledWith("/access_group/{access_group}/budget", { + params: { path: { access_group: "shared" } }, + body: { max_budget: 12.5 }, + }), + ); + }); + + it("pre-fills the modal from the budget the group already has", async () => { + renderPanel(); + await openActions("premium"); + await userEvent.click(await screen.findByText("Edit budget")); + + expect(await screen.findByLabelText(/Max Budget/)).toHaveValue(2.5); + }); + + it("refuses to save a budget with every field blank", async () => { + renderPanel(); + await openActions("shared"); + await userEvent.click(await screen.findByText("Set budget")); + await userEvent.click(await screen.findByRole("button", { name: "Save Budget" })); + + expect(await screen.findByText(/Set at least one of max budget/)).toBeInTheDocument(); + expect(PUT).not.toHaveBeenCalled(); + }); + + it("offers an admin viewer no way to start a write the proxy would reject with a 403", async () => { + userRole.current = "Admin Viewer"; + renderPanel(); + + expect(await screen.findByText("premium")).toBeInTheDocument(); + + await openActions("premium"); + + expect(await screen.findByTestId("access-group-action-set-budget")).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByTestId("access-group-action-clear-budget")).toHaveAttribute("aria-disabled", "true"); + }); + + it("does not offer a budget on a group whose name a path segment cannot carry", async () => { + GET.mockResolvedValue({ data: { access_groups: [{ ...FREE_GROUP, access_group: "openai/prod" }] } }); + renderPanel(); + + await openActions("openai/prod"); + + expect(await screen.findByTestId("access-group-action-set-budget")).toHaveAttribute("aria-disabled", "true"); + }); + + it("clears a budget only after the confirmation is accepted", async () => { + renderPanel(); + await openActions("premium"); + await userEvent.click(await screen.findByRole("menuitem", { name: /clear budget/i })); + + expect(DELETE).not.toHaveBeenCalled(); + + await userEvent.click(await screen.findByRole("button", { name: /^delete$/i })); + + await waitFor(() => + expect(DELETE).toHaveBeenCalledWith("/access_group/{access_group}/budget", { + params: { path: { access_group: "premium" } }, + }), + ); + }); +}); 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 new file mode 100644 index 00000000000..ee8baab81ab --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { SortingState } from "@tanstack/react-table"; +import { Inbox } from "lucide-react"; +import React, { useMemo, useState } from "react"; + +import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; +import { DataTable } from "@/components/shared/DataTable"; +import { toast } from "@/lib/toast"; +import { isProxyAdminRole } from "@/utils/roles"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { ModelAccessGroup, useModelAccessGroups } from "@/app/(dashboard)/hooks/modelAccessGroups/useModelAccessGroups"; +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 { getAccessGroupBudgetColumns } from "@/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetColumns"; + +const DEFAULT_SORTING: SortingState = [{ id: "access_group", desc: false }]; + +function EmptyState() { + return ( +
+
+ +
+
No model access groups yet
+
+ Put a deployment in an access group from its model settings, then give the group a shared budget here. +
+
+ ); +} + +export default function AccessGroupBudgetsPanel() { + const { userRole } = useAuthorized(); + const { data: accessGroups, isLoading } = useModelAccessGroups(); + const setBudget = useSetModelAccessGroupBudget(); + const clearBudget = useDeleteModelAccessGroupBudget(); + + const [sorting, setSorting] = useState(DEFAULT_SORTING); + const [editing, setEditing] = useState(null); + const [clearing, setClearing] = useState(null); + + const canWrite = isProxyAdminRole(userRole ?? ""); + const columns = useMemo( + () => getAccessGroupBudgetColumns({ canWrite, onSetBudget: setEditing, onClearBudget: setClearing }), + [canWrite], + ); + + const handleSubmit = (params: SetModelAccessGroupBudgetParams) => { + if (!editing) return; + const accessGroup = editing.access_group; + setBudget.mutate( + { accessGroup, params }, + { + onSuccess: () => { + toast.success(`Budget saved for "${accessGroup}"`); + setEditing(null); + }, + }, + ); + }; + + const handleConfirmClear = () => { + if (!clearing) return; + const accessGroup = clearing.access_group; + clearBudget.mutate(accessGroup, { + onSuccess: () => { + toast.success(`Budget cleared for "${accessGroup}"`); + setClearing(null); + }, + }); + }; + + 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. +

+ + group.access_group} + sortingMode="client" + sorting={sorting} + onSortingChange={setSorting} + isLoading={isLoading} + loadingMessage="Loading model access groups…" + noDataMessage={} + size="compact" + /> + + setEditing(null)} + onSubmit={handleSubmit} + /> + + setClearing(null)} + onOk={handleConfirmClear} + confirmLoading={clearBudget.isPending} + /> +
+ ); +} diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 993405928f2..137c67e837c 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -432,7 +432,8 @@ export interface paths { * List Access Groups * @description List all access groups. * - * Returns a list of all access groups with their model names and deployment counts. + * Returns a list of all access groups with their model names, deployment counts, shared budget + * and the spend drawn against it. * * Example: * ```bash