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:
ryan-crabbe-berri 2026-09-03 16:58:36 -07:00
parent ab0478f068
commit 5be96fb71a
10 changed files with 377 additions and 23 deletions

View file

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

View file

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

View file

@ -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[]>({

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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&apos;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&apos;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}