Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/ai-coder/ai-governance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2259,6 +2259,30 @@ class ApiMethods {
await this.axios.delete(`/api/v2/groups/${groupId}`);
};

getGroupAIBudget = async (
groupId: string,
): Promise<TypesGen.GroupAIBudget> => {
const response = await this.axios.get(
`/api/v2/groups/${groupId}/ai/budget`,
);
return response.data;
};

upsertGroupAIBudget = async (
groupId: string,
data: TypesGen.UpsertGroupAIBudgetRequest,
): Promise<TypesGen.GroupAIBudget> => {
const response = await this.axios.put(
`/api/v2/groups/${groupId}/ai/budget`,
data,
);
return response.data;
};

deleteGroupAIBudget = async (groupId: string): Promise<void> => {
await this.axios.delete(`/api/v2/groups/${groupId}/ai/budget`);
};

getWorkspaceQuota = async (
organizationName: string,
username: string,
Expand Down
49 changes: 49 additions & 0 deletions site/src/api/queries/groups.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<GroupAIBudget | null> => {
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,
Expand Down
89 changes: 64 additions & 25 deletions site/src/pages/GroupsPage/GroupSettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The backend gates on this, but there's also ai_governance_user_limit. Do we want to unify the cost control features somehow?

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 (
<div className="flex items-center justify-center p-10">
<Spinner loading className="size-6" />
</div>
);
}
if (aibridgeVisible && budgetQuery.error) {
return <ErrorAlert error={budgetQuery.error} />;
}

const currentBudgetMicros = budgetQuery.data?.spend_limit_micros ?? null;
const initialBudgetDollars =
currentBudgetMicros !== null ? microsToDollars(currentBudgetMicros) : null;
const isUpdating =
patchGroupMutation.isPending || saveBudgetMutation.isPending;

return (
<GroupSettingsPageView
onCancel={() => 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}
/>
);
};
Expand Down
83 changes: 78 additions & 5 deletions site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,94 @@
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";

const meta: Meta<typeof GroupSettingsPageView> = {
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<typeof GroupSettingsPageView>;

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