mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
feat(ui): create a model access group from the budgets tab
The Model Access Group Budgets tab could only set budgets on groups that already existed, and a group only exists once some deployment carries the label in its model settings. Add a Create Access Group button that opens a modal taking a name plus the models to tag, posting to the existing /access_group/new route. The picker lists only models with a database deployment since the proxy refuses config.yaml ones, and the form rejects duplicate names and names with a "/" before any request is sent Claude-Session: https://claude.ai/code/session_01Q5sbiogJzPcCRmYSbaHxZf
This commit is contained in:
parent
ab0478f068
commit
5be96fb71a
10 changed files with 377 additions and 23 deletions
|
|
@ -0,0 +1,31 @@
|
|||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchClient } from "@/lib/http/api";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
import { modelKeys } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import { modelAccessGroupKeys } from "./useModelAccessGroups";
|
||||
|
||||
export type CreateModelAccessGroupParams = components["schemas"]["NewModelGroupRequest"];
|
||||
type CreateModelAccessGroupResponse = components["schemas"]["NewModelGroupResponse"];
|
||||
|
||||
const createModelAccessGroup = async (
|
||||
params: CreateModelAccessGroupParams,
|
||||
): Promise<CreateModelAccessGroupResponse | undefined> => {
|
||||
const { data } = await fetchClient.POST("/access_group/new", { body: params });
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a model access group by tagging every database deployment of the given model names with it.
|
||||
* The proxy refuses a name that already exists and any model that only lives in config.yaml.
|
||||
*/
|
||||
export const useCreateModelAccessGroup = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: createModelAccessGroup,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: modelAccessGroupKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: modelKeys.lists() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import {
|
||||
isAutoRouterDeployment,
|
||||
selectAutoRouterModelGroups,
|
||||
selectDatabaseModelGroups,
|
||||
selectPlainModelGroups,
|
||||
useAllProxyModels,
|
||||
useAutoRouterModelGroups,
|
||||
|
|
@ -1136,3 +1137,18 @@ describe("useAutoRouterModelGroups", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectDatabaseModelGroups", () => {
|
||||
it("keeps only names with a database deployment, deduplicated and sorted", () => {
|
||||
expect(
|
||||
selectDatabaseModelGroups([
|
||||
{ model_name: "zeta", model_info: { db_model: true } },
|
||||
{ model_name: "alpha", model_info: { db_model: true } },
|
||||
{ model_name: "alpha", model_info: { db_model: true } },
|
||||
{ model_name: "config-only", model_info: { db_model: false } },
|
||||
{ model_name: "untagged", model_info: {} },
|
||||
{ model_name: null, model_info: { db_model: true } },
|
||||
]),
|
||||
).toEqual(["alpha", "zeta"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export interface PaginatedModelInfoResponse {
|
|||
size: number;
|
||||
}
|
||||
|
||||
const modelKeys = createQueryKeys("models");
|
||||
export const modelKeys = createQueryKeys("models");
|
||||
const modelHubKeys = createQueryKeys("modelHub");
|
||||
const allProxyModelsKeys = createQueryKeys("allProxyModels");
|
||||
const selectedTeamModelsKeys = createQueryKeys("selectedTeamModels");
|
||||
|
|
@ -196,6 +196,30 @@ export const usePlainModelGroups = (): ReadonlySet<string> => {
|
|||
return data ?? NO_AUTO_ROUTERS;
|
||||
};
|
||||
|
||||
const NO_MODEL_GROUPS: readonly string[] = [];
|
||||
|
||||
/** Public model names with at least one database deployment, which is what /access_group/new can tag. */
|
||||
export const selectDatabaseModelGroups = (deployments: AutoRouterDeployment[]): string[] =>
|
||||
Array.from(
|
||||
new Set(
|
||||
deployments
|
||||
.filter((deployment) => deployment.model_info?.db_model === true)
|
||||
.map((deployment) => deployment.model_name)
|
||||
.filter((modelName): modelName is string => Boolean(modelName)),
|
||||
),
|
||||
).sort();
|
||||
|
||||
export const useDatabaseModelGroups = (): { data: readonly string[]; isLoading: boolean } => {
|
||||
const { accessToken, userId, userRole } = useAuthorized();
|
||||
const { data, isLoading } = useQuery<AutoRouterDeployment[], Error, string[]>({
|
||||
queryKey: autoRouterListKey(userId, userRole),
|
||||
queryFn: async () => await fetchAllModelDeployments(accessToken!, userId!, userRole!),
|
||||
enabled: Boolean(accessToken && userId && userRole),
|
||||
select: selectDatabaseModelGroups,
|
||||
});
|
||||
return { data: data ?? NO_MODEL_GROUPS, isLoading };
|
||||
};
|
||||
|
||||
export const useAutoRouters = (): UseQueryResult<AutoRouterDeployment[], Error> => {
|
||||
const { accessToken, userId, userRole } = useAuthorized();
|
||||
return useQuery<AutoRouterDeployment[], Error, AutoRouterDeployment[]>({
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { CircleHelp } from "lucide-react";
|
||||
import React from "react";
|
||||
import { z } from "zod/v4";
|
||||
import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import NumericalInput from "@/components/shared/numerical_input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
|
|
@ -14,16 +13,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
|
|||
import { ModelAccessGroup } from "@/app/(dashboard)/hooks/modelAccessGroups/useModelAccessGroups";
|
||||
import { SetModelAccessGroupBudgetParams } from "@/app/(dashboard)/hooks/modelAccessGroups/useSetModelAccessGroupBudget";
|
||||
import { accessGroupBudgetFormValues, buildAccessGroupBudgetBody, hasAnyBudgetValue } from "./accessGroupBudgetPayload";
|
||||
|
||||
const labelWithHint = (label: React.ReactNode, hint: string): React.ReactNode => (
|
||||
<>
|
||||
{label}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
|
||||
<TooltipContent>{hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
import { labelWithHint } from "./LabelWithHint";
|
||||
|
||||
const budgetSchema = z
|
||||
.object({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { CreateModelAccessGroupParams } from "@/app/(dashboard)/hooks/modelAccessGroups/useCreateModelAccessGroup";
|
||||
import { createAccessGroupSchema } from "./createAccessGroupSchema";
|
||||
import { labelWithHint } from "./LabelWithHint";
|
||||
|
||||
interface CreateAccessGroupModalProps {
|
||||
existingGroups: readonly string[];
|
||||
modelOptions: readonly string[];
|
||||
isLoadingModels: boolean;
|
||||
isSaving: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (params: CreateModelAccessGroupParams) => void;
|
||||
}
|
||||
|
||||
const CreateAccessGroupModal: React.FC<CreateAccessGroupModalProps> = ({
|
||||
existingGroups,
|
||||
modelOptions,
|
||||
isLoadingModels,
|
||||
isSaving,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const schema = useMemo(() => createAccessGroupSchema(new Set(existingGroups)), [existingGroups]);
|
||||
const form = useZodForm(schema, { defaultValues: { access_group: "", model_names: [] } });
|
||||
const options = useMemo(() => modelOptions.map((name) => ({ label: name, value: name })), [modelOptions]);
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onCancel()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create access group</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The group is added to every deployment of the models you pick. Keys and teams can then be granted the group by
|
||||
name, and it can carry one shared budget.
|
||||
</p>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} noValidate>
|
||||
<TooltipProvider>
|
||||
<FieldGroup className="mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="access_group"
|
||||
label={labelWithHint("Access Group Name", "Free-text label, for example production-models")}
|
||||
>
|
||||
{(field) => <Input {...field} placeholder="production-models" autoComplete="off" />}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model_names"
|
||||
label={labelWithHint(
|
||||
"Models",
|
||||
"Only models with a database deployment are listed. A model defined in config.yaml joins a group through its access_groups entry in the config file",
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange }) => (
|
||||
<MultiSelect
|
||||
id={id}
|
||||
options={options}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
loading={isLoadingModels}
|
||||
placeholder="Select models"
|
||||
emptyText="No database models found"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? "Creating..." : "Create Access Group"}
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateAccessGroupModal;
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { CircleHelp } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
export const labelWithHint = (label: React.ReactNode, hint: string): React.ReactNode => (
|
||||
<>
|
||||
{label}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
|
||||
<TooltipContent>{hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { createAccessGroupSchema } from "./createAccessGroupSchema";
|
||||
|
||||
const schema = createAccessGroupSchema(new Set(["premium"]));
|
||||
|
||||
describe("createAccessGroupSchema", () => {
|
||||
it("accepts a fresh name with at least one model and trims the name", () => {
|
||||
const result = schema.safeParse({ access_group: " gold ", model_names: ["gold-nano"] });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({ access_group: "gold", model_names: ["gold-nano"] });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["", "Enter a name for the access group"],
|
||||
[" ", "Enter a name for the access group"],
|
||||
["openai/prod", 'A group name cannot contain "/"'],
|
||||
["premium", "An access group with this name already exists"],
|
||||
[" premium ", "An access group with this name already exists"],
|
||||
])("rejects the name %j", (name, message) => {
|
||||
const result = schema.safeParse({ access_group: name, model_names: ["gold-nano"] });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues.map((issue) => issue.message)).toContain(message);
|
||||
});
|
||||
|
||||
it("rejects an empty model list", () => {
|
||||
const result = schema.safeParse({ access_group: "gold", model_names: [] });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues.map((issue) => issue.message)).toEqual(["Pick at least one model"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { z } from "zod/v4";
|
||||
import { isBudgetAddressable } from "./AccessGroupBudgetColumns";
|
||||
|
||||
export const createAccessGroupSchema = (existingGroups: ReadonlySet<string>) =>
|
||||
z.object({
|
||||
access_group: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Enter a name for the access group")
|
||||
.refine(isBudgetAddressable, 'A group name cannot contain "/"')
|
||||
.refine((name) => !existingGroups.has(name), "An access group with this name already exists"),
|
||||
model_names: z.array(z.string()).min(1, "Pick at least one model"),
|
||||
});
|
||||
|
||||
export type CreateAccessGroupFormValues = z.input<ReturnType<typeof createAccessGroupSchema>>;
|
||||
|
|
@ -1,19 +1,26 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { GET, PUT, DELETE, userRole } = vi.hoisted(() => ({
|
||||
const { GET, PUT, DELETE, POST, modelInfoCall, userRole } = vi.hoisted(() => ({
|
||||
GET: vi.fn(),
|
||||
PUT: vi.fn(),
|
||||
DELETE: vi.fn(),
|
||||
POST: vi.fn(),
|
||||
modelInfoCall: vi.fn(),
|
||||
userRole: { current: "Admin" },
|
||||
}));
|
||||
vi.mock("@/lib/http/api", () => ({ fetchClient: { GET, PUT, DELETE } }));
|
||||
vi.mock("@/lib/http/api", () => ({ fetchClient: { GET, PUT, DELETE, POST } }));
|
||||
vi.mock("@/components/networking", () => ({
|
||||
modelInfoCall,
|
||||
modelHubCall: vi.fn(),
|
||||
modelAvailableCall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: () => ({ accessToken: "sk-test", userRole: userRole.current }),
|
||||
default: () => ({ accessToken: "sk-test", userId: "user-1", userRole: userRole.current }),
|
||||
}));
|
||||
|
||||
import AccessGroupBudgetsPanel from "./AccessGroupBudgetsPanel";
|
||||
|
|
@ -40,6 +47,16 @@ const FREE_GROUP = {
|
|||
budget: null,
|
||||
};
|
||||
|
||||
const DATABASE_DEPLOYMENT = { model_name: "gold-nano", model_info: { id: "m-1", db_model: true } };
|
||||
const CONFIG_DEPLOYMENT = { model_name: "config-nano", model_info: { id: "m-2", db_model: false } };
|
||||
const MODEL_INFO_PAGE = {
|
||||
data: [DATABASE_DEPLOYMENT, CONFIG_DEPLOYMENT],
|
||||
total_count: 2,
|
||||
current_page: 1,
|
||||
total_pages: 1,
|
||||
size: 1000,
|
||||
};
|
||||
|
||||
const renderPanel = () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
|
|
@ -60,6 +77,8 @@ describe("AccessGroupBudgetsPanel", () => {
|
|||
GET.mockResolvedValue({ data: { access_groups: [BUDGETED_GROUP, FREE_GROUP] } });
|
||||
PUT.mockResolvedValue({ data: { access_group: "shared", spend: 0, budget: null } });
|
||||
DELETE.mockResolvedValue({ data: { access_group: "premium", budget_deleted: true, message: "ok" } });
|
||||
POST.mockResolvedValue({ data: { access_group: "gold", model_names: ["gold-nano"], models_updated: 1 } });
|
||||
modelInfoCall.mockResolvedValue(MODEL_INFO_PAGE);
|
||||
});
|
||||
|
||||
it("lists each group with the spend drawn against its shared budget", async () => {
|
||||
|
|
@ -162,4 +181,82 @@ describe("AccessGroupBudgetsPanel", () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("creating a group", () => {
|
||||
const openCreate = async () => {
|
||||
await userEvent.click(await screen.findByTestId("create-access-group"));
|
||||
await screen.findByRole("dialog");
|
||||
};
|
||||
|
||||
const nameInput = () => screen.getByLabelText(/access group name/i);
|
||||
const submitButton = () => within(screen.getByRole("dialog")).getByRole("button", { name: /create access group/i });
|
||||
|
||||
// Base UI marks the dialog inert while the option list is open, so dismiss it before touching the form
|
||||
const pickModel = async (modelName: string) => {
|
||||
await userEvent.click(within(screen.getByRole("dialog")).getByRole("combobox"));
|
||||
await userEvent.click(await screen.findByText(modelName));
|
||||
await userEvent.click(nameInput());
|
||||
};
|
||||
|
||||
it("hides creation from an admin viewer", async () => {
|
||||
userRole.current = "Admin Viewer";
|
||||
renderPanel();
|
||||
|
||||
expect(await screen.findByText("premium")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("create-access-group")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers only models that have a database deployment", async () => {
|
||||
renderPanel();
|
||||
await openCreate();
|
||||
|
||||
await userEvent.click(within(screen.getByRole("dialog")).getByRole("combobox"));
|
||||
|
||||
expect(await screen.findByText("gold-nano")).toBeInTheDocument();
|
||||
expect(screen.queryByText("config-nano")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("tags the chosen models with the trimmed name and lists the new group", async () => {
|
||||
renderPanel();
|
||||
await openCreate();
|
||||
|
||||
fireEvent.change(nameInput(), { target: { value: " gold " } });
|
||||
await pickModel("gold-nano");
|
||||
GET.mockResolvedValue({
|
||||
data: { access_groups: [BUDGETED_GROUP, FREE_GROUP, { ...FREE_GROUP, access_group: "gold" }] },
|
||||
});
|
||||
await userEvent.click(submitButton());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(POST).toHaveBeenCalledWith("/access_group/new", {
|
||||
body: { access_group: "gold", model_names: ["gold-nano"] },
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByText("gold")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refuses a name the proxy already has without sending a request", async () => {
|
||||
renderPanel();
|
||||
await openCreate();
|
||||
|
||||
fireEvent.change(nameInput(), { target: { value: "premium" } });
|
||||
await pickModel("gold-nano");
|
||||
await userEvent.click(submitButton());
|
||||
|
||||
expect(await screen.findByText("An access group with this name already exists")).toBeInTheDocument();
|
||||
expect(POST).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires at least one model", async () => {
|
||||
renderPanel();
|
||||
await openCreate();
|
||||
|
||||
fireEvent.change(nameInput(), { target: { value: "gold" } });
|
||||
await userEvent.click(submitButton());
|
||||
|
||||
expect(await screen.findByText("Pick at least one model")).toBeInTheDocument();
|
||||
expect(POST).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,21 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { SortingState } from "@tanstack/react-table";
|
||||
import { Inbox } from "lucide-react";
|
||||
import { Inbox, Plus } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { isProxyAdminRole } from "@/utils/roles";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useDatabaseModelGroups } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import { ModelAccessGroup, useModelAccessGroups } from "@/app/(dashboard)/hooks/modelAccessGroups/useModelAccessGroups";
|
||||
import {
|
||||
CreateModelAccessGroupParams,
|
||||
useCreateModelAccessGroup,
|
||||
} from "@/app/(dashboard)/hooks/modelAccessGroups/useCreateModelAccessGroup";
|
||||
import { useDeleteModelAccessGroupBudget } from "@/app/(dashboard)/hooks/modelAccessGroups/useDeleteModelAccessGroupBudget";
|
||||
import {
|
||||
SetModelAccessGroupBudgetParams,
|
||||
useSetModelAccessGroupBudget,
|
||||
} from "@/app/(dashboard)/hooks/modelAccessGroups/useSetModelAccessGroupBudget";
|
||||
import AccessGroupBudgetModal from "@/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetModal";
|
||||
import CreateAccessGroupModal from "@/app/(dashboard)/models-and-endpoints/components/CreateAccessGroupModal";
|
||||
import { getAccessGroupBudgetColumns } from "@/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetColumns";
|
||||
|
||||
const DEFAULT_SORTING: SortingState = [{ id: "access_group", desc: false }];
|
||||
|
|
@ -28,7 +35,7 @@ function EmptyState() {
|
|||
</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.
|
||||
Create one here or put a deployment in a group from its model settings, then give the group a shared budget.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -39,10 +46,13 @@ export default function AccessGroupBudgetsPanel() {
|
|||
const { data: accessGroups, isLoading } = useModelAccessGroups();
|
||||
const setBudget = useSetModelAccessGroupBudget();
|
||||
const clearBudget = useDeleteModelAccessGroupBudget();
|
||||
const createGroup = useCreateModelAccessGroup();
|
||||
const { data: databaseModelGroups, isLoading: isLoadingModels } = useDatabaseModelGroups();
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
const [editing, setEditing] = useState<ModelAccessGroup | null>(null);
|
||||
const [clearing, setClearing] = useState<ModelAccessGroup | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const canWrite = isProxyAdminRole(userRole ?? "");
|
||||
const columns = useMemo(
|
||||
|
|
@ -50,6 +60,17 @@ export default function AccessGroupBudgetsPanel() {
|
|||
[canWrite],
|
||||
);
|
||||
|
||||
const existingGroups = useMemo(() => (accessGroups ?? []).map((group) => group.access_group), [accessGroups]);
|
||||
|
||||
const handleCreate = (params: CreateModelAccessGroupParams) => {
|
||||
createGroup.mutate(params, {
|
||||
onSuccess: () => {
|
||||
toast.success(`Access group "${params.access_group}" created`);
|
||||
setCreating(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (params: SetModelAccessGroupBudgetParams) => {
|
||||
if (!editing) return;
|
||||
const accessGroup = editing.access_group;
|
||||
|
|
@ -77,10 +98,18 @@ export default function AccessGroupBudgetsPanel() {
|
|||
|
||||
return (
|
||||
<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>
|
||||
<div className="flex items-start justify-between 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>
|
||||
{canWrite && (
|
||||
<Button size="sm" className="shrink-0" onClick={() => setCreating(true)} data-testid="create-access-group">
|
||||
<Plus />
|
||||
Create Access Group
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={accessGroups ?? []}
|
||||
|
|
@ -95,6 +124,17 @@ export default function AccessGroupBudgetsPanel() {
|
|||
size="compact"
|
||||
/>
|
||||
|
||||
{creating && (
|
||||
<CreateAccessGroupModal
|
||||
existingGroups={existingGroups}
|
||||
modelOptions={databaseModelGroups}
|
||||
isLoadingModels={isLoadingModels}
|
||||
isSaving={createGroup.isPending}
|
||||
onCancel={() => setCreating(false)}
|
||||
onSubmit={handleCreate}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AccessGroupBudgetModal
|
||||
accessGroup={editing}
|
||||
isSaving={setBudget.isPending}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue