From 0777e378491132c55a457a52b909558b955afd72 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 29 Aug 2026 16:32:28 -0700 Subject: [PATCH 1/7] feat(ui): set a model access group's shared budget from the dashboard Model access group budgets shipped API-only, so the only way to give a group a budget was a curl. Adds an Access Group Budgets tab under Models & Endpoints that lists every group with the spend drawn against its shared pool, and a modal to set, edit or clear the budget. /access_group/list now carries each group's budget and spend inline, so the table renders from one read instead of one follow-up request per row. --- ...model_access_group_management_endpoints.py | 50 +++++-- .../model_management_endpoints.py | 2 +- .../test_access_group_management.py | 51 +++++++ .../useDeleteModelAccessGroupBudget.ts | 36 +++++ .../modelAccessGroups/useModelAccessGroups.ts | 39 ++++++ .../useSetModelAccessGroupBudget.ts | 41 ++++++ .../components/AccessGroupBudgetColumns.tsx | 132 ++++++++++++++++++ .../components/AccessGroupBudgetModal.tsx | 125 +++++++++++++++++ .../accessGroupBudgetPayload.test.ts | 62 ++++++++ .../components/accessGroupBudgetPayload.ts | 29 ++++ .../(dashboard)/models-and-endpoints/page.tsx | 15 +- ...cessGroupBudgetsPanel.integration.test.tsx | 129 +++++++++++++++++ .../panels/AccessGroupBudgetsPanel.tsx | 116 +++++++++++++++ 13 files changed, 813 insertions(+), 14 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useDeleteModelAccessGroupBudget.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useModelAccessGroups.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useSetModelAccessGroupBudget.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetColumns.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetModal.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/accessGroupBudgetPayload.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/accessGroupBudgetPayload.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.tsx 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..03ca5d6ff22 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useDeleteModelAccessGroupBudget.ts @@ -0,0 +1,36 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { fetchClient } from "@/lib/http/api"; +import type { components } from "@/lib/http/schema"; +import { modelAccessGroupKeys } from "./useModelAccessGroups"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +type DeleteModelAccessGroupBudgetResponse = components["schemas"]["DeleteAccessGroupBudgetResponse"]; + +// ── Fetch function ─────────────────────────────────────────────────────────── + +const deleteModelAccessGroupBudget = async ( + accessGroup: string, +): Promise => { + const { data } = await fetchClient.DELETE("/access_group/{access_group}/budget", { + params: { path: { access_group: accessGroup } }, + }); + return data; +}; + +// ── Hook ───────────────────────────────────────────────────────────────────── + +/** + * 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..c8dbec94aab --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useModelAccessGroups.ts @@ -0,0 +1,39 @@ +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"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export type ModelAccessGroupBudget = components["schemas"]["AccessGroupBudget"]; +export type ModelAccessGroup = components["schemas"]["AccessGroupInfo"]; + +// ── Query keys (shared across model-access-group hooks) ────────────────────── + +export const modelAccessGroupKeys = createQueryKeys("modelAccessGroups"); + +// ── Fetch function ─────────────────────────────────────────────────────────── + +const fetchModelAccessGroups = async (): Promise => { + const { data } = await fetchClient.GET("/access_group/list"); + return data?.access_groups ?? []; +}; + +// ── Hook ───────────────────────────────────────────────────────────────────── + +/** + * 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..d57571e1a05 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useSetModelAccessGroupBudget.ts @@ -0,0 +1,41 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { fetchClient } from "@/lib/http/api"; +import type { components } from "@/lib/http/schema"; +import { modelAccessGroupKeys } from "./useModelAccessGroups"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export type SetModelAccessGroupBudgetParams = components["schemas"]["AccessGroupBudgetRequest"]; +type SetModelAccessGroupBudgetResponse = components["schemas"]["AccessGroupBudgetResponse"]; + +export interface SetModelAccessGroupBudgetVariables { + accessGroup: string; + params: SetModelAccessGroupBudgetParams; +} + +// ── Fetch function ─────────────────────────────────────────────────────────── + +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; +}; + +// ── Hook ───────────────────────────────────────────────────────────────────── + +/** 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..5dd6bcacbe1 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetColumns.tsx @@ -0,0 +1,132 @@ +"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"; + +interface AccessGroupRowActionsProps { + accessGroup: ModelAccessGroup; + onSetBudget: (accessGroup: ModelAccessGroup) => void; + onClearBudget: (accessGroup: ModelAccessGroup) => void; +} + +function AccessGroupRowActions({ accessGroup, onSetBudget, onClearBudget }: AccessGroupRowActionsProps) { + const hasBudget = accessGroup.budget != null; + + return ( + + + + + + onSetBudget(accessGroup)}> + + {hasBudget ? "Edit budget" : "Set budget"} + + onClearBudget(accessGroup)} + > + + Clear budget + + + + ); +} + +interface AccessGroupBudgetColumnsDeps { + onSetBudget: (accessGroup: ModelAccessGroup) => void; + onClearBudget: (accessGroup: ModelAccessGroup) => void; +} + +export const getAccessGroupBudgetColumns = ({ + 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..4f8c05bfa0d 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": "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], 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..96b710a7a26 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.integration.test.tsx @@ -0,0 +1,129 @@ +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 } = vi.hoisted(() => ({ GET: vi.fn(), PUT: vi.fn(), DELETE: vi.fn() })); +vi.mock("@/lib/http/api", () => ({ fetchClient: { GET, PUT, DELETE } })); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ accessToken: "sk-test", userRole: "Admin" }), +})); + +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(); + 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("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("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..3e1e6924bb8 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.tsx @@ -0,0 +1,116 @@ +"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 { 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 { 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 columns = useMemo( + () => getAccessGroupBudgetColumns({ onSetBudget: setEditing, onClearBudget: setClearing }), + [], + ); + + 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} + /> +
+ ); +} From a5cd3fae81357edd1463f49795c75b8202ce348e Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 29 Aug 2026 16:38:58 -0700 Subject: [PATCH 2/7] fix(ui): name the tab Model Access Group Budgets and keep sub-cent budgets readable Marks the tab New, and stops a budget under a cent rendering as "of $0.00" next to an over-budget meter. --- .../components/AccessGroupBudgetColumns.tsx | 9 ++++++++- .../src/app/(dashboard)/models-and-endpoints/page.tsx | 10 +++++++++- .../AccessGroupBudgetsPanel.integration.test.tsx | 9 +++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) 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 index 5dd6bcacbe1..c51c4ca1490 100644 --- 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 @@ -16,6 +16,9 @@ import { 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; + interface AccessGroupRowActionsProps { accessGroup: ModelAccessGroup; onSetBudget: (accessGroup: ModelAccessGroup) => void; @@ -101,7 +104,11 @@ export const getAccessGroupBudgetColumns = ({ size: 180, enableSorting: true, cell: ({ row }) => ( - + ), }, { 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 4f8c05bfa0d..5594bd6211d 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 @@ -9,6 +9,7 @@ import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings" import { all_admin_roles, internalUserRoles } from "@/utils/roles"; import { canCreateModels } from "@/utils/modelPermissions"; import BetaBadge from "@/components/BetaBadge"; +import NewBadge from "@/components/common_components/NewBadge"; import CostOptimizationFeedbackBanner from "@/components/molecules/cost_optimization_feedback_banner"; import ModelInfoView from "@/components/model_info_view"; import TeamInfoView from "@/components/team/TeamInfo"; @@ -48,7 +49,7 @@ const TAB_LABELS: Record = { health: "Health Status", "retry-settings": "Model Retry Settings", "model-group-alias": "Model Group Alias", - "access-group-budgets": "Access Group Budgets", + "access-group-budgets": "Model Access Group Budgets", "price-data": "Price Data Reload", }; @@ -131,6 +132,13 @@ export default function ModelsAndEndpointsPage() { ); } + if (slug === "access-group-budgets") { + return ( + + {TAB_LABELS[slug]} + + ); + } 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 index 96b710a7a26..1342e85b669 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 @@ -66,6 +66,15 @@ describe("AccessGroupBudgetsPanel", () => { 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(); From 7c0e58ed06f4506f719ed34c2c8583378581c201 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 29 Aug 2026 16:45:48 -0700 Subject: [PATCH 3/7] fix(ui): stop offering access group budget writes that the proxy refuses An Admin Viewer and a group whose name contains a slash both reached an enabled Set budget action that could only ever come back 403 or 404. Gate the row actions on proxy admin and on the name being addressable, with the reason in the tooltip. --- .../components/AccessGroupBudgetColumns.tsx | 38 ++++++++++++++++--- ...cessGroupBudgetsPanel.integration.test.tsx | 31 ++++++++++++++- .../panels/AccessGroupBudgetsPanel.tsx | 8 +++- 3 files changed, 68 insertions(+), 9 deletions(-) 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 index c51c4ca1490..4401941c2f4 100644 --- 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 @@ -19,14 +19,30 @@ import { ModelAccessGroup } from "@/app/(dashboard)/hooks/modelAccessGroups/useM 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, onSetBudget, onClearBudget }: AccessGroupRowActionsProps) { +function AccessGroupRowActions({ accessGroup, canWrite, onSetBudget, onClearBudget }: AccessGroupRowActionsProps) { const hasBudget = accessGroup.budget != null; + const blocked = writeBlockedReason(accessGroup, canWrite); return ( @@ -38,15 +54,20 @@ function AccessGroupRowActions({ accessGroup, onSetBudget, onClearBudget }: Acce - onSetBudget(accessGroup)}> + onSetBudget(accessGroup)} + > {hasBudget ? "Edit budget" : "Set budget"} onClearBudget(accessGroup)} > @@ -58,11 +79,13 @@ function AccessGroupRowActions({ accessGroup, onSetBudget, onClearBudget }: Acce } interface AccessGroupBudgetColumnsDeps { + canWrite: boolean; onSetBudget: (accessGroup: ModelAccessGroup) => void; onClearBudget: (accessGroup: ModelAccessGroup) => void; } export const getAccessGroupBudgetColumns = ({ + canWrite, onSetBudget, onClearBudget, }: AccessGroupBudgetColumnsDeps): ColumnDef[] => [ @@ -132,7 +155,12 @@ export const getAccessGroupBudgetColumns = ({ enableHiding: false, cell: ({ row }) => (
- +
), }, 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 1342e85b669..4d0818987ce 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 @@ -4,11 +4,16 @@ import userEvent from "@testing-library/user-event"; import React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const { GET, PUT, DELETE } = vi.hoisted(() => ({ GET: vi.fn(), PUT: vi.fn(), DELETE: vi.fn() })); +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: "Admin" }), + default: () => ({ accessToken: "sk-test", userRole: userRole.current }), })); import AccessGroupBudgetsPanel from "./AccessGroupBudgetsPanel"; @@ -51,6 +56,7 @@ const openActions = async (accessGroup: string) => { 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" } }); @@ -120,6 +126,27 @@ describe("AccessGroupBudgetsPanel", () => { 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"); 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 3e1e6924bb8..ee8baab81ab 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 @@ -7,6 +7,8 @@ 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 { @@ -33,6 +35,7 @@ function EmptyState() { } export default function AccessGroupBudgetsPanel() { + const { userRole } = useAuthorized(); const { data: accessGroups, isLoading } = useModelAccessGroups(); const setBudget = useSetModelAccessGroupBudget(); const clearBudget = useDeleteModelAccessGroupBudget(); @@ -41,9 +44,10 @@ export default function AccessGroupBudgetsPanel() { const [editing, setEditing] = useState(null); const [clearing, setClearing] = useState(null); + const canWrite = isProxyAdminRole(userRole ?? ""); const columns = useMemo( - () => getAccessGroupBudgetColumns({ onSetBudget: setEditing, onClearBudget: setClearing }), - [], + () => getAccessGroupBudgetColumns({ canWrite, onSetBudget: setEditing, onClearBudget: setClearing }), + [canWrite], ); const handleSubmit = (params: SetModelAccessGroupBudgetParams) => { From 243f8317f4363c952c06d16a3863d3979fb4aeae Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 29 Aug 2026 16:47:50 -0700 Subject: [PATCH 4/7] style(ui): drop decorative section banners from the access group budget hooks --- .../modelAccessGroups/useDeleteModelAccessGroupBudget.ts | 6 ------ .../hooks/modelAccessGroups/useModelAccessGroups.ts | 8 -------- .../modelAccessGroups/useSetModelAccessGroupBudget.ts | 6 ------ 3 files changed, 20 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useDeleteModelAccessGroupBudget.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useDeleteModelAccessGroupBudget.ts index 03ca5d6ff22..3ed8af15cde 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useDeleteModelAccessGroupBudget.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useDeleteModelAccessGroupBudget.ts @@ -3,12 +3,8 @@ import { fetchClient } from "@/lib/http/api"; import type { components } from "@/lib/http/schema"; import { modelAccessGroupKeys } from "./useModelAccessGroups"; -// ── Types ──────────────────────────────────────────────────────────────────── - type DeleteModelAccessGroupBudgetResponse = components["schemas"]["DeleteAccessGroupBudgetResponse"]; -// ── Fetch function ─────────────────────────────────────────────────────────── - const deleteModelAccessGroupBudget = async ( accessGroup: string, ): Promise => { @@ -18,8 +14,6 @@ const deleteModelAccessGroupBudget = async ( return data; }; -// ── Hook ───────────────────────────────────────────────────────────────────── - /** * Clear a model access group's shared budget. The group and its deployments are untouched, * and the recorded spend goes with the budget row. diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useModelAccessGroups.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useModelAccessGroups.ts index c8dbec94aab..703c51b75b1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useModelAccessGroups.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useModelAccessGroups.ts @@ -5,24 +5,16 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { fetchClient } from "@/lib/http/api"; import type { components } from "@/lib/http/schema"; -// ── Types ──────────────────────────────────────────────────────────────────── - export type ModelAccessGroupBudget = components["schemas"]["AccessGroupBudget"]; export type ModelAccessGroup = components["schemas"]["AccessGroupInfo"]; -// ── Query keys (shared across model-access-group hooks) ────────────────────── - export const modelAccessGroupKeys = createQueryKeys("modelAccessGroups"); -// ── Fetch function ─────────────────────────────────────────────────────────── - const fetchModelAccessGroups = async (): Promise => { const { data } = await fetchClient.GET("/access_group/list"); return data?.access_groups ?? []; }; -// ── Hook ───────────────────────────────────────────────────────────────────── - /** * 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 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useSetModelAccessGroupBudget.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useSetModelAccessGroupBudget.ts index d57571e1a05..cdea15bedb4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useSetModelAccessGroupBudget.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/modelAccessGroups/useSetModelAccessGroupBudget.ts @@ -3,8 +3,6 @@ import { fetchClient } from "@/lib/http/api"; import type { components } from "@/lib/http/schema"; import { modelAccessGroupKeys } from "./useModelAccessGroups"; -// ── Types ──────────────────────────────────────────────────────────────────── - export type SetModelAccessGroupBudgetParams = components["schemas"]["AccessGroupBudgetRequest"]; type SetModelAccessGroupBudgetResponse = components["schemas"]["AccessGroupBudgetResponse"]; @@ -13,8 +11,6 @@ export interface SetModelAccessGroupBudgetVariables { params: SetModelAccessGroupBudgetParams; } -// ── Fetch function ─────────────────────────────────────────────────────────── - const setModelAccessGroupBudget = async ({ accessGroup, params, @@ -26,8 +22,6 @@ const setModelAccessGroupBudget = async ({ return data; }; -// ── Hook ───────────────────────────────────────────────────────────────────── - /** Set or replace a model access group's shared budget. The write is idempotent. */ export const useSetModelAccessGroupBudget = () => { const queryClient = useQueryClient(); From 8530f80c782432234ec0c2e8e57473a2b74f3687 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 29 Aug 2026 17:05:39 -0700 Subject: [PATCH 5/7] chore(ui): sync schema.d.ts with the access group list docstring --- ui/litellm-dashboard/src/lib/http/schema.d.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index c762183dec4..dc6fe6f50cd 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 From 934edbab1fa4c57cd72c4a0cf771171fb8512467 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 29 Aug 2026 17:08:10 -0700 Subject: [PATCH 6/7] chore: sync the lazy OpenAPI snapshot with the access group list docstring --- litellm/proxy/_lazy_openapi_snapshot.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 1d1b7f057cb..8dc6fc49adf 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": { From 797a4dcbfe21fcea21270cf025ed89006dab42d6 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 29 Aug 2026 17:10:20 -0700 Subject: [PATCH 7/7] fix(ui): badge the Model Access Group Budgets tab as Beta rather than New --- .../src/app/(dashboard)/models-and-endpoints/page.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) 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 5594bd6211d..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 @@ -9,7 +9,6 @@ import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings" import { all_admin_roles, internalUserRoles } from "@/utils/roles"; import { canCreateModels } from "@/utils/modelPermissions"; import BetaBadge from "@/components/BetaBadge"; -import NewBadge from "@/components/common_components/NewBadge"; import CostOptimizationFeedbackBanner from "@/components/molecules/cost_optimization_feedback_banner"; import ModelInfoView from "@/components/model_info_view"; import TeamInfoView from "@/components/team/TeamInfo"; @@ -125,20 +124,13 @@ 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]} ); } - if (slug === "access-group-budgets") { - return ( - - {TAB_LABELS[slug]} - - ); - } return TAB_LABELS[slug]; };