From 4beb5846135c7f6e4fb574d5987e9561fbe9b55c Mon Sep 17 00:00:00 2001
From: Susana Cardoso Ferreira
Date: Wed, 29 Jul 2026 08:59:06 +0000
Subject: [PATCH 1/2] fix: ai cost control cap configurable AI spend limit
---
coderd/apidoc/docs.go | 2 +
coderd/apidoc/swagger.json | 2 +
codersdk/aibridge.go | 10 ++-
docs/reference/api/schemas.md | 8 +--
enterprise/coderd/aibridge.go | 22 ++++++
enterprise/coderd/aibridge_test.go | 69 +++++++++++++++++++
site/src/api/typesGenerated.ts | 13 ++++
.../GroupSettingsPageView.stories.tsx | 25 +++++++
.../GroupsPage/GroupSettingsPageView.tsx | 13 ++--
.../UserAIBudgetOverrideDialog.stories.tsx | 54 +++++++++++++--
.../GroupsPage/UserAIBudgetOverrideDialog.tsx | 26 ++++---
11 files changed, 221 insertions(+), 23 deletions(-)
diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index 58fd8e55b2e32..91f1bb9140bd3 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -26320,6 +26320,7 @@ const docTemplate = `{
"type": "object",
"properties": {
"spend_limit_micros": {
+ "description": "SpendLimitMicros must not exceed MaxAISpendLimitMicros.",
"type": "integer",
"minimum": 0
}
@@ -26337,6 +26338,7 @@ const docTemplate = `{
"format": "uuid"
},
"spend_limit_micros": {
+ "description": "SpendLimitMicros must not exceed MaxAISpendLimitMicros.",
"type": "integer",
"minimum": 0
}
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index 597c09ab310c1..de7e0316cf75c 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -24200,6 +24200,7 @@
"type": "object",
"properties": {
"spend_limit_micros": {
+ "description": "SpendLimitMicros must not exceed MaxAISpendLimitMicros.",
"type": "integer",
"minimum": 0
}
@@ -24215,6 +24216,7 @@
"format": "uuid"
},
"spend_limit_micros": {
+ "description": "SpendLimitMicros must not exceed MaxAISpendLimitMicros.",
"type": "integer",
"minimum": 0
}
diff --git a/codersdk/aibridge.go b/codersdk/aibridge.go
index e5fb5aeadb649..ce4ec3fc03063 100644
--- a/codersdk/aibridge.go
+++ b/codersdk/aibridge.go
@@ -15,6 +15,10 @@ import (
"github.com/coder/coder/v2/coderd/util/slice"
)
+// MaxAISpendLimitMicros is the highest AI spend limit that can be configured,
+// $1,000,000 per member per budget period.
+const MaxAISpendLimitMicros int64 = 1_000_000_000_000
+
// AIBudgetLimitSource identifies which tier produced the user's
// effective budget limit.
type AIBudgetLimitSource string
@@ -418,6 +422,7 @@ type GroupAIBudget struct {
}
type UpsertGroupAIBudgetRequest struct {
+ // SpendLimitMicros must not exceed MaxAISpendLimitMicros.
SpendLimitMicros int64 `json:"spend_limit_micros" validate:"gte=0"`
}
@@ -485,8 +490,9 @@ type UserAIBudgetOverride struct {
type UpsertUserAIBudgetOverrideRequest struct {
// GroupID is the group the user's spend is attributed to. The user must
// be a member of this group.
- GroupID uuid.UUID `json:"group_id" format:"uuid" validate:"required"`
- SpendLimitMicros int64 `json:"spend_limit_micros" validate:"gte=0"`
+ GroupID uuid.UUID `json:"group_id" format:"uuid" validate:"required"`
+ // SpendLimitMicros must not exceed MaxAISpendLimitMicros.
+ SpendLimitMicros int64 `json:"spend_limit_micros" validate:"gte=0"`
}
// UserAIBudgetOverride returns the AI spend budget override configured for the given user.
diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md
index 50e047efae492..286fd611a31a4 100644
--- a/docs/reference/api/schemas.md
+++ b/docs/reference/api/schemas.md
@@ -14302,9 +14302,9 @@ If the schedule is empty, the user will be updated to use the default schedule.|
### Properties
-| Name | Type | Required | Restrictions | Description |
-|----------------------|---------|----------|--------------|-------------|
-| `spend_limit_micros` | integer | false | | |
+| Name | Type | Required | Restrictions | Description |
+|----------------------|---------|----------|--------------|-----------------------------------------------------------|
+| `spend_limit_micros` | integer | false | | Spend limit micros must not exceed MaxAISpendLimitMicros. |
## codersdk.UpsertUserAIBudgetOverrideRequest
@@ -14320,7 +14320,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
| Name | Type | Required | Restrictions | Description |
|----------------------|---------|----------|--------------|---------------------------------------------------------------------------------------------------|
| `group_id` | string | true | | Group ID is the group the user's spend is attributed to. The user must be a member of this group. |
-| `spend_limit_micros` | integer | false | | |
+| `spend_limit_micros` | integer | false | | Spend limit micros must not exceed MaxAISpendLimitMicros. |
## codersdk.UpsertWorkspaceAgentPortShareRequest
diff --git a/enterprise/coderd/aibridge.go b/enterprise/coderd/aibridge.go
index be338982c0f2f..3fddd80371fb6 100644
--- a/enterprise/coderd/aibridge.go
+++ b/enterprise/coderd/aibridge.go
@@ -611,6 +611,22 @@ func (api *API) groupAIBudget(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.GroupAIBudget(groupBudget))
}
+// validAISpendLimit reports whether the limit is within the configurable
+// maximum, writing a 400 when it is not.
+func validAISpendLimit(ctx context.Context, rw http.ResponseWriter, spendLimitMicros int64) bool {
+ if spendLimitMicros <= codersdk.MaxAISpendLimitMicros {
+ return true
+ }
+ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
+ Message: "Invalid AI spend limit.",
+ Validations: []codersdk.ValidationError{{
+ Field: "spend_limit_micros",
+ Detail: fmt.Sprintf("Must not exceed %d.", codersdk.MaxAISpendLimitMicros),
+ }},
+ })
+ return false
+}
+
// @Summary Upsert group AI budget
// @ID upsert-group-ai-budget
// @Security CoderSessionToken
@@ -640,6 +656,9 @@ func (api *API) upsertGroupAIBudget(rw http.ResponseWriter, r *http.Request) {
if !httpapi.Read(ctx, rw, r, &req) {
return
}
+ if !validAISpendLimit(ctx, rw, req.SpendLimitMicros) {
+ return
+ }
// Capture the existing budget (if any) so the audit log records the
// before-state. An absent row leaves aReq.Old as the zero value.
@@ -750,6 +769,9 @@ func (api *API) upsertUserAIBudgetOverride(rw http.ResponseWriter, r *http.Reque
if !httpapi.Read(ctx, rw, r, &req) {
return
}
+ if !validAISpendLimit(ctx, rw, req.SpendLimitMicros) {
+ return
+ }
// Look up the new group first so a missing or forbidden group_id
// returns 404. We also need the group for the audit log.
diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go
index 9d9fc1776b745..46d57130246ca 100644
--- a/enterprise/coderd/aibridge_test.go
+++ b/enterprise/coderd/aibridge_test.go
@@ -2289,6 +2289,40 @@ func TestGroupAIBudget(t *testing.T) {
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
})
+ t.Run("SpendLimitMaximum", func(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ limit int64
+ wantError bool
+ }{
+ {name: "AtMaximum", limit: codersdk.MaxAISpendLimitMicros},
+ {name: "AboveMaximum", limit: codersdk.MaxAISpendLimitMicros + 1, wantError: true},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ adminClient, group := setupGroupAIBudgetTest(t)
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ budget, err := adminClient.UpsertGroupAIBudget(ctx, group.ID, codersdk.UpsertGroupAIBudgetRequest{
+ SpendLimitMicros: tc.limit,
+ })
+ if tc.wantError {
+ var sdkErr *codersdk.Error
+ require.ErrorAs(t, err, &sdkErr)
+ require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
+ return
+ }
+ require.NoError(t, err)
+ require.Equal(t, tc.limit, budget.SpendLimitMicros)
+ })
+ }
+ })
+
t.Run("AcceptsZeroSpendLimitToBlock", func(t *testing.T) {
t.Parallel()
@@ -2590,6 +2624,41 @@ func TestUserAIBudgetOverride(t *testing.T) {
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
})
+ t.Run("Upsert/SpendLimitMaximum", func(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ limit int64
+ wantError bool
+ }{
+ {name: "AtMaximum", limit: codersdk.MaxAISpendLimitMicros},
+ {name: "AboveMaximum", limit: codersdk.MaxAISpendLimitMicros + 1, wantError: true},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ adminClient, targetUser, group := setupAICostControlTest(t, aiCostControlTestOptions{GroupName: "override-max-group"})
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ override, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
+ GroupID: group.ID,
+ SpendLimitMicros: tc.limit,
+ })
+ if tc.wantError {
+ var sdkErr *codersdk.Error
+ require.ErrorAs(t, err, &sdkErr)
+ require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
+ return
+ }
+ require.NoError(t, err)
+ require.Equal(t, tc.limit, override.SpendLimitMicros)
+ })
+ }
+ })
+
t.Run("Upsert/RejectsUnknownGroup", func(t *testing.T) {
t.Parallel()
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 351150aefc9d6..5f88feb3454dc 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -6086,6 +6086,13 @@ export interface MatchedProvisioners {
readonly most_recently_seen?: string;
}
+// From codersdk/aibridge.go
+/**
+ * MaxAISpendLimitMicros is the highest AI spend limit that can be configured,
+ * $1,000,000 per member per budget period.
+ */
+export const MaxAISpendLimitMicros = 1000000000000;
+
// From codersdk/chats.go
/**
* MaxChatFileIDs is the maximum number of file IDs that can be
@@ -10218,6 +10225,9 @@ export interface UpsertChatUsageLimitOverrideRequest {
// From codersdk/aibridge.go
export interface UpsertGroupAIBudgetRequest {
+ /**
+ * SpendLimitMicros must not exceed MaxAISpendLimitMicros.
+ */
readonly spend_limit_micros: number;
}
@@ -10228,6 +10238,9 @@ export interface UpsertUserAIBudgetOverrideRequest {
* be a member of this group.
*/
readonly group_id: string;
+ /**
+ * SpendLimitMicros must not exceed MaxAISpendLimitMicros.
+ */
readonly spend_limit_micros: number;
}
diff --git a/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx b/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx
index 98cb79f48ff5c..13e091bb7fef7 100644
--- a/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx
+++ b/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx
@@ -1,6 +1,8 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
+import { MaxAISpendLimitMicros } from "#/api/typesGenerated";
import { MockGroup } from "#/testHelpers/entities";
+import { MICROS_PER_DOLLAR } from "#/utils/currency";
import GroupSettingsPageView from "./GroupSettingsPageView";
const meta: Meta = {
@@ -98,6 +100,29 @@ export const AIBudgetDecimal: Story = {
},
};
+// A budget above the configurable maximum blocks saving.
+export const AIBudgetAboveMaximum: Story = {
+ args: {
+ showAISettings: true,
+ initialBudgetDollars: null,
+ },
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement);
+ const input = canvas.getByLabelText("Monthly limit per member");
+ const maxDollars = MaxAISpendLimitMicros / MICROS_PER_DOLLAR;
+
+ await userEvent.type(input, String(maxDollars + 1));
+ // Blur to surface the error, matching the touched-then-validate flow.
+ await userEvent.tab();
+ await expect(
+ await canvas.findByText("Enter an amount between 0 and $1,000,000."),
+ ).toBeInTheDocument();
+
+ await userEvent.click(canvas.getByRole("button", { name: "Save" }));
+ await expect(args.onSubmit).not.toHaveBeenCalled();
+ },
+};
+
export const SaveWithBudget: Story = {
args: {
showAISettings: true,
diff --git a/site/src/pages/GroupsPage/GroupSettingsPageView.tsx b/site/src/pages/GroupsPage/GroupSettingsPageView.tsx
index a2eee3fc739ba..71e81584efc66 100644
--- a/site/src/pages/GroupsPage/GroupSettingsPageView.tsx
+++ b/site/src/pages/GroupsPage/GroupSettingsPageView.tsx
@@ -1,7 +1,7 @@
import { useFormik } from "formik";
import type { FC, ReactNode } from "react";
import * as Yup from "yup";
-import type { Group } from "#/api/typesGenerated";
+import { type Group, MaxAISpendLimitMicros } from "#/api/typesGenerated";
import { Alert } from "#/components/Alert/Alert";
import { Badge } from "#/components/Badge/Badge";
import { Button } from "#/components/Button/Button";
@@ -15,7 +15,7 @@ import {
import { Label } from "#/components/Label/Label";
import { Spinner } from "#/components/Spinner/Spinner";
import { isEveryoneGroup } from "#/modules/groups";
-import { usdBudgetFormatter } from "#/utils/currency";
+import { MICROS_PER_DOLLAR, usdBudgetFormatter } from "#/utils/currency";
import {
getFormHelpers,
nameValidator,
@@ -31,13 +31,17 @@ type FormData = {
monthly_budget_per_member: string;
};
+const maxBudgetDollars = MaxAISpendLimitMicros / MICROS_PER_DOLLAR;
+const budgetRangeError = `Enter an amount between 0 and ${usdBudgetFormatter.format(maxBudgetDollars)}.`;
+
const validationSchema = Yup.object({
name: nameValidator("Name"),
quota_allowance: Yup.number().required().min(0).integer(),
- // Optional: empty is unlimited. A value must be zero or more; 0 disables.
+ // Optional: empty is unlimited. A value must be within the range; 0 disables.
monthly_budget_per_member: Yup.number()
.transform((value, original) => (original === "" ? undefined : value))
- .min(0, "Enter an amount of zero or more."),
+ .min(0, budgetRangeError)
+ .max(maxBudgetDollars, budgetRangeError),
});
interface AIBudgetFeedbackProps {
@@ -244,6 +248,7 @@ const UpdateGroupForm: FC = ({
onBlur={budgetField.onBlur}
type="number"
min="0"
+ max={maxBudgetDollars}
step="1"
placeholder="unlimited"
aria-invalid={budgetField.error}
diff --git a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx
index e26162a8d4dfd..74bc05ef252e5 100644
--- a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx
+++ b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx
@@ -3,8 +3,13 @@ import { expect, spyOn, userEvent, within } from "storybook/test";
import { API } from "#/api/api";
import { groupAIBudget, groupsForUser } from "#/api/queries/groups";
import { getUserAIBudgetOverrideQueryKey } from "#/api/queries/users";
-import type { GroupAIBudget, UserAIBudgetOverride } from "#/api/typesGenerated";
+import {
+ type GroupAIBudget,
+ MaxAISpendLimitMicros,
+ type UserAIBudgetOverride,
+} from "#/api/typesGenerated";
import { MockGroup, MockGroup2, MockUserMember } from "#/testHelpers/entities";
+import { MICROS_PER_DOLLAR } from "#/utils/currency";
import { UserAIBudgetOverrideDialog } from "./UserAIBudgetOverrideDialog";
const mockOverride: UserAIBudgetOverride = {
@@ -122,12 +127,14 @@ export const Uncapped: Story = {
async () => {
const budgetInput = body.getByLabelText("Custom monthly budget");
await expect(
- body.queryByText("Enter a monthly budget of 0 or more."),
+ body.queryByText("Enter a monthly budget between 0 and $1,000,000."),
).not.toBeInTheDocument();
await userEvent.click(budgetInput);
await userEvent.tab();
await expect(
- await body.findByText("Enter a monthly budget of 0 or more."),
+ await body.findByText(
+ "Enter a monthly budget between 0 and $1,000,000.",
+ ),
).toBeInTheDocument();
},
);
@@ -222,7 +229,9 @@ export const SubmitRequiresValueOrUncheck: Story = {
// Blur to surface the error, matching the touched-then-validate flow.
await userEvent.tab();
await expect(
- await body.findByText("Enter a monthly budget of 0 or more."),
+ await body.findByText(
+ "Enter a monthly budget between 0 and $1,000,000.",
+ ),
).toBeInTheDocument();
await expect(updateButton).toBeDisabled();
});
@@ -244,6 +253,43 @@ export const SubmitRequiresValueOrUncheck: Story = {
},
};
+// A budget above the configurable maximum blocks submit.
+export const SubmitBlockedAboveMaximum: Story = {
+ parameters: {
+ queries: [
+ {
+ key: getUserAIBudgetOverrideQueryKey(MockUserMember.id),
+ data: mockOverride,
+ },
+ ...groupQueries,
+ ],
+ },
+ play: async ({ step }) => {
+ const body = within(document.body);
+ const budgetInput = await body.findByLabelText("Custom monthly budget");
+ const updateButton = body.getByRole("button", { name: "Update" });
+ const maxDollars = MaxAISpendLimitMicros / MICROS_PER_DOLLAR;
+
+ await step("the maximum itself is submittable", async () => {
+ await userEvent.clear(budgetInput);
+ await userEvent.type(budgetInput, String(maxDollars));
+ await expect(updateButton).toBeEnabled();
+ });
+
+ await step("one dollar above the maximum blocks submit", async () => {
+ await userEvent.clear(budgetInput);
+ await userEvent.type(budgetInput, String(maxDollars + 1));
+ await userEvent.tab();
+ await expect(
+ await body.findByText(
+ "Enter a monthly budget between 0 and $1,000,000.",
+ ),
+ ).toBeInTheDocument();
+ await expect(updateButton).toBeDisabled();
+ });
+ },
+};
+
export const Loading: Story = {
beforeEach: () => {
spyOn(API, "getUserAIBudgetOverride").mockReturnValue(
diff --git a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx
index a7448793c88a4..80a7c8643043c 100644
--- a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx
+++ b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx
@@ -15,12 +15,13 @@ import {
saveUserAIBudgetOverride,
userAIBudgetOverride,
} from "#/api/queries/users";
-import type {
- Group,
- GroupAIBudget,
- ReducedUser,
- UpsertUserAIBudgetOverrideRequest,
- UserAIBudgetOverride,
+import {
+ type Group,
+ type GroupAIBudget,
+ MaxAISpendLimitMicros,
+ type ReducedUser,
+ type UpsertUserAIBudgetOverrideRequest,
+ type UserAIBudgetOverride,
} from "#/api/typesGenerated";
import { Alert } from "#/components/Alert/Alert";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
@@ -56,6 +57,7 @@ import { cn } from "#/utils/cn";
import {
dollarsToMicros,
formatBudgetUSD,
+ MICROS_PER_DOLLAR,
microsToDollars,
} from "#/utils/currency";
@@ -213,9 +215,13 @@ const OverrideForm: FC = ({
const selectedGroup = groupOptions.find((g) => g.id === selectedGroupId);
const overrideGroup = groupOptions.find((g) => g.id === override?.group_id);
- // A "0" budget is valid and disables AI; empty or negative is not.
+ // A "0" budget is valid and disables AI. Empty, negative, or above the
+ // configurable maximum is not.
const budgetAmount = Number(budgetDollars);
- const budgetValid = budgetDollars.trim() !== "" && budgetAmount >= 0;
+ const budgetValid =
+ budgetDollars.trim() !== "" &&
+ budgetAmount >= 0 &&
+ budgetAmount <= MaxAISpendLimitMicros / MICROS_PER_DOLLAR;
// Hold the error until the field is touched, so it doesn't flag immediately.
const budgetInvalid = overrideEnabled && budgetTouched && !budgetValid;
const budgetDisablesAI = budgetValid && budgetAmount === 0;
@@ -319,6 +325,7 @@ const OverrideForm: FC = ({
onBlur={() => setBudgetTouched(true)}
type="number"
min="0"
+ max={MaxAISpendLimitMicros / MICROS_PER_DOLLAR}
step="1"
aria-invalid={budgetInvalid}
aria-describedby={
@@ -332,7 +339,8 @@ const OverrideForm: FC = ({
id={`${budgetId}-error`}
className="m-0 text-sm text-content-destructive"
>
- Enter a monthly budget of 0 or more.
+ Enter a monthly budget between 0 and{" "}
+ {formatBudgetUSD(MaxAISpendLimitMicros)}.
)}
From d3f2ea3ac9f37bfcdcf100de0c89d52f47c207c1 Mon Sep 17 00:00:00 2001
From: Susana Cardoso Ferreira
Date: Wed, 29 Jul 2026 12:33:04 +0000
Subject: [PATCH 2/2] chore: address review comments
---
site/src/modules/groups.ts | 20 +++++++++-----
.../GroupSettingsPageView.stories.tsx | 18 +++++--------
.../GroupsPage/GroupSettingsPageView.tsx | 21 ++++++++-------
.../UserAIBudgetOverrideDialog.stories.tsx | 27 ++++++-------------
.../GroupsPage/UserAIBudgetOverrideDialog.tsx | 22 +++++++--------
5 files changed, 49 insertions(+), 59 deletions(-)
diff --git a/site/src/modules/groups.ts b/site/src/modules/groups.ts
index ef58ea459b23e..673850e4390d2 100644
--- a/site/src/modules/groups.ts
+++ b/site/src/modules/groups.ts
@@ -1,10 +1,18 @@
-import type {
- Group,
- OrganizationMemberWithUserData,
- ReducedUser,
- User,
- WorkspaceUser,
+import {
+ type Group,
+ MaxAISpendLimitMicros,
+ type OrganizationMemberWithUserData,
+ type ReducedUser,
+ type User,
+ type WorkspaceUser,
} from "#/api/typesGenerated";
+import { MICROS_PER_DOLLAR, usdBudgetFormatter } from "#/utils/currency";
+
+/** Highest AI budget that can be configured for a group or member, in dollars. */
+export const maxAIBudgetDollars = MaxAISpendLimitMicros / MICROS_PER_DOLLAR;
+
+/** Shown when an entered AI budget falls outside the configurable range. */
+export const aiBudgetRangeError = `Enter an amount between 0 and ${usdBudgetFormatter.format(maxAIBudgetDollars)}.`;
/**
* Union of all user-like types that can be distinguished from Group.
diff --git a/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx b/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx
index 13e091bb7fef7..a80aba46e5547 100644
--- a/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx
+++ b/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx
@@ -1,8 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
-import { MaxAISpendLimitMicros } from "#/api/typesGenerated";
+import { maxAIBudgetDollars } from "#/modules/groups";
import { MockGroup } from "#/testHelpers/entities";
-import { MICROS_PER_DOLLAR } from "#/utils/currency";
import GroupSettingsPageView from "./GroupSettingsPageView";
const meta: Meta = {
@@ -42,10 +41,8 @@ export const WithAIBudget: Story = {
await expect(canvas.getByLabelText("Monthly limit per member")).toHaveValue(
1000,
);
- const helper = canvas.getByText(/month maximum/i);
- await expect(helper).toHaveTextContent(
- "$7,000/month maximum, based on 7 members.",
- );
+ const helper = canvas.getByText(/month, based on/i);
+ await expect(helper).toHaveTextContent("$7,000/month, based on 7 members.");
},
};
@@ -93,10 +90,8 @@ export const AIBudgetDecimal: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Cents are kept when the amount is not a whole dollar.
- const helper = canvas.getByText(/month maximum/i);
- await expect(helper).toHaveTextContent(
- "$99.99/month maximum, based on 1 member.",
- );
+ const helper = canvas.getByText(/month, based on/i);
+ await expect(helper).toHaveTextContent("$99.99/month, based on 1 member.");
},
};
@@ -109,9 +104,8 @@ export const AIBudgetAboveMaximum: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Monthly limit per member");
- const maxDollars = MaxAISpendLimitMicros / MICROS_PER_DOLLAR;
- await userEvent.type(input, String(maxDollars + 1));
+ await userEvent.type(input, String(maxAIBudgetDollars + 1));
// Blur to surface the error, matching the touched-then-validate flow.
await userEvent.tab();
await expect(
diff --git a/site/src/pages/GroupsPage/GroupSettingsPageView.tsx b/site/src/pages/GroupsPage/GroupSettingsPageView.tsx
index 71e81584efc66..4bc3596e5d533 100644
--- a/site/src/pages/GroupsPage/GroupSettingsPageView.tsx
+++ b/site/src/pages/GroupsPage/GroupSettingsPageView.tsx
@@ -1,7 +1,7 @@
import { useFormik } from "formik";
import type { FC, ReactNode } from "react";
import * as Yup from "yup";
-import { type Group, MaxAISpendLimitMicros } from "#/api/typesGenerated";
+import type { Group } from "#/api/typesGenerated";
import { Alert } from "#/components/Alert/Alert";
import { Badge } from "#/components/Badge/Badge";
import { Button } from "#/components/Button/Button";
@@ -14,8 +14,12 @@ import {
} from "#/components/InputGroup/InputGroup";
import { Label } from "#/components/Label/Label";
import { Spinner } from "#/components/Spinner/Spinner";
-import { isEveryoneGroup } from "#/modules/groups";
-import { MICROS_PER_DOLLAR, usdBudgetFormatter } from "#/utils/currency";
+import {
+ aiBudgetRangeError,
+ isEveryoneGroup,
+ maxAIBudgetDollars,
+} from "#/modules/groups";
+import { usdBudgetFormatter } from "#/utils/currency";
import {
getFormHelpers,
nameValidator,
@@ -31,17 +35,14 @@ type FormData = {
monthly_budget_per_member: string;
};
-const maxBudgetDollars = MaxAISpendLimitMicros / MICROS_PER_DOLLAR;
-const budgetRangeError = `Enter an amount between 0 and ${usdBudgetFormatter.format(maxBudgetDollars)}.`;
-
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.
monthly_budget_per_member: Yup.number()
.transform((value, original) => (original === "" ? undefined : value))
- .min(0, budgetRangeError)
- .max(maxBudgetDollars, budgetRangeError),
+ .min(0, aiBudgetRangeError)
+ .max(maxAIBudgetDollars, aiBudgetRangeError),
});
interface AIBudgetFeedbackProps {
@@ -98,7 +99,7 @@ const AIBudgetFeedback: FC = ({
{usdBudgetFormatter.format(budgetAmount * memberCount)}
- /month maximum, based on{" "}
+ /month, based on{" "}
{memberCount}{" "}
{memberCount === 1 ? "member" : "members"}.
@@ -248,7 +249,7 @@ const UpdateGroupForm: FC = ({
onBlur={budgetField.onBlur}
type="number"
min="0"
- max={maxBudgetDollars}
+ max={maxAIBudgetDollars}
step="1"
placeholder="unlimited"
aria-invalid={budgetField.error}
diff --git a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx
index 74bc05ef252e5..3953bf5550d22 100644
--- a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx
+++ b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx
@@ -3,13 +3,9 @@ import { expect, spyOn, userEvent, within } from "storybook/test";
import { API } from "#/api/api";
import { groupAIBudget, groupsForUser } from "#/api/queries/groups";
import { getUserAIBudgetOverrideQueryKey } from "#/api/queries/users";
-import {
- type GroupAIBudget,
- MaxAISpendLimitMicros,
- type UserAIBudgetOverride,
-} from "#/api/typesGenerated";
+import type { GroupAIBudget, UserAIBudgetOverride } from "#/api/typesGenerated";
+import { maxAIBudgetDollars } from "#/modules/groups";
import { MockGroup, MockGroup2, MockUserMember } from "#/testHelpers/entities";
-import { MICROS_PER_DOLLAR } from "#/utils/currency";
import { UserAIBudgetOverrideDialog } from "./UserAIBudgetOverrideDialog";
const mockOverride: UserAIBudgetOverride = {
@@ -127,14 +123,12 @@ export const Uncapped: Story = {
async () => {
const budgetInput = body.getByLabelText("Custom monthly budget");
await expect(
- body.queryByText("Enter a monthly budget between 0 and $1,000,000."),
+ body.queryByText("Enter an amount between 0 and $1,000,000."),
).not.toBeInTheDocument();
await userEvent.click(budgetInput);
await userEvent.tab();
await expect(
- await body.findByText(
- "Enter a monthly budget between 0 and $1,000,000.",
- ),
+ await body.findByText("Enter an amount between 0 and $1,000,000."),
).toBeInTheDocument();
},
);
@@ -229,9 +223,7 @@ export const SubmitRequiresValueOrUncheck: Story = {
// Blur to surface the error, matching the touched-then-validate flow.
await userEvent.tab();
await expect(
- await body.findByText(
- "Enter a monthly budget between 0 and $1,000,000.",
- ),
+ await body.findByText("Enter an amount between 0 and $1,000,000."),
).toBeInTheDocument();
await expect(updateButton).toBeDisabled();
});
@@ -268,22 +260,19 @@ export const SubmitBlockedAboveMaximum: Story = {
const body = within(document.body);
const budgetInput = await body.findByLabelText("Custom monthly budget");
const updateButton = body.getByRole("button", { name: "Update" });
- const maxDollars = MaxAISpendLimitMicros / MICROS_PER_DOLLAR;
await step("the maximum itself is submittable", async () => {
await userEvent.clear(budgetInput);
- await userEvent.type(budgetInput, String(maxDollars));
+ await userEvent.type(budgetInput, String(maxAIBudgetDollars));
await expect(updateButton).toBeEnabled();
});
await step("one dollar above the maximum blocks submit", async () => {
await userEvent.clear(budgetInput);
- await userEvent.type(budgetInput, String(maxDollars + 1));
+ await userEvent.type(budgetInput, String(maxAIBudgetDollars + 1));
await userEvent.tab();
await expect(
- await body.findByText(
- "Enter a monthly budget between 0 and $1,000,000.",
- ),
+ await body.findByText("Enter an amount between 0 and $1,000,000."),
).toBeInTheDocument();
await expect(updateButton).toBeDisabled();
});
diff --git a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx
index 80a7c8643043c..9b7a292e68504 100644
--- a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx
+++ b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx
@@ -15,13 +15,12 @@ import {
saveUserAIBudgetOverride,
userAIBudgetOverride,
} from "#/api/queries/users";
-import {
- type Group,
- type GroupAIBudget,
- MaxAISpendLimitMicros,
- type ReducedUser,
- type UpsertUserAIBudgetOverrideRequest,
- type UserAIBudgetOverride,
+import type {
+ Group,
+ GroupAIBudget,
+ ReducedUser,
+ UpsertUserAIBudgetOverrideRequest,
+ UserAIBudgetOverride,
} from "#/api/typesGenerated";
import { Alert } from "#/components/Alert/Alert";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
@@ -53,11 +52,11 @@ import {
import { Label } from "#/components/Label/Label";
import { Separator } from "#/components/Separator/Separator";
import { Spinner } from "#/components/Spinner/Spinner";
+import { aiBudgetRangeError, maxAIBudgetDollars } from "#/modules/groups";
import { cn } from "#/utils/cn";
import {
dollarsToMicros,
formatBudgetUSD,
- MICROS_PER_DOLLAR,
microsToDollars,
} from "#/utils/currency";
@@ -221,7 +220,7 @@ const OverrideForm: FC = ({
const budgetValid =
budgetDollars.trim() !== "" &&
budgetAmount >= 0 &&
- budgetAmount <= MaxAISpendLimitMicros / MICROS_PER_DOLLAR;
+ budgetAmount <= maxAIBudgetDollars;
// Hold the error until the field is touched, so it doesn't flag immediately.
const budgetInvalid = overrideEnabled && budgetTouched && !budgetValid;
const budgetDisablesAI = budgetValid && budgetAmount === 0;
@@ -325,7 +324,7 @@ const OverrideForm: FC = ({
onBlur={() => setBudgetTouched(true)}
type="number"
min="0"
- max={MaxAISpendLimitMicros / MICROS_PER_DOLLAR}
+ max={maxAIBudgetDollars}
step="1"
aria-invalid={budgetInvalid}
aria-describedby={
@@ -339,8 +338,7 @@ const OverrideForm: FC = ({
id={`${budgetId}-error`}
className="m-0 text-sm text-content-destructive"
>
- Enter a monthly budget between 0 and{" "}
- {formatBudgetUSD(MaxAISpendLimitMicros)}.
+ {aiBudgetRangeError}
)}