mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
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:
commit
9da0b30888
15 changed files with 872 additions and 17 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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 || ""),
|
||||
});
|
||||
};
|
||||
|
|
@ -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 });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
@ -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 "{accessGroup?.access_group}"
|
||||
</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'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;
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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" } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
3
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
3
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue