From d6279fb1a34319bc9038c3e30e80f524d79a83a1 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 15 Jun 2026 18:02:34 +0300 Subject: [PATCH 1/2] feat(site): add group AI budget management UI Add an AI budget section to the group settings page, gated by the aibridge feature. The page's Save persists a per-member monthly budget via the group AI budget endpoints alongside the group patch: an empty field is uncapped (deletes the budget), 0 disables spending, and any value >= 0 is accepted. A helper shows the group-wide monthly maximum. --- site/src/api/api.ts | 24 +++++ site/src/api/queries/groups.ts | 49 ++++++++++ .../pages/GroupsPage/GroupSettingsPage.tsx | 89 +++++++++++++------ .../GroupSettingsPageView.stories.tsx | 83 +++++++++++++++-- .../GroupsPage/GroupSettingsPageView.tsx | 87 +++++++++++++++++- 5 files changed, 299 insertions(+), 33 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index d2b09d9b40b82..e16638cd4e5a0 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2259,6 +2259,30 @@ class ApiMethods { await this.axios.delete(`/api/v2/groups/${groupId}`); }; + getGroupAIBudget = async ( + groupId: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/groups/${groupId}/ai/budget`, + ); + return response.data; + }; + + upsertGroupAIBudget = async ( + groupId: string, + data: TypesGen.UpsertGroupAIBudgetRequest, + ): Promise => { + const response = await this.axios.put( + `/api/v2/groups/${groupId}/ai/budget`, + data, + ); + return response.data; + }; + + deleteGroupAIBudget = async (groupId: string): Promise => { + await this.axios.delete(`/api/v2/groups/${groupId}/ai/budget`); + }; + getWorkspaceQuota = async ( organizationName: string, username: string, diff --git a/site/src/api/queries/groups.ts b/site/src/api/queries/groups.ts index 0c29d4b5e1d8d..51611e6b056d8 100644 --- a/site/src/api/queries/groups.ts +++ b/site/src/api/queries/groups.ts @@ -1,8 +1,10 @@ import type { QueryClient, UseQueryOptions } from "react-query"; import { API } from "#/api/api"; +import { isApiError } from "#/api/errors"; import type { CreateGroupRequest, Group, + GroupAIBudget, GroupMembersResponse, GroupRequest, PatchGroupRequest, @@ -226,6 +228,53 @@ export const removeMember = ( }; }; +const getGroupAIBudgetQueryKey = (groupId: string) => [ + "group", + groupId, + "aiBudget", +]; + +/** Budget query; resolves to null when none is set (the GET 404s). */ +export const groupAIBudget = ( + groupId: string, +): UseQueryOptions => { + return { + queryKey: getGroupAIBudgetQueryKey(groupId), + queryFn: async () => { + try { + return await API.getGroupAIBudget(groupId); + } catch (error) { + if (isApiError(error) && error.response.status === 404) { + return null; + } + throw error; + } + }, + }; +}; + +/* Upserts the budget for a value, or deletes it (uncapped) when given null. */ +export const saveGroupAIBudget = ( + queryClient: QueryClient, + groupId: string, +) => { + return { + mutationFn: async (spendLimitMicros: number | null) => { + if (spendLimitMicros === null) { + await API.deleteGroupAIBudget(groupId); + } else { + await API.upsertGroupAIBudget(groupId, { + spend_limit_micros: spendLimitMicros, + }); + } + }, + onSuccess: async () => + queryClient.invalidateQueries({ + queryKey: getGroupAIBudgetQueryKey(groupId), + }), + }; +}; + const invalidateGroup = ( queryClient: QueryClient, organization: string, diff --git a/site/src/pages/GroupsPage/GroupSettingsPage.tsx b/site/src/pages/GroupsPage/GroupSettingsPage.tsx index 81fb430c3e607..315c97f5c0dcb 100644 --- a/site/src/pages/GroupsPage/GroupSettingsPage.tsx +++ b/site/src/pages/GroupsPage/GroupSettingsPage.tsx @@ -1,12 +1,24 @@ import type { FC } from "react"; -import { useMutation, useQueryClient } from "react-query"; +import { useMutation, useQuery, useQueryClient } from "react-query"; import { useNavigate, useOutletContext, useParams } from "react-router"; import { toast } from "sonner"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; -import { patchGroup } from "#/api/queries/groups"; +import { + groupAIBudget, + patchGroup, + saveGroupAIBudget, +} from "#/api/queries/groups"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { Spinner } from "#/components/Spinner/Spinner"; +import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; +import { dollarsToMicros, microsToDollars } from "#/utils/currency"; import type { GroupPageOutletContext } from "./GroupPage"; import GroupSettingsPageView from "./GroupSettingsPageView"; +// Empty is uncapped (no budget row); otherwise the budget in micros. +const budgetFromInput = (dollars: string): number | null => + dollars.trim() === "" ? null : dollarsToMicros(dollars); + const GroupSettingsPage: FC = () => { const { organization = "default", groupName } = useParams() as { organization?: string; @@ -17,39 +29,66 @@ const GroupSettingsPage: FC = () => { const patchGroupMutation = useMutation(patchGroup(queryClient, organization)); const navigate = useNavigate(); + // Budget routes are gated on aibridge; useFeatureVisibility is {} unlicensed. + const aibridgeVisible = Boolean(useFeatureVisibility().aibridge); + const budgetQuery = useQuery({ + ...groupAIBudget(groupData.id), + enabled: aibridgeVisible, + }); + const saveBudgetMutation = useMutation( + saveGroupAIBudget(queryClient, groupData.id), + ); + + // Load the budget before rendering so the form initializes with it. + if (aibridgeVisible && budgetQuery.isLoading) { + return ( +
+ +
+ ); + } + if (aibridgeVisible && budgetQuery.error) { + return ; + } + + const currentBudgetMicros = budgetQuery.data?.spend_limit_micros ?? null; + const initialBudgetDollars = + currentBudgetMicros !== null ? microsToDollars(currentBudgetMicros) : null; + const isUpdating = + patchGroupMutation.isPending || saveBudgetMutation.isPending; + return ( navigate("..")} onSubmit={async (data) => { - await patchGroupMutation.mutateAsync( - { + const { monthly_budget_per_member, ...groupFields } = data; + try { + await patchGroupMutation.mutateAsync({ groupId: groupData.id, - ...data, + ...groupFields, add_users: [], remove_users: [], - }, - { - onSuccess: () => { - navigate(`/organizations/${organization}/groups/${data.name}`); - }, - onError: (error) => { - toast.error( - getErrorMessage( - error, - `Failed to update group "${groupName}".`, - ), - { - description: getErrorDetail(error), - }, - ); - }, - }, - ); + }); + + // Save only when the budget changed (0 disables, empty uncaps). + const next = budgetFromInput(monthly_budget_per_member); + if (aibridgeVisible && next !== currentBudgetMicros) { + await saveBudgetMutation.mutateAsync(next); + } + + navigate(`/organizations/${organization}/groups/${data.name}`); + } catch (error) { + toast.error( + getErrorMessage(error, `Failed to update group "${groupName}".`), + { description: getErrorDetail(error) }, + ); + } }} group={groupData} + showAISettings={aibridgeVisible} + initialBudgetDollars={initialBudgetDollars} formErrors={undefined} - isLoading={false} - isUpdating={patchGroupMutation.isPending} + isUpdating={isUpdating} /> ); }; diff --git a/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx b/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx index dd85785881092..f06e820f8534d 100644 --- a/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx +++ b/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { action } from "storybook/actions"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import { MockGroup } from "#/testHelpers/entities"; import GroupSettingsPageView from "./GroupSettingsPageView"; @@ -7,15 +7,88 @@ const meta: Meta = { title: "pages/OrganizationGroupsPage/GroupSettingsPageView", component: GroupSettingsPageView, args: { - onCancel: action("onCancel"), + onCancel: fn(), + onSubmit: fn(), group: MockGroup, - isLoading: false, + showAISettings: false, + initialBudgetDollars: null, + formErrors: undefined, + isUpdating: false, }, }; export default meta; type Story = StoryObj; -const Example: Story = {}; +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Without the AI add-on, the AI budget section is hidden. + await expect(canvas.queryByText("AI budget")).not.toBeInTheDocument(); + }, +}; -export { Example as GroupSettingsPageView }; +export const WithAIBudget: Story = { + args: { + showAISettings: true, + group: { ...MockGroup, total_member_count: 7 }, + initialBudgetDollars: 1000, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("AI budget")).toBeInTheDocument(); + const helper = canvas.getByText(/month maximum/i); + await expect(helper).toHaveTextContent( + "$7,000.00/month maximum, based on 7 members.", + ); + }, +}; + +export const AIBudgetUncapped: Story = { + args: { + showAISettings: true, + initialBudgetDollars: null, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText("Leave empty for uncapped spend."), + ).toBeInTheDocument(); + }, +}; + +export const AIBudgetDisabled: Story = { + args: { + showAISettings: true, + group: { ...MockGroup, total_member_count: 7 }, + initialBudgetDollars: 0, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // A budget of 0 is valid and reads as disabled spend. + const helper = canvas.getByText(/month maximum/i); + await expect(helper).toHaveTextContent( + "$0.00/month maximum, based on 7 members.", + ); + }, +}; + +export const SaveWithBudget: Story = { + args: { + showAISettings: true, + initialBudgetDollars: null, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const input = canvas.getByLabelText("Monthly budget per member (USD)"); + await userEvent.type(input, "25"); + await userEvent.click(canvas.getByRole("button", { name: "Save" })); + // onSubmit fires asynchronously with (values, formikHelpers). + await waitFor(() => + expect(args.onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ monthly_budget_per_member: "25" }), + expect.anything(), + ), + ); + }, +}; diff --git a/site/src/pages/GroupsPage/GroupSettingsPageView.tsx b/site/src/pages/GroupsPage/GroupSettingsPageView.tsx index 675a54004e754..2db8ef0a5b840 100644 --- a/site/src/pages/GroupsPage/GroupSettingsPageView.tsx +++ b/site/src/pages/GroupsPage/GroupSettingsPageView.tsx @@ -2,12 +2,14 @@ import { useFormik } from "formik"; import type { FC } from "react"; import * as Yup from "yup"; import type { Group } from "#/api/typesGenerated"; +import { Badge } from "#/components/Badge/Badge"; import { Button } from "#/components/Button/Button"; import { IconField } from "#/components/IconField/IconField"; import { Input } from "#/components/Input/Input"; import { Label } from "#/components/Label/Label"; import { Spinner } from "#/components/Spinner/Spinner"; import { isEveryoneGroup } from "#/modules/groups"; +import { dollarsToMicros, formatCostMicros } from "#/utils/currency"; import { getFormHelpers, nameValidator, @@ -19,15 +21,25 @@ type FormData = { display_name: string; avatar_url: string; quota_allowance: number; + // Per-member AI budget, in dollars. "" is no budget (uncapped); 0 disables. + monthly_budget_per_member: string; }; const validationSchema = Yup.object({ name: nameValidator("Name"), quota_allowance: Yup.number().required().min(0).integer(), + // Optional: empty is uncapped. A value must be zero or more (0 disables). + monthly_budget_per_member: Yup.number() + .transform((value, original) => (original === "" ? undefined : value)) + .min(0, "Enter an amount of zero or more."), }); interface UpdateGroupFormProps { group: Group; + /** Whether the AI add-on settings are shown (gated by the aibridge feature). */ + showAISettings: boolean; + /** Per-member AI budget in dollars, or null when none is set. */ + initialBudgetDollars: number | null; errors: unknown; onSubmit: (data: FormData) => void; onCancel: () => void; @@ -36,6 +48,8 @@ interface UpdateGroupFormProps { const UpdateGroupForm: FC = ({ group, + showAISettings, + initialBudgetDollars, errors, onSubmit, onCancel, @@ -47,6 +61,8 @@ const UpdateGroupForm: FC = ({ display_name: group.display_name, avatar_url: group.avatar_url, quota_allowance: group.quota_allowance, + monthly_budget_per_member: + initialBudgetDollars === null ? "" : String(initialBudgetDollars), }, validationSchema, onSubmit, @@ -60,6 +76,12 @@ const UpdateGroupForm: FC = ({ helperText: `This group gives ${form.values.quota_allowance} quota credits to each of its members.`, }); + const budgetField = getFieldHelpers("monthly_budget_per_member"); + const budgetDollars = form.values.monthly_budget_per_member; + const memberCount = group.total_member_count; + const monthlyMaximum = formatCostMicros( + dollarsToMicros(budgetDollars) * memberCount, + ); return (
@@ -132,6 +154,60 @@ const UpdateGroupForm: FC = ({ )} + + {showAISettings && ( +
+
+

+ AI budget +

+ + AI add-on + +
+
+
+ + + form.setFieldValue(budgetField.name, event.target.value) + } + onBlur={budgetField.onBlur} + type="number" + min="0" + step="1" + aria-invalid={budgetField.error} + /> + {budgetField.error ? ( + + {budgetField.helperText} + + ) : budgetDollars.trim() !== "" ? ( + + + {monthlyMaximum} + + /month maximum, based on{" "} + + {memberCount} + {" "} + {memberCount === 1 ? "member" : "members"}. + + ) : ( + + Leave empty for uncapped spend. + + )} +
+
+
+ )} +

@@ -186,9 +262,10 @@ const UpdateGroupForm: FC = ({ type SettingsGroupPageViewProps = { onCancel: () => void; onSubmit: (data: FormData) => void; - group: Group | undefined; + group: Group; + showAISettings: boolean; + initialBudgetDollars: number | null; formErrors: unknown; - isLoading: boolean; isUpdating: boolean; }; @@ -196,12 +273,16 @@ const GroupSettingsPageView: FC = ({ onCancel, onSubmit, group, + showAISettings, + initialBudgetDollars, formErrors, isUpdating, }) => { return ( Date: Mon, 15 Jun 2026 18:30:29 +0300 Subject: [PATCH 2/2] docs(ai-coder): document group AI budgets Add AI budgets to the AI Governance Add-On feature list and a section describing the per-member monthly spend limit, the empty/0/positive values, and the group-wide maximum calculation. --- docs/ai-coder/ai-governance.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/ai-coder/ai-governance.md b/docs/ai-coder/ai-governance.md index ce786ea53e086..0ab57df97aac2 100644 --- a/docs/ai-coder/ai-governance.md +++ b/docs/ai-coder/ai-governance.md @@ -16,6 +16,8 @@ that help organizations safely roll out AI tooling at scale: MCP server management, and policy enforcement - [Agent Firewall](./agent-firewall/index.md): Process-level firewalls for agents, restricting which domains can be accessed by AI agents +- [AI budgets](#ai-budgets): Per-member monthly spend limits for AI usage, + configured per group > [!NOTE] > As of Coder v2.32, the AI Governance Add-On is required to use AI Gateway and Agent Firewall. @@ -76,6 +78,22 @@ Without usage data, it's hard to justify AI tooling investments or identify high-leverage use cases. AI Gateway captures metrics on token spend, adoption rates, and usage patterns to inform decisions about AI strategy. +## AI budgets + +AI budgets set a monthly AI spend limit for the members of a group. Configure a +budget on the group's settings page at +`/organizations/{organization}/groups/{group}/settings`. + +The value is a per-member monthly amount in USD: + +- **Empty**: no budget. The group's AI spend is uncapped. +- **`0`**: AI spending is disabled for the group. +- **A positive value**: the monthly budget for each member. + +The settings page shows the group-wide maximum, calculated as the per-member +budget multiplied by the number of members. For example, a `$1,000` per-member +budget for a group of 7 members allows up to `$7,000` per month. + ## GA status and availability Starting with Coder v2.30 (February 2026), AI Gateway and Agent Firewall are