fix: show no budget instead of unlimited for empty group AI budget (#27993)

An empty per-member AI budget on the group settings page previously
displayed "unlimited budget" with a "Members in this group have no
spending cap." alert. Unlimited spend only applies when the everyone
group is the sole group, so this messaging was misleading everywhere
else.

## Changes

- Empty budget now shows "This group has **no budget** set. View docs"
with no info alert. "View docs" links to [Effective group
resolution](https://coder.com/docs/ai-coder/ai-gateway/cost-controls#effective-group-resolution)
using the versioned `docs()` helper.
- Removed the "Members in this group have no spending cap." alert
entirely.
- Input placeholder changed from `unlimited` to `no budget`.
- Updated the `AIBudgetUncapped` story expectations to match.

## Unchanged

- Entering an explicit `$0` still shows the "A $0 limit disables AI
access for this group." alert, as before.
- Saving with an empty field still sends `null` to the budget API;
backend semantics are untouched.

Story tests pass: `pnpm test:storybook
src/pages/GroupsPage/GroupSettingsPageView.stories.tsx` (7/7).

---

*This PR was generated by Coder Agents on behalf of @tracyjohnsonux.*
This commit is contained in:
TJ
2026-08-19 06:15:06 -07:00
committed by GitHub
parent da99941099
commit 97cb722fb8
3 changed files with 72 additions and 22 deletions
+1 -1
View File
@@ -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.
@@ -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.",
);
},
};
@@ -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 = () => (
<Link
href={docs("/ai-coder/ai-gateway/cost-controls#effective-group-resolution")}
target="_blank"
rel="noreferrer"
size="sm"
// The link's default left padding reads as a stray gap when the link
// wraps to its own line under the helper text.
className="pl-0"
>
Learn how budgets apply across groups
</Link>
);
interface AIBudgetFeedbackProps {
error: boolean;
helperText?: ReactNode;
@@ -69,26 +84,34 @@ const AIBudgetFeedback: FC<AIBudgetFeedbackProps> = ({
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{" "}
<span className="font-medium text-content-primary">$0</span>.
</>
),
message:
"A $0 limit blocks AI access for members that aren't in another group with a budget set.",
};
return (
<>
<span className="text-left text-xs text-content-secondary">
This group has{" "}
<span className="font-medium text-content-primary">{label}</span>.
{summary}
</span>
<span className="text-left text-xs text-content-secondary">
{message} <BudgetDocsLink />
</span>
<Alert severity="info">{message}</Alert>
</>
);
}
@@ -96,12 +119,13 @@ const AIBudgetFeedback: FC<AIBudgetFeedbackProps> = ({
if (Number.isFinite(budgetAmount) && budgetAmount > 0) {
return (
<span className="text-left text-xs text-content-secondary">
This group's limit is{" "}
<span className="font-medium text-content-primary">
{usdBudgetFormatter.format(budgetAmount * memberCount)}
</span>
/month, based on{" "}
<span className="font-medium text-content-primary">{memberCount}</span>{" "}
{memberCount === 1 ? "member" : "members"}.
{memberCount === 1 ? "member" : "members"}. <BudgetDocsLink />
</span>
);
}
@@ -251,7 +275,7 @@ const UpdateGroupForm: FC<UpdateGroupFormProps> = ({
min="0"
max={maxAIBudgetDollars}
step="1"
placeholder="unlimited"
placeholder="no budget"
aria-invalid={budgetField.error}
/>
<InputGroupAddon align="inline-end">USD</InputGroupAddon>