mirror of
https://github.com/coder/coder.git
synced 2026-09-21 12:44:32 +08:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user