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}

)}