diff --git a/docs/ai-coder/ai-gateway/cost-controls.md b/docs/ai-coder/ai-gateway/cost-controls.md index 741d6e5134..1b82a5f968 100644 --- a/docs/ai-coder/ai-gateway/cost-controls.md +++ b/docs/ai-coder/ai-gateway/cost-controls.md @@ -77,7 +77,7 @@ member can spend $200 USD and the group has a total spend limit of $2,000 USD. Budget values behave as follows: -- An empty field means no limit. The field displays `unlimited`. +- An empty field means no budget is set. The field displays `no budget`. - `$0 USD` blocks every request routed via AI Gateway from members whose effective group is this group. - The maximum is `$1,000,000 USD` per member per budget period. diff --git a/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx b/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx index a80aba46e5..6ab83b98c8 100644 --- a/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx +++ b/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx @@ -42,7 +42,14 @@ export const WithAIBudget: Story = { 1000, ); const helper = canvas.getByText(/month, based on/i); - await expect(helper).toHaveTextContent("$7,000/month, based on 7 members."); + await expect(helper).toHaveTextContent( + "This group's limit is $7,000/month, based on 7 members.", + ); + await expect( + canvas.getByRole("link", { + name: /learn how budgets apply across groups/i, + }), + ).toBeInTheDocument(); }, }; @@ -55,11 +62,23 @@ export const AIBudgetUncapped: Story = { const canvas = within(canvasElement); await expect( canvas.getByLabelText("Monthly limit per member"), - ).toHaveAttribute("placeholder", "unlimited"); - await expect(canvas.getByText("unlimited budget")).toBeInTheDocument(); + ).toHaveAttribute("placeholder", "no budget"); await expect( - canvas.getByText("Members in this group have no spending cap."), + canvas.getByText("This group doesn't have a budget set."), ).toBeInTheDocument(); + await expect( + canvas.getByText(/Members will fall back to another group's limit/), + ).toBeInTheDocument(); + await expect( + canvas.getByRole("link", { + name: /learn how budgets apply across groups/i, + }), + ).toHaveAttribute( + "href", + expect.stringContaining( + "/ai-coder/ai-gateway/cost-controls#effective-group-resolution", + ), + ); }, }; @@ -74,9 +93,14 @@ export const AIBudgetDisabled: Story = { await expect(canvas.getByLabelText("Monthly limit per member")).toHaveValue( 0, ); - await expect(canvas.getByText("no budget")).toBeInTheDocument(); + const summary = canvas.getByText(/This group's limit has been set to/); + await expect(summary).toHaveTextContent( + "This group's limit has been set to $0.", + ); await expect( - canvas.getByText("A $0 limit disables AI access for this group."), + canvas.getByText( + /A \$0 limit blocks AI access for members that aren't in another group/, + ), ).toBeInTheDocument(); }, }; @@ -91,7 +115,9 @@ export const AIBudgetDecimal: Story = { const canvas = within(canvasElement); // Cents are kept when the amount is not a whole dollar. const helper = canvas.getByText(/month, based on/i); - await expect(helper).toHaveTextContent("$99.99/month, based on 1 member."); + await expect(helper).toHaveTextContent( + "This group's limit is $99.99/month, based on 1 member.", + ); }, }; diff --git a/site/src/pages/GroupsPage/GroupSettingsPageView.tsx b/site/src/pages/GroupsPage/GroupSettingsPageView.tsx index 4bc3596e5d..e8ff2c1c77 100644 --- a/site/src/pages/GroupsPage/GroupSettingsPageView.tsx +++ b/site/src/pages/GroupsPage/GroupSettingsPageView.tsx @@ -2,7 +2,6 @@ import { useFormik } from "formik"; import type { FC, ReactNode } from "react"; import * as Yup from "yup"; import type { Group } from "#/api/typesGenerated"; -import { Alert } from "#/components/Alert/Alert"; import { Badge } from "#/components/Badge/Badge"; import { Button } from "#/components/Button/Button"; import { IconField } from "#/components/IconField/IconField"; @@ -13,6 +12,7 @@ import { InputGroupInput, } from "#/components/InputGroup/InputGroup"; import { Label } from "#/components/Label/Label"; +import { Link } from "#/components/Link/Link"; import { Spinner } from "#/components/Spinner/Spinner"; import { aiBudgetRangeError, @@ -20,6 +20,7 @@ import { maxAIBudgetDollars, } from "#/modules/groups"; import { usdBudgetFormatter } from "#/utils/currency"; +import { docs } from "#/utils/docs"; import { getFormHelpers, nameValidator, @@ -31,20 +32,34 @@ type FormData = { display_name: string; avatar_url: string; quota_allowance: number; - // Per-member AI budget, in dollars. "" is unlimited; 0 disables. + // Per-member AI budget, in dollars. "" means no budget; 0 disables AI access. monthly_budget_per_member: string; }; const validationSchema = Yup.object({ name: nameValidator("Name"), quota_allowance: Yup.number().required().min(0).integer(), - // Optional: empty is unlimited. A value must be within the range; 0 disables. + // Optional: empty means no budget. A value must be within the range; 0 disables. monthly_budget_per_member: Yup.number() .transform((value, original) => (original === "" ? undefined : value)) .min(0, aiBudgetRangeError) .max(maxAIBudgetDollars, aiBudgetRangeError), }); +const BudgetDocsLink: FC = () => ( + + Learn how budgets apply across groups + +); + interface AIBudgetFeedbackProps { error: boolean; helperText?: ReactNode; @@ -69,26 +84,34 @@ const AIBudgetFeedback: FC = ({ const budgetValue = monthlyBudgetPerMember.trim(); const budgetAmount = Number(budgetValue); - // Empty means unlimited spend; $0 disables AI access. Both states show an + // Empty means no budget; $0 disables AI access. Both states show an // explanatory alert alongside the summary line. if (budgetValue === "" || budgetAmount === 0) { - const { label, message } = + const { summary, message } = budgetValue === "" ? { - label: "unlimited budget", - message: "Members in this group have no spending cap.", + summary: "This group doesn't have a budget set.", + message: + "Members will fall back to another group's limit, or if no budgets have been set, they will have no spend limit.", } : { - label: "no budget", - message: "A $0 limit disables AI access for this group.", + summary: ( + <> + This group's limit has been set to{" "} + $0. + + ), + message: + "A $0 limit blocks AI access for members that aren't in another group with a budget set.", }; return ( <> - This group has{" "} - {label}. + {summary} + + + {message} - {message} ); } @@ -96,12 +119,13 @@ const AIBudgetFeedback: FC = ({ if (Number.isFinite(budgetAmount) && budgetAmount > 0) { return ( + This group's limit is{" "} {usdBudgetFormatter.format(budgetAmount * memberCount)} /month, based on{" "} {memberCount}{" "} - {memberCount === 1 ? "member" : "members"}. + {memberCount === 1 ? "member" : "members"}. ); } @@ -251,7 +275,7 @@ const UpdateGroupForm: FC = ({ min="0" max={maxAIBudgetDollars} step="1" - placeholder="unlimited" + placeholder="no budget" aria-invalid={budgetField.error} /> USD