Merge pull request #38843 from BerriAI/litellm_mag_budget_ui

feat(ui): set a model access group's shared budget from the dashboard
This commit is contained in:
ryan-crabbe-berri 2026-08-29 17:27:17 -07:00 • committed by GitHub
commit 9da0b30888
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 872 additions and 17 deletions

View file

@ -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": {

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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<DeleteModelAccessGroupBudgetResponse | undefined> => {
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 });
},
});
};

View file

@ -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<ModelAccessGroup[]> => {
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<ModelAccessGroup[]>({
queryKey: modelAccessGroupKeys.list({}),
queryFn: fetchModelAccessGroups,
enabled: Boolean(accessToken) && all_admin_roles.includes(userRole || ""),
});
};

View file

@ -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<SetModelAccessGroupBudgetResponse | undefined> => {
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 });
},
});
};

View file

@ -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 (
<DropdownMenu>
<DropdownMenuTrigger
aria-label={`Open budget actions for ${accessGroup.access_group}`}
data-testid={`access-group-actions-${accessGroup.access_group}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem
disabled={blocked !== undefined}
title={blocked}
data-testid="access-group-action-set-budget"
onClick={() => onSetBudget(accessGroup)}
>
<Wallet />
{hasBudget ? "Edit budget" : "Set budget"}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
disabled={blocked !== undefined || !hasBudget}
data-testid="access-group-action-clear-budget"
title={blocked ?? (hasBudget ? undefined : "This access group has no budget to clear")}
onClick={() => onClearBudget(accessGroup)}
>
<Trash2 />
Clear budget
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
interface AccessGroupBudgetColumnsDeps {
canWrite: boolean;
onSetBudget: (accessGroup: ModelAccessGroup) => void;
onClearBudget: (accessGroup: ModelAccessGroup) => void;
}
export const getAccessGroupBudgetColumns = ({
canWrite,
onSetBudget,
onClearBudget,
}: AccessGroupBudgetColumnsDeps): ColumnDef<ModelAccessGroup>[] => [
{
id: "access_group",
accessorKey: "access_group",
meta: { title: "Access Group" },
header: ({ column }) => <DataTableSortHeader column={column} title="Access Group" />,
size: 220,
enableSorting: true,
cell: ({ row }) => (
<span className="block max-w-56 truncate font-mono text-xs" title={row.original.access_group}>
{row.original.access_group}
</span>
),
},
{
id: "models",
meta: { title: "Models", skeleton: "chips" },
header: "Models",
size: 280,
enableSorting: false,
cell: ({ row }) => <ModelsCell models={row.original.model_names} />,
},
{
id: "deployment_count",
accessorKey: "deployment_count",
meta: { title: "Deployments", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Deployments" />,
size: 120,
enableSorting: true,
cell: ({ row }) => row.original.deployment_count,
},
{
id: "spend",
accessorKey: "spend",
meta: { title: "Shared Spend" },
header: ({ column }) => <DataTableSortHeader column={column} title="Shared Spend" />,
size: 180,
enableSorting: true,
cell: ({ row }) => (
<SpendBudgetCell
spend={row.original.spend}
maxBudget={row.original.budget?.max_budget}
budgetDecimals={budgetDecimals(row.original.budget?.max_budget)}
/>
),
},
{
id: "budget_duration",
meta: { title: "Resets" },
header: "Resets",
size: 110,
enableSorting: false,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{getBudgetDurationLabel(row.original.budget?.budget_duration)}
</span>
),
},
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
size: 64,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<div className="flex justify-end">
<AccessGroupRowActions
accessGroup={row.original}
canWrite={canWrite}
onSetBudget={onSetBudget}
onClearBudget={onClearBudget}
/>
</div>
),
},
];

View file

@ -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}
<Tooltip>
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
<TooltipContent>{hint}</TooltipContent>
</Tooltip>
</>
);
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<AccessGroupBudgetModalProps> = ({
accessGroup,
isSaving,
onCancel,
onSubmit,
}) => {
const budget = accessGroup?.budget ?? null;
const form = useZodForm(budgetSchema, { values: accessGroupBudgetFormValues(budget) });
return (
<Dialog open={accessGroup !== null} onOpenChange={(open) => !open && onCancel()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>
{budget ? "Edit" : "Set"} budget for &quot;{accessGroup?.access_group}&quot;
</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Every key granted this access group by name draws from this one budget. A key that reaches the group&apos;s
models through a wildcard or <code>all-proxy-models</code> is not charged against it.
</p>
<form onSubmit={form.handleSubmit((values) => onSubmit(buildAccessGroupBudgetBody(values)))} noValidate>
<TooltipProvider>
<FieldGroup className="mt-4">
<FormField
control={form.control}
name="max_budget"
label={labelWithHint(
"Max Budget (USD)",
"Total the whole group may spend. Once its shared spend reaches this, every key that draws from the group is refused",
)}
>
{({ ref, value, ...field }) => <NumericalInput {...field} value={value ?? ""} step={0.01} />}
</FormField>
<FormField
control={form.control}
name="soft_budget"
label={labelWithHint(
"Soft Budget (USD)",
"Fires an alert when the group's spend reaches this. Requests keep succeeding",
)}
>
{({ ref, value, ...field }) => <NumericalInput {...field} value={value ?? ""} step={0.01} />}
</FormField>
<FormField
control={form.control}
name="budget_duration"
label={labelWithHint(
"Reset Budget",
"How often the group's spend resets. Leave empty for a budget that never resets",
)}
>
{({ id, value, onChange }) => (
<BudgetDurationDropdown id={id} value={value || null} onChange={onChange} />
)}
</FormField>
</FieldGroup>
<p className="mt-3 text-xs text-muted-foreground">
A field left blank keeps whatever the budget already has. Use Clear budget to remove the budget itself.
</p>
<div className="mt-6 flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? "Saving..." : "Save Budget"}
</Button>
</div>
</TooltipProvider>
</form>
</DialogContent>
</Dialog>
);
};
export default AccessGroupBudgetModal;

View file

@ -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);
});
});

View file

@ -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<AccessGroupBudgetFormValues> => ({
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;

View file

@ -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<ModelTabSlug, string> = {
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 <ModelRetrySettingsPanel />;
case "model-group-alias":
return <ModelGroupAliasPanel />;
case "access-group-budgets":
return <AccessGroupBudgetsPanel />;
case "price-data":
return <PriceDataPanel />;
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 (
<span className="flex items-center gap-2">
{TAB_LABELS[slug]} <BetaBadge />

View file

@ -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(
<QueryClientProvider client={queryClient}>
<AccessGroupBudgetsPanel />
</QueryClientProvider>,
);
};
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" } },
}),
);
});
});

View file

@ -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 (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<Inbox className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">No model access groups yet</div>
<div className="text-sm text-muted-foreground">
Put a deployment in an access group from its model settings, then give the group a shared budget here.
</div>
</div>
);
}
export default function AccessGroupBudgetsPanel() {
const { userRole } = useAuthorized();
const { data: accessGroups, isLoading } = useModelAccessGroups();
const setBudget = useSetModelAccessGroupBudget();
const clearBudget = useDeleteModelAccessGroupBudget();
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const [editing, setEditing] = useState<ModelAccessGroup | null>(null);
const [clearing, setClearing] = useState<ModelAccessGroup | null>(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 (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
A model access group can carry one budget that every key granted the group by name draws from together. Keys
that reach the group&apos;s models through a wildcard or all-proxy-models are not charged against it.
</p>
<DataTable
data={accessGroups ?? []}
columns={columns}
getRowId={(group) => group.access_group}
sortingMode="client"
sorting={sorting}
onSortingChange={setSorting}
isLoading={isLoading}
loadingMessage="Loading model access groups…"
noDataMessage={<EmptyState />}
size="compact"
/>
<AccessGroupBudgetModal
accessGroup={editing}
isSaving={setBudget.isPending}
onCancel={() => setEditing(null)}
onSubmit={handleSubmit}
/>
<DeleteResourceModal
isOpen={clearing !== null}
title="Clear Budget"
message="Are you sure you want to clear this access group's budget? The recorded shared spend is cleared with it, and the group's models stay available."
resourceInformationTitle="Access Group"
resourceInformation={[
{ label: "Access Group", value: clearing?.access_group ?? null, code: true },
{ label: "Max Budget", value: clearing?.budget?.max_budget?.toString() ?? null },
]}
onCancel={() => setClearing(null)}
onOk={handleConfirmClear}
confirmLoading={clearBudget.isPending}
/>
</div>
);
}

View file

@ -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